241 lines
15 KiB
Markdown
241 lines
15 KiB
Markdown
|
|
---
|
||
|
|
name: qdrant-collection-management
|
||
|
|
description: "Manage Qdrant collections — consolidation, migration, deduplication, registry, and cleanup. Covers the scroll→transform→upsert→validate→delete pattern, batch processing with curl subprocess, and the collection registry file convention."
|
||
|
|
version: 1.1.0
|
||
|
|
author: Hermes Agent
|
||
|
|
license: MIT
|
||
|
|
metadata:
|
||
|
|
hermes:
|
||
|
|
tags: [qdrant, collections, migration, dedup, consolidation, registry]
|
||
|
|
related_skills: [local-vector-memory]
|
||
|
|
---
|
||
|
|
|
||
|
|
# Qdrant Collection Management
|
||
|
|
|
||
|
|
Manage Qdrant collections at scale — consolidate scattered collections into a unified store, deduplicate near-duplicate points, maintain a registry file, and clean up stale data.
|
||
|
|
|
||
|
|
## When to Use
|
||
|
|
|
||
|
|
- Consolidating multiple collections into one (e.g., merging kimi, vera into `memories`)
|
||
|
|
- Deduplicating points by cosine similarity
|
||
|
|
- Cleaning up stale/dormant collections
|
||
|
|
- Maintaining a collection registry with tags, ownership, and protection status
|
||
|
|
- Migrating points between collections (including re-embedding for dimension mismatches)
|
||
|
|
|
||
|
|
## MCP vs Curl
|
||
|
|
|
||
|
|
The MCP Qdrant tools (`mcp_better_qdrant_*`) only expose: list, search, add, delete. They do NOT cover: collection info (dimensions, config), point scrolling, batch search, or point count. For these operations, use curl directly against the Qdrant REST API.
|
||
|
|
|
||
|
|
**Always prefer MCP for what it covers** (list, search, add, delete). Fall back to curl for everything else.
|
||
|
|
|
||
|
|
## Consolidation Workflow
|
||
|
|
|
||
|
|
The proven pattern for merging collections:
|
||
|
|
|
||
|
|
### 1. Read source (scroll all points with vectors)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -s -X POST http://10.0.0.22:6333/collections/{source}/points/scroll \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"limit": 100, "with_payload": true, "with_vector": true}'
|
||
|
|
```
|
||
|
|
|
||
|
|
Paginate using `next_page_offset` until it returns null. For large collections (10K+), use a Python script with curl subprocess — the `requests` library can hit port exhaustion on the sandbox.
|
||
|
|
|
||
|
|
### 2. Transform payloads
|
||
|
|
|
||
|
|
Add `source` and `migrated_at` fields to every point. Normalize text fields: if the source uses `content` or `data` instead of `text`, copy to `text` for unified search.
|
||
|
|
|
||
|
|
```python
|
||
|
|
payload["source"] = "source_collection_name"
|
||
|
|
payload["migrated_at"] = "2026-06-28T00:00:00Z"
|
||
|
|
if "content" in payload and "text" not in payload:
|
||
|
|
payload["text"] = payload["content"]
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Upsert to target
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -s -X PUT http://10.0.0.22:6333/collections/{target}/points \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"points": [{"id": "...", "vector": [...], "payload": {...}}]}'
|
||
|
|
```
|
||
|
|
|
||
|
|
Batch in groups of 100. For large payloads, write to temp file and use `-d @file.json` to avoid argument list overflow.
|
||
|
|
|
||
|
|
### 4. Validate
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -s -X POST http://10.0.0.22:6333/collections/{target}/points/count \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"filter": {"must": [{"key": "source", "match": {"value": "source_name"}}]}}'
|
||
|
|
```
|
||
|
|
|
||
|
|
Count must match the source collection's point count.
|
||
|
|
|
||
|
|
### 5. Delete source
|
||
|
|
|
||
|
|
```bash
|
||
|
|
curl -s -X DELETE http://10.0.0.22:6333/collections/{source}
|
||
|
|
```
|
||
|
|
|
||
|
|
Or use `mcp_better_qdrant_delete_collection` for MCP-tracked deletions.
|
||
|
|
|
||
|
|
### Dimension Mismatch
|
||
|
|
|
||
|
|
If source has different vector dimensions than target (e.g., 768-dim → 1024-dim), re-embed using Ollama before upserting:
|
||
|
|
|
||
|
|
```python
|
||
|
|
r = subprocess.run(
|
||
|
|
["curl", "-s", "http://localhost:11434/api/embed",
|
||
|
|
"-d", json.dumps({"model": "snowflake-arctic-embed2:latest", "input": text})],
|
||
|
|
capture_output=True, text=True
|
||
|
|
)
|
||
|
|
vec = json.loads(r.stdout)["embeddings"][0]
|
||
|
|
```
|
||
|
|
|
||
|
|
## Deduplication
|
||
|
|
|
||
|
|
Use cosine similarity search to find near-duplicates. The batch search API is efficient for large collections:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Build batch searches
|
||
|
|
searches = []
|
||
|
|
for pt in chunk:
|
||
|
|
searches.append({
|
||
|
|
"vector": pt["vector"],
|
||
|
|
"limit": 5,
|
||
|
|
"score_threshold": 0.97,
|
||
|
|
"filter": {"must_not": [{"has_id": [pt["id"]]}]}
|
||
|
|
})
|
||
|
|
|
||
|
|
# Execute batch
|
||
|
|
curl -X POST /collections/{name}/points/search/batch -d '{"searches": [...]}'
|
||
|
|
```
|
||
|
|
|
||
|
|
**Threshold guidance:**
|
||
|
|
- 0.99: exact duplicates (same text, different source)
|
||
|
|
- 0.97: near-duplicates (same fact, slightly different wording)
|
||
|
|
- 0.95: semantic duplicates (same meaning, different phrasing)
|
||
|
|
|
||
|
|
At 0.97 on a 30K-point consolidated collection, expect ~20% duplicates from overlapping sources.
|
||
|
|
|
||
|
|
**Important:** When using Python for dedup, the `requests` library may fail with `[Errno 99] Cannot assign requested address` from the sandbox. Use `subprocess.run(["curl", ...])` instead — curl works reliably. See `references/dedup-script.py` for the full implementation.
|
||
|
|
|
||
|
|
## Collection Registry
|
||
|
|
|
||
|
|
Maintain a registry file at `~/workspace/general/qdrant-collections.json` with:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"version": "1.0.0",
|
||
|
|
"collections": {
|
||
|
|
"name": {
|
||
|
|
"tags": ["project", "kb"],
|
||
|
|
"description": "...",
|
||
|
|
"owner": "project_name",
|
||
|
|
"status": "active",
|
||
|
|
"protected": false
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"tag_index": {
|
||
|
|
"project": ["col1", "col2"],
|
||
|
|
"kb": ["col1"]
|
||
|
|
},
|
||
|
|
"deleted_collections": {
|
||
|
|
"old_name": {
|
||
|
|
"date": "2026-06-28",
|
||
|
|
"reason": "Merged into memories",
|
||
|
|
"points": 1234
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Protected collections** (`"protected": true`) must never be modified or deleted without explicit user direction. Known protected collections: `girls_mom`, `memories`, `pandaneuro`, `private_court_docs`.
|
||
|
|
|
||
|
|
Update the registry after every collection change (merge, delete, create). Bump the version number.
|
||
|
|
|
||
|
|
## Unified Memory Schema
|
||
|
|
|
||
|
|
The `memories` collection uses a 10-type classification schema (replacing the old Hindsight-aligned 4-type schema). See `references/memory-schema-gc-proposal.md` for the full design with type taxonomy, garbage-collection strategy, and quality scoring.
|
||
|
|
|
||
|
|
**Current 10 types:** `fact`, `infrastructure`, `entity`, `preference`, `procedure`, `decision`, `event`, `experience`, `observation`, `reference` — each with a `mem_class` (semantic/episodic/procedural) for retrieval routing.
|
||
|
|
|
||
|
|
**Key payload fields:**
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"text": "Normalized content — the only field that gets embedded",
|
||
|
|
"type": "fact | infrastructure | entity | preference | procedure | decision | event | experience | observation | reference",
|
||
|
|
"mem_class": "semantic | episodic | procedural",
|
||
|
|
"entities": ["Rob", "10.0.0.6", "Qwen3 DFlash"],
|
||
|
|
"tags": ["infrastructure", "dgx", "vllm"],
|
||
|
|
"importance": 0.85,
|
||
|
|
"quality_score": 0.72,
|
||
|
|
"confidence": 0.9,
|
||
|
|
"timestamp": "2026-03-27T12:50:37Z",
|
||
|
|
"organized_at": "2026-07-15T09:00:00Z",
|
||
|
|
"source": "memories_tr",
|
||
|
|
"migrated_at": "2026-06-28T00:00:00Z",
|
||
|
|
"merged_from": ["id1", "id2"],
|
||
|
|
"contradiction_with": ["id3"],
|
||
|
|
"supersedes": ["id4"],
|
||
|
|
"superseded_by": "id5",
|
||
|
|
"_source": { /* original payload, untouched */ }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Key design decisions:**
|
||
|
|
- `type` + `mem_class` replace the old Hindsight-aligned 4-type schema (world/experience/observation/reference). Hindsight is no longer used.
|
||
|
|
- `_source` is nested (not flattened) to preserve original payloads from 13 source schemas without polluting the filterable namespace.
|
||
|
|
- `importance` is LLM-assigned (0.0-1.0) — a heartbeat check is 0.1, a court document is 0.9.
|
||
|
|
- `contradiction_with` tracks conflicting facts (e.g., "model is qwen" vs "model is deepseek").
|
||
|
|
- `supersedes`/`superseded_by` chain facts through versions — semantic/procedural types are superseded, never deleted on staleness alone.
|
||
|
|
- `quality_score` is weighted: 30% richness + 25% actionability + 20% uniqueness + 15% freshness + 10% confidence.
|
||
|
|
- **Long-term types (8):** fact, infrastructure, entity, preference, procedure, decision, observation, reference — NEVER deleted, only versioned via supersedes/superseded_by. The only exception is hard-duplicate merging.
|
||
|
|
- **Short-term types (2):** event, experience — consolidated into observations, then deleted after 90 days.
|
||
|
|
- Garbage collection deletes: noise/trivial (regex patterns, short text, no entities), hard duplicates (cosine >=0.97), and consolidated episodics (>90d, already rolled into an observation). Capped at 5 per run (5% of 100). Long-term types are NEVER deleted — only versioned via supersedes/superseded_by.
|
||
|
|
- **Error log:** `/home/n8n/workspace/general/cron_qdrant_errors.md` — append-only, timestamped entries. Only touched when errors occur; if no errors, the file is not created or modified. If it already exists from a prior run, append to it.
|
||
|
|
|
||
|
|
When the user says "save to memory", save to the Qdrant `memories` collection (10.0.0.22:6333, 1024-dim, snowflake-arctic-embed2). This is the consolidated primary memory store.
|
||
|
|
|
||
|
|
**At save time, the agent MUST classify the content and include these fields in the payload:**
|
||
|
|
- `type` — one of 10 types (fact, infrastructure, entity, preference, procedure, decision, event, experience, observation, reference)
|
||
|
|
- `mem_class` — semantic, episodic, or procedural
|
||
|
|
- `entities` — people, servers, IPs, tools, projects, models mentioned
|
||
|
|
- `tags` — 2-3 category tags for filtering
|
||
|
|
- `importance` — 0.0-1.0 (0.9+ critical, 0.5-0.8 useful, 0.1-0.4 noise)
|
||
|
|
- `confidence` — 1.0 user-stated, 0.7 agent-inferred, 0.5 single-episode
|
||
|
|
- `created_at` / `updated_at` — ISO 8601 timestamps
|
||
|
|
- `source` — "agent", "user", "skill", or "tool"
|
||
|
|
- `ttl_hint` — "slow" for semantic/procedural, "fast" for episodic
|
||
|
|
- `quality_score` — set to `None` (cron organizer computes this)
|
||
|
|
|
||
|
|
The agent reasons about the content inline — no extra LLM call needed. The cron organizer later backfills `quality_score` and handles cross-point work (dedup, supersession, contradiction, garbage collection).
|
||
|
|
|
||
|
|
Use the pattern in `references/individual-statement-upsert.md` for the curl + Ollama embed + upsert workflow. Use `mcp_better_qdrant_add_documents` only for file-based bulk saves.
|
||
|
|
|
||
|
|
## Pitfalls
|
||
|
|
|
||
|
|
- **Check before creating.** Before creating a new collection, always list existing collections first. The user may already have one with that name and purpose.
|
||
|
|
- **`requests` library fails from sandbox.** The Hermes execute_code sandbox can't reach 10.0.0.22 via Python's requests. Use `subprocess.run(["curl", ...])` instead — curl works from both sandbox and terminal.
|
||
|
|
- **Batch search needs vectors.** Qdrant's search API requires a vector — you can't search by ID alone. Scroll points with `with_vector: true` first.
|
||
|
|
- **`indexed_vectors_count` is NOT an activity indicator.** Qdrant's `indexing_threshold` is 10,000 — any collection under that shows 0 indexed regardless of usage. Don't use it to determine if a collection is active.
|
||
|
|
- **One-at-a-time with validation.** Never batch-delete collections. Migrate one, validate the count, then delete. This prevents silent data loss.
|
||
|
|
- **Never touch `pandaneuro`.** It's permanently off-limits.
|
||
|
|
- **KBs should NOT be merged into `memories`.** Project-scoped knowledge bases (comfyui_kb, hermes_kb, etc.) need isolation for scoped recall. Only merge conversation memories and raw data.
|
||
|
|
- **Cron job model pinning.** When creating a cron job, always pass both `model` and `provider` explicitly. Passing only `provider` causes the job to inherit the session model, which may not be the intended model for the cron workload.
|
||
|
|
- **Holographic sync supersedes manual saves.** With the Holographic→Qdrant sync cron active, the agent no longer needs to manually save facts to Qdrant. The "save to memory" workflow remains available for file-based bulk saves (PDFs, documents) via `mcp_better_qdrant_add_documents`.
|
||
|
|
- **UPSERT vs SET_PAYLOAD — critical distinction.** `PUT /collections/{name}/points` (upsert) **requires a vector** and **replaces the entire payload**. Using it for payload-only updates (e.g., setting `type` on existing points) will fail with "missing field `vector`" or silently wipe all other payload fields. For payload-only updates, use `POST /collections/{name}/points/payload?wait=true` (set_payload) which **merges** the new payload fields without touching vectors or existing fields. The consolidation workflow in this skill uses upsert because it's writing new points with vectors — that's correct. But any operation that updates payload on existing points MUST use set_payload.
|
||
|
|
- **BigInt point IDs break in jq/JS pipelines.** Point IDs derived from SHA-256 hashing are ~19-digit uint64s (>2^53, exceeding `Number.MAX_SAFE_INTEGER`). Any JS-based JSON tool (jq ≤1.6, node, most shell pipelines) silently rounds them to a different integer. When building JSON for Qdrant API calls, use Python's `json.dumps` which preserves arbitrary-precision integers exactly. Never pipe a point ID through jq. The `id` field in the JSON body must be a bare integer (not quoted) — Python's `json.dumps` emits it correctly.
|
||
|
|
- **`is_empty` filter matches absent keys.** `{"is_empty": {"key": "type"}}` matches points where `type` does not exist, is null, or is `[]`. This is the correct filter for finding untyped points. Do NOT use `must_not: [match type=...]` — Qdrant issue #9255 shows `match` conditions incorrectly return points whose key is absent, so the inverse would wrongly exclude untyped points. Verify `is_empty` behavior on your Qdrant version with a count probe before relying on it in production.
|
||
|
|
- **LLM agent non-compliance: move mechanics to code, not more instructions.** When an LLM-driven cron job ignores its own prompt (e.g., using upsert instead of set_payload, unfiltered scroll instead of `is_empty`), adding more instructions is the wrong fix — it makes the prompt longer and the compliance problem worse. The reliable fix is to move all mechanical operations (scroll, write-back, checkpoint, lease, logging) into a deterministic Python wrapper script that shells out to curl for network calls. Reduce the LLM to classification-only: text in, type out. No HTTP access, no endpoint to pick, no checkpoint to bookkeep. See `references/classify-batch-wrapper.md` for the production implementation with lease, validation, poison-pill guard, and stall tracking.
|
||
|
|
|
||
|
|
- **Wrapper script pitfalls (from production deployment):** When building a classify-batch wrapper, these 11 bugs were found by adversarial peer review and must be avoided: (1) Double `release_lease()` in poison-pill path — use a single atomic write. (2) `lease_expires` set to `now_iso()` instead of `now + timedelta(minutes=N)`. (3) No defense against LLM string IDs — coerce `int(item["id"])` and reject `float`/`bool`. (4) Empty LLM output advancing the checkpoint — release lease WITHOUT advancing. (5) `if offset` truthiness treats point ID 0 as null — use `if offset is not None`. (6) `valid_ids` type mismatch — force `int()` on both sides. (7) No stall reset on success — `stall.pop(offset_key)` + prune + cap. (8) No list guard on classifications — check `isinstance(raw_classifications, list)`. (9) Unknown types defaulting to `experience` — REJECT unknown types; defaulting permanently misclassifies. (10) Hallucinated IDs not validated — check `pid in valid_ids`. (11) `__null__` dropped by 50-entry cap — pop before sort, re-add after.
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- **Dedup script**: `references/dedup-script.py` — full Python dedup implementation using curl subprocess, batch search, and checkpointing
|
||
|
|
- **Registry template**: `references/registry-template.json` — starter collection registry file
|
||
|
|
- **Individual statement upsert**: `references/individual-statement-upsert.md` — pattern for upserting discrete facts as independent searchable points (one per statement) with deterministic IDs, source/category tags, and typo-correction workflow. Use when MCP `add_documents` chunking is too coarse.
|