Grounded answers: retrieval, pgvector, and embedding consistency
How Dokki turns a question into top-k chunks of the active snapshot — HNSW on halfvec, a shared embedding model, and sources you can look up.
Dokki's query plane is stateless and has exactly one job: answer a question grounded against the active snapshot. The path there is short, but every step has a trap.
Retrieval against exactly one snapshot
The retrieval SQL joins the chunks over the snapshot_members of the active
snapshot — so a query never sees anything that wasn't promoted:
SELECT c.content, c.metadata, c.embedding <=> $queryEmbedding AS distance
FROM vectors.chunks c
JOIN knowledge.snapshot_members sm ON sm.document_version_id = c.document_version_id
WHERE sm.snapshot_id = $activeSnapshotId
ORDER BY c.embedding <=> $queryEmbedding
LIMIT $k;
Which snapshot is active, the service knows through a cached pointer that it
invalidates on snapshot.activated — no table lookup per question.
Use the halfvec index. For the HNSW index to kick in, the
ORDER BYexpression must match the indexed expression. With ahalfveccast, cast on both sides:ORDER BY c.embedding::halfvec(1024) <=> $queryEmbedding::halfvec(1024)— otherwise it's a sequential scan. The cast also halves the storage (4 → 2 bytes per dimension).
The same model on both sides
The subtlest source of error in RAG: question and documents are vectorized with different embedding models. Then query and document vectors aren't in the same space and the cosine distance is garbage.
Dokki enforces consistency structurally:
- Ollama (
bge-m3) as its own deployment that both ingest and RAG call — one implementation, no duplicated model. embedding_modelis recorded per version and per snapshot.- On a query, the service reads the
embedding_modelof the active snapshot and embeds the question with exactly that model. - A model change is therefore not an in-place update but a full re-ingest into a new snapshot — activated only after eval.
Multilingual isn't a nice-to-have here
A collection like Lughaty contains Arabic learning material. bge-m3 is
explicitly multilingual and runs locally — with Arabic text (RTL, diacritics),
the model's AR coverage is directly retrieval-relevant. The embedding model is
thus not a pure infrastructure choice but a quality decision.
Optional: reranking
After the vector retrieval, a cross-encoder can re-sort the top-k chunks before
they go into the prompt — more precise when raw cosine distance is too coarse.
Controllable per request via rerank: true.
The answer knows its sources
An answer always includes the sources used, from the chunk metadata — filename, ordinal/page, distance, excerpt:
{
"answer": "The dual (المثنى) is formed by appending ...",
"snapshotId": "s5a4b3c2-...",
"embeddingModel": "bge-m3",
"sources": [
{
"filename": "arabic-grammar.pdf",
"ordinal": 142,
"distance": 0.18,
"excerpt": "The dual denotes exactly two ..."
}
]
}
Claude's native Citations API provides the text spans over the supplied
chunks; the sources[] mapping (filename, ordinal, distance) still comes from
the chunk metadata. The result is an answer where every statement can be traced
back to the page in the original document — the whole point of the exercise.
That closes the series: from the three-plane architecture through immutable snapshots and the quality gate to the grounded answer here.