ADR-0022 — F5 4-phase retrieval + [n] citation architecture (M5 Phase 1)¶
Status: Accepted Date: 2026-06-29 Sprint: v0.5 (M5 Phase 1) Authors: solution-architect (design), tech-writer (formatting) Features: F5 (4-phase retrieval), F6 AC-F6-3 (
[n]citations), F6 AC-F6-5 (save-to-wiki), F14 (context-window budget), F17 (CliAgentProvider.chat()) Invariants in force: I1, I2, I3, I5, I6, I7, I9 Resolves: AQ-v0.5-1, AQ-v0.5-2, AQ-v0.5-7 (P0/P1 Phase-1 blockers) Extends: ADR-0019 (chat: fills the M5-reservedmessages.citationscolumn + addsdone.citations; replaces the light-context-only retrieval with the real 4-phase pipeline), ADR-0002 (Qdrant point id == pages.id), ADR-0007/0011 (providerchat()contract unchanged), ADR-0009 (NB-4 cost). Supersedes: nothing.
1. Context¶
M5 Phase 1 is the retrieval foundation. ADR-0019 (M4 Phase 3) shipped chat with a deliberately
minimal context strategy — build_chat_context() reads purpose.md + overview.md and passes one
string as retrieval_context — and explicitly deferred F5 4-phase RAG and [n] citations to M5.
ADR-0019 reserved the messages.citations JSONB column (always [] in M4) for exactly this work.
This ADR locks the Phase-1 contract so backend-engineer (retrieval pipeline, GET /search, chat
wiring, CliAgentProvider.chat()) and frontend-engineer (citation rendering, save-to-wiki enable) can
build against a frozen interface. It also unblocks the three M4 carry-forwards (F6 AC-F6-3, F6 AC-F6-5,
F17 CliAgentProvider.chat()) that are otherwise empty stubs visible to the user.
Ground truth that drives the design (verified in code):
- A
pagesrow tracks araw/sources/source document, not awiki/page. The Qdrant point (id == pages.id, ADR-0002) embeds the raw source text (orchestrator.py:text_for_embedding = raw_bytes.decode(...)).pages.file_pathis araw/sources/...path. wiki/*.mdfiles produced bywrite_wiki_page()are not separately rowed inpages; they carrysources: [...]frontmatter back to the source paths (F3 traceability).- Therefore the searchable unit is the source page, and a
[n]citation must resolve to apagesrow (id + display title + derived slug). No new join table is needed. - The existing
synapse_pagesQdrant collection is single dense vector (cosine,EMBEDDING_DIM). There is no sparse/BM25 index configured. provider.chat(messages, retrieval_context: str)is locked (ADR-0007/0011); it must stay unchanged.chat/stream.pyalready bounds a chat turn bytoken_budget+timeout_seconds(ADR-0019 §2.2) and already consumes both coroutine-returning and async-generator provider shapes.
2. Decisions¶
2.1 Module & public interface — backend/app/rag/retrieval.py¶
async def retrieve(
query: str,
*,
vault_id: str,
context_window: int,
k: int = 8,
expansion_depth: int = 2,
) -> RetrievalContext: ...
retrieve() is a single bounded pass (I7) that makes zero inference calls and zero vault/
walks (I1): it reads Qdrant (bge-m3) + Postgres (pages/links/edges) + targeted source-file
bodies, and assembles a context string + citation map server-side (I3).
2.2 The four phases (in order)¶
- Tokenized / vector search (I9). Embed
queryvia the existingget_embedding_client()(bge-m3),client.query_points(collection_name="synapse_pages", query=vector, limit=k, with_payload=True), readingresponse.points. Note:qdrant_client.QdrantClient.search()was removed in qdrant-client 1.18;query_points()is the current dense top-k API (semantically identical — same cosine top-k, same single-densesynapse_pagescollection, sameScoredPointwherepoint.id == pages.id). Point ids arepages.id. Dense-only (see §3, AQ-v0.5-1). Score = cosine similarity. - Graph-expansion (I2). BFS over the
edgestable (the F4 4-signal output) from the seed pages,expansion_depth ≤ 2(hard cap), ordered by edgeweight DESC; also follow resolvedlinks.target_page_id. This readsedges— it does NOT call the GraphEngine or FA2.data_versionis unchanged across the call (AC-F5-5). - Token-budget allocation (F14).
budget_tokens = int(context_window * 0.20)(the "retrieved" slice of 60/20/5/15);budget_chars = budget_tokens * 4(char/4, AQ-v0.5-2). Candidates ranked: vector seeds (by cosine) then expansions (by edge weight). - Context assembly (I3). Walk candidates in rank order while budget remains; load each passage
(source-file body, per-passage capped), assign the next 1-based
n, append[n] <title>\n<passage>totext, and record the matchingCitation. Lowest-ranked candidates that don't fit are dropped (never mid-sentence truncate-without-drop, AC-F5-4).
2.3 Data structures (the contract)¶
class PageRef(BaseModel):
id: str # str(uuid) of the pages row (== Qdrant point id)
title: str # frontmatter title, else filename stem (never empty)
slug: str # slugify(title) — derived in code, NOT a DB column (§2.6)
class Citation(BaseModel):
n: int # 1-based, contiguous from 1
ref: PageRef
score: float
phase: Literal["vector", "expansion"]
class RetrievalContext(BaseModel):
query: str
text: str # assembled context WITH inline [n] markers (≤ budget)
citations: list[Citation] # len == count of distinct [n] in text (single authority)
token_budget: int
approx_tokens: int # char/4 of text, ≤ token_budget
data_version: int # snapshot read before assembly (AC-F5-5 proof)
2.4 The citation contract (F5 ↔ F6 ↔ frontend)¶
- Stored: on a chat turn with non-empty
citations,chat/stream.pywrites the serialized list into the assistantmessages.citationsJSONB column (reserved by ADR-0019). - Streamed: the
doneNDJSON event gains an additivecitationsfield — the compact projection[{"n","id","title","slug"}, ...](score/phase stored, not streamed). Additive → non-breaking for existing clients. - Resolved: the frontend reads the message's
citations, buildsn → {title, slug}, and post-processes the already-parsed-once settled markdown (I3) to turn each[n]text token into<sup role="link" title="{title}">[n]</sup>(hover = title; click = navigate by slug). This runs once on the settled message (memoized on raw + citations), never per streaming token. - Authority: the assembler emits each
[n]and itsCitationtogether →len(citations) == count of distinct [n]; they cannot drift.
2.5 GET /search (D4)¶
GET /search?q=<query>&vault_id=<id>&k=<int> → 200
{ "query", "context", "results":[{n,id,title,slug,score,phase}], "data_version", "approx_tokens", "token_budget" }
0-hit query → 200 with empty results + empty context (AC-F5-7a). Read-only; never bumps
data_version (AC-F5-5). Documented in openapi.json (make openapi, I8).
2.6 F5 adds NO migration¶
messages.citations already exists (reserved, ADR-0019) — F5 reuses it. slug is derived
(slugify(title or file_stem)) in rag/retrieval.py, not a new pages.slug column (no backfill,
no migration). Migration 0009 is reserved for Phase 3 review_items — Phase 1 is migration-free.
Qdrant usage is unchanged (same collection, same dense vector, read-only search — no new collection,
no sparse vector).
2.7 Chat & provider integration (carry-forwards)¶
- Chat (F6):
chat/stream.pyextracts the latest user message as the query, callsretrieve()once before streaming (I3 — not per-token), prepends the existing light file-context (build_chat_context, the 5% grounding header) toRetrievalContext.text, and passes the combined string asretrieval_context. The providerchat()signature is unchanged (I6); citations travel out of band in the orchestration layer and are stamped onto the assistant message +doneevent. - Save-to-wiki (F6 AC-F6-5): wire the existing disabled button to the existing
POST /ingest/from-textseam (ADR-0019 §2.7) — no new endpoint, no new ingest logic (I1/I6). CliAgentProvider.chat()(F17, AQ-v0.5-7): implement the M4 stub as a delegated streaming chat viaclaude-agent-sdk(mirroringdelegate_ingest):retrieval_contextinjected as the system/leading context (read-only chat — the agent is not grantedwrite_page); yields text deltas in the existing shape; cost per NB-4. Bounded by three caps, no new DB column:token_budget+timeout_seconds(both already flow throughrun_chat_stream) + an envCHAT_AGENT_MAX_TURNS(default 8) on SDK agent turns. With noANTHROPIC_API_KEYit raises a clean pre-stream config error — never a fake stream (dev default stays Ollama; mocked SDK in tests).
3. Resolved ambiguities (the P0/P1 Phase-1 blockers)¶
- AQ-v0.5-1 (P0) — dense-only, no BM25. The
synapse_pagescollection is single-dense; adding a named sparse vector is a collection-shape change we do not take on the dependency-root feature. The keyword phase = bge-m3 dense top-k viaclient.query_points()(qdrant-client ≥ 1.12;.search()was removed in 1.18). Hybrid (named sparse vector + RRF fusion) is a measured post-M5 enhancement, not a guess. Recall is widened by graph-expansion (Phase 2). - AQ-v0.5-2 (P0) — char/4, not tiktoken. Reuse the existing
_CHARS_PER_TOKEN=4convention (chat/context.py/stream.py). tiktoken is OpenAI-specific (mis-estimates Anthropic/Ollama), a new heavy dependency (I9), and unnecessary for a drop-lowest-ranked safety cap. Under-fill is the safe direction (never over-fill the window). - AQ-v0.5-7 (P1) — no
chat_max_itercolumn. CLI chat is bounded bytoken_budget+timeout_seconds(existing) +CHAT_AGENT_MAX_TURNSenv (new, default 8). A schema change for one provider's turn cap is not warranted; I7 is satisfied by three bounds.
(AQ-v0.5-3/-4/-5/-6 are Phase 2–4 decisions, resolved in v0.5-architecture.md §1 and finalized in ADR-0024/0025/0026 at their phase gates.)
4. The one accepted limitation¶
[n] resolves to the source-document pages row, not the synthesized wiki/ page, because pages
index raw/sources/ documents (§1). This is correct, honest grounding (the answer came from that
indexed text), but a user clicking [n] lands on the source rather than necessarily a polished wiki
page. Indexing wiki/ pages as first-class searchable units (needs a page_kind discriminator on
pages or a parallel index) is the clean fix and is explicitly deferred post-M5. This is a scope
boundary, not an invariant violation.
5. Do-NOT list (rejection triggers in PR review)¶
- Do NOT make an inference call inside
retrieve(). It is pure store reads + assembly (I1/I7). - Do NOT walk
vault/or trigger a rescan in retrieval orGET /search(I1). Read Qdrant + Postgres + targeted source files only.GET /searchmust leavedata_versionunchanged (AC-F5-5). - Do NOT call the GraphEngine / FA2 from the expansion phase. Read the
edgestable (I2). - Do NOT introduce a new embedding service, a new Qdrant collection, a sparse/BM25 index, tiktoken,
or sentence_transformers (I9 / AQ-v0.5-1 / AQ-v0.5-2). Reuse
get_embedding_client+ the existingsynapse_pagescollection. - Do NOT add a
pages.slugcolumn or any F5 migration.slugis derived;messages.citationsis reused (§2.6). - Do NOT resolve
[n]per streaming token, and do NOT add a second markdown parse. Citation<sup>decoration runs once on the settled, already-parsed message (I3 / G3, ADR-0019 §2.6). - Do NOT change
provider.chat(messages, retrieval_context). Citations travel out of band in the orchestration layer (I6). - Do NOT hardcode a provider/model and do NOT add an unbounded loop. Chat resolves via
resolve_provider_config("chat", ...);CliAgentProvider.chat()is bounded bytoken_budget+timeout_seconds+CHAT_AGENT_MAX_TURNS(I6/I7). - Do NOT fake a CLI chat stream when no API key is set. Raise a clean pre-stream config error;
cost follows NB-4 (
Decimal("0.00")+ WARNING when the SDK reports none). - Do NOT skip D4.
make openapimust includeGET /searchwith zero drift (I8). (No D2 change expected in Phase 1.)
6. AC mapping¶
| AC | Satisfied by |
|---|---|
| AC-F5-1 (4 phases in order) | §2.2 |
AC-F5-2 ([n] markers + PageRef{id,title,slug}) |
§2.3, §2.4 |
| AC-F5-3 (Qdrant/bge-m3 only, no new service) | §2.1, §3 (AQ-1/2), Do-NOT #4 |
| AC-F5-4 (budget respected; drop lowest-ranked) | §2.2 phase 3–4 |
AC-F5-5 (data_version unchanged) |
§2.2 phase 2 + RetrievalContext.data_version (§2.3) |
AC-F5-6 (GET /search + openapi) |
§2.5 |
| AC-F5-7 (0/1/multi/overflow cases) | §2.2 (BFS expansion + budget drop) |
| AC-F5-8 (context passed to provider.chat for all 3 backends) | §2.7 |
AC-F6-3 (citations stored + rendered <sup> + hover) |
§2.4 |
| AC-F6-5 (save-to-wiki) | §2.7 (reuse POST /ingest/from-text) |
| AC-F17-CHAT-1..3 (CLI chat, bounded, cost) | §2.7 (AQ-v0.5-7 + NB-4) |
7. Consequences¶
Positive. F5 is migration-free (reuses the reserved citations column, derives slug) and
inference-free in the hot path (pure store reads), so it is cheap, deterministic, and easy to test.
The provider chat() contract is filled, not changed (I6 honored). I2/I3/G3 are preserved by
construction — retrieval runs once before streaming, the expansion phase only reads edges, and
citation decoration is a single settled-message pass. The three M4 carry-forwards land together,
turning empty stubs into working features.
Negative / accepted. Dense-only retrieval may under-recall exact-keyword queries (mitigated by
graph-expansion; hybrid reserved post-M5). char/4 may slightly under-fill the 20% slice (safe
direction). [n] points at source documents, not wiki pages (§4 — deferred post-M5). CLI chat is
exercised only via a mocked SDK in dev (no key) — documented, not faked.
Follow-ups (later in M5 / post-M5). Phase 2 (F10) feeds auto-ingested deep-research synthesis into
the same retrieval corpus; Phase 3/4 (F9/F13) add review_items (migration 0009) and cascade-delete.
Post-M5: hybrid BM25 retrieval and wiki/-page-as-citation-target (page_kind).