# 07 — Indexing and storage ## Qdrant collections | Collection | Points | Vector | Purpose | |---|---|---|---| | `duocthu_v1` | 15,100 | 1,024-d, Cosine | The corpus | | `duocthu_v1__manifest` | 1 | 1-d `[0.0]`, never searched | Corpus binding record | ### Why a sidecar collection Qdrant has no collection-level metadata field, so the manifest must live in a point. Putting it inside the data collection would make `count()` one larger than the chunk count — and `qdrant_point_count == chunk_count` is an acceptance gate. `ingestion/load/manifest.py` states the reasoning: > A gate that needs an "except the manifest" footnote is a gate that will > eventually be read wrong. Manifest point id is the fixed UUID `00000000-0000-5000-8000-000000000001`, defined identically in `ingestion/load/manifest.py` and `apps/ai-service/rag/manifest.py`. ### Manifest payload | Field | Example | Compared at | |---|---|---| | `corpus_sha256` | sha256 of the whole `chunks.jsonl` | load time | | `chunk_count` | 15100 | load time | | `model_id` | `cohere.embed-v4:0` | **load time and startup** | | `dimensions` | 1024 | **load time and startup** | | `input_kind` | `search_document` | load time | | `provider`, `distance` | `cohere-v4`, `Cosine` | load time | Two independent checks use it: - **Load time** — `assert_compatible()` raises `CorpusMismatch` on any conflict, *before* creating or writing anything, so a refused load leaves the store untouched. A data collection that already holds points but has no manifest is itself a refusal. - **Startup** — `bootstrap.py::_verify_corpus_manifest` reads the sidecar and calls `rag/manifest.py::check_manifest`, comparing `model_id` and `dimensions` against the configured query embedder. A mismatch — or a missing manifest — raises `ManifestMismatch`, which crashes the process at import time, so the service never serves a query against an unattested corpus. The failure this prevents is silent: two embedding models can produce vectors of the same dimensionality, and Qdrant returns plausible nearest neighbours with no error. ## Point ids ```python POINT_NAMESPACE = uuid.UUID("6f0d6d1e-4c2a-5f6b-9a3d-2f8e1c7b4a90") point_id_for(chunk_id) = str(uuid.uuid5(POINT_NAMESPACE, chunk_id)) ``` Derived, never random, so a re-load converges instead of doubling. The namespace is described in-code as "a constant of the project, not a tunable" — changing it re-ids the whole corpus and orphans every loaded point. Consequence documented in `adapters/qdrant.py`: because ids are UUIDs, Qdrant's natural scroll order (point-id order) is effectively random. `find_by_section` therefore re-sorts by `part_index` before returning — `PARACETAMOL`'s dosing section came back `3, 4, 1, 2, 0`, opening mid-sentence on paediatric doses. A section served out of order is a clinical hazard, not a formatting one. ## Payload The whole chunk record passes through intact — `build_point` does `payload=dict(record)` with no whitelist. `ingestion/load/models.py` explains why: a whitelist would silently drop any field a later chunker adds. ### Indexed payload fields `CollectionSpec.indexed_fields`, created once at collection creation: | Field | Schema | Used by | |---|---|---| | `chunk_id` | keyword | `QdrantParentStore.get` | | `drug_id` | keyword | every retrieval route | | `section_key` | keyword | `find_by_section`, `find_by_indication`, `search_indication`, `search_lexical` | | `atc_codes` | keyword | **no runtime query filters on it today** | | `chunk_kind` | keyword | `find_by_drug`, `find_by_indication`, `search_indication` | | `has_quarantined_content` | bool | **no runtime query filters on it today**; it is read off the payload instead | `text` is **not** in `INDEXED_PAYLOAD_FIELDS`, yet `search_lexical` issues `MatchText` conditions against it. Qdrant requires an explicit full-text index for `MatchText`; without one the condition does not match as intended. This is recorded in [27-technical-debt.md](27-technical-debt.md) — the lexical route may be relying on the post-filter re-scoring in Python (`matched = sum(1 for t in tokens if t in text_normalized.split())`) rather than on the index. ## Loading `ChunkLoader.load()` (`ingestion/load/upsert.py`), in a fixed order: 1. `assert_compatible()` — corpus binding gate, before any write. 2. Create the collection + payload indexes if absent. 3. Write the manifest. 4. Validate each record (`validate_chunk_record`) and each vector's length against `spec.vector_size` — a wrong-sized vector is a whole-run defect, and failing on the first is cheaper than discovering it after 15,000 upserts. 5. Upsert in batches of 256 with `wait=True`. 6. Report `collection_count` vs `points_upserted`; `run.py` exits non-zero on mismatch. `assert_point_count(expected_chunks)` exists as the stricter v1 gate but `run.py` does not call it — it compares against `points_upserted` instead. ## PostgreSQL schema Four migrations, applied in sorted filename order by `python -m migrate` (`apps/ai-service/migrate.py`). All are `IF NOT EXISTS`, so re-running is safe. ```mermaid erDiagram rag_retrieval_trace ||--o| rag_answer_feedback : "trace_id FK, ON DELETE CASCADE" rag_conversation_turn }o..o{ rag_retrieval_trace : "conversation_id, no FK" rag_retrieval_trace { uuid trace_id PK text query_text text subject_scope text query_intent text decision text reason text resolved_drug_id jsonb citations text correlation_id varchar32 otel_trace_id timestamptz created_at } rag_conversation_turn { bigserial id PK text conversation_id text line timestamptz created_at } rag_answer_feedback { uuid feedback_id PK uuid trace_id FK "UNIQUE" varchar128 conversation_id varchar16 rating "helpful|not_helpful" text comment "<=2000 chars" timestamptz created_at timestamptz updated_at } ``` Indexes: `rag_retrieval_trace (created_at DESC)`; partial indexes on `correlation_id` and `otel_trace_id` where not null; `rag_conversation_turn (conversation_id, id)`; `rag_answer_feedback (created_at DESC)`. Notes: - `rag_conversation_turn` is append-only. There is **no retention or deletion path** anywhere in the repository — every user turn accumulates forever. See [16-security.md](16-security.md). - `subject_scope` and `query_intent` on the trace are the **server-resolved** values, not the caller's claim (`routers/rag.py` comment). - Access is `psycopg` with a **new connection per call** and no pool, with `connect_timeout=5`. The timeout matters: an unreachable-but-not-refusing host otherwise hangs on the OS TCP timeout, defeating the caller's fail-open `try/except`. ## Other storage | Location | Contents | Lifecycle | |---|---|---| | Docker volume `postgres-data` | PostgreSQL data | Host-local, no backup job in repo | | Docker volume `qdrant-data` | Qdrant storage | Host-local, no backup job in repo | | Docker volumes `caddy-data`, `caddy-config` | ACME certs | Managed by Caddy | | Docker volumes `prometheus-data`, `tempo-data`, `grafana-data` | Observability | Retention configured in Helm values only (7d / 24h); the Compose overlay sets no retention flags | | `ingestion/data/processed/embeddings/*.jsonl` | Embedding cache keyed by `(model_id, input_kind, sha256(text))` | Local disk, reused across runs | To move the corpus between machines, `ingestion/README.md` instructs snapshot + restore of the Qdrant collection rather than re-embedding — it is free and exact, whereas re-embedding costs real Bedrock spend.