Taha @ MJTHD
/
GitHub
Blog
·3 min read

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.

airagretrievaldokki

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 BY expression must match the indexed expression. With a halfvec cast, 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_model is recorded per version and per snapshot.
  • On a query, the service reads the embedding_model of 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.