ADR-0006 — The dense seam returns (Chunk, score), not RetrievalResult¶
- Status: Accepted (owner, 2026-08-13)
- Date: 2026-08-13
- Deciders: Andy Freeman (owner)
Context¶
VectorStoreProtocol.search was declared in Phase 1 to return
list[RetrievalResult], inherited from ARP's vector store shape (ADR-0001).
It had no implementation until Wave A of Phase 3, so the declaration was never
exercised — and building the first implementation surfaced that it conflicts
with a correction groundkit had already made on the lexical side.
RetrievalResult requires a source. Only Document carries one; Chunk
does not. BM25Index.search therefore returns (Chunk, score) pairs and
leaves the join to retrieval/search.py, where Retriever.search resolves
each hit against get_document_sources() and fails closed — raising
RetrievalError rather than emitting a citation it cannot verify. That
asymmetry is documented deliberately: BM25 doesn't build RetrievalResult
precisely because it cannot know a source.
A vector store has the same limitation and the same lack of a store handle, so
the Phase 1 signature left it only one way to satisfy the return type: read
source out of chunk.metadata. That is available — ingestion/chunking.py
seeds metadata={"source": document.source, **document.metadata} — and the
first implementation duly did it, failing closed when the key was absent.
The problem is that chunk.metadata["source"] is a snapshot taken at chunk
time, while documents.source in SQLite is the durable truth (ADR-0002)
that the lexical path already treats as authoritative. The two can disagree.
Re-ingest a document from a moved or renamed path and a dense hit cites the
stale path while a BM25 hit for that same document cites the current one —
in a single fused response, with no error raised on either side. Phase 3's
whole purpose is to return those two result sets together.
Decision¶
VectorStoreProtocol.search returns list[tuple[Chunk, float]], matching
BM25Index.search exactly. Resolving a chunk to its document's source, and
therefore constructing every RetrievalResult, belongs to
retrieval/search.py and happens against the metadata store alone.
Consequences that follow and are part of this decision:
- No store may read
chunk.metadata["source"]to build a citation. The key remains populated by the chunker (removing it is a separate change with its own migration cost, deliberately not bundled here) but is not load- bearing for retrieval. - Scores crossing the seam are still
>= 0.0with a deterministic tie-break, so the producer-side clamp closing ADR-0001 hazard 2's class of defect stays where it is;Retrieveris a backstop, not the enforcement point. - Both stores'
add/deletesemantics are unchanged. This ADR touches the read path only.
Alternatives considered¶
- Keep
RetrievalResultand populatemetadata["source"]reliably at ingest. Rejected: it makes a per-chunk copy of a document-level fact load-bearing, which is a second source of truth for the same value — precisely the shape ADR-0002 and SPEC.md §2 rule out. It also cannot be made correct by care alone; the copy goes stale on any re-ingest at a new path, and nothing detects it. - Pass a source-resolver callback into the vector store. Rejected: it hands the storage layer a retrieval-layer concern through a back channel, and every implementation would have to be trusted to call it. The seam boundary is the point.
- Give the vector store a metadata-store handle. Rejected: it inverts the dependency (index → retrieval), and makes every vector store implementation responsible for citation correctness rather than one place.
- Leave the asymmetry and normalize at the fusion layer. Rejected: fusion
would consume two different types and have to unpick a
RetrievalResultback into a chunk to rank it. The divergence would also survive as a live correctness bug in the dense-only path, not merely an inconvenience.
Consequences¶
- Wave C's RRF consumes two lists of identical shape, and the source join happens once, after fusion, on the surviving results — fewer joins than resolving per-retriever, and one place where the fail-closed rule lives.
_to_retrieval_resultand the "chunk has no source in metadata" error path are deleted fromindex/dense.py, along with their tests. A vector store can no longer fail for a citation-shaped reason, because it no longer makes citations.- A dense store is now usable with chunks that carry no metadata at all, which makes test fixtures simpler and removes a class of fixture-only failure.
- The Phase 1 protocol changed after being declared stable. That is the correct outcome — a Protocol with no implementation is a hypothesis, and this one was falsified the first time it met a real store. The signature parity conformance tests are what made the change safe to make.
References¶
- ADR-0001 — the ARP port whose vector-store shape this corrects; hazards 3 and 4 constrain the rest of the signature and are unaffected.
- ADR-0002 — SQLite as the durable truth for the document→source mapping.
- ADR-0004 — the sibling decision preventing the dense store from diverging from SQLite on embedding identity; this one prevents divergence on source.
- SPEC.md §2 (no in-process state shadowing persisted state) and §5.2
(
RetrievalResultscore normalization guaranteed by producers).