ADR-012: Retrieval Stack — Markdown Files as Truth, SQLite FTS5 + sqlite-vec, Configurable Embeddings
Status: Accepted Date: 2026-06-09 Deciders: Yuxing Wu Supersedes: ADR-010 (LlamaIndex RAG engine) and ADR-011 (mem0 memory engine) — both since removed as dead docs Related: spec 006-knowledge-base, spec 007-memory, ADR-002, ADR-007, ADR-013
Context
The first cut of knowledge_base (ADR-010) and memory (ADR-011) shipped as two near-symmetric "stores you query over MCP", each with its own heavyweight engine:
- KB routed between a hand-rolled keyword backend and a LlamaIndex semantic backend through a
DispatchingKnowledgeBaseStore. LlamaIndex persisted its own on-disk index alongside the raw documents. - Memory used
mem0ai, which requires an LLM at write time (mem0 fact-extraction). The defaultllm_provider="none"madeadd_memoryreturn 503, and text was dual-written to both SQLite and chroma.
Three structural problems surfaced once both kinds were in use:
- Dual source of truth. mem0/chroma stored fact text in chroma and in SQLite; LlamaIndex stored a derived index that could silently diverge from the raw documents. There was no single authoritative copy to back up, diff, or rebuild from.
- Engine sprawl. Two RAG/memory frameworks (LlamaIndex, mem0), a vector DB (chroma), a hand-rolled keyword store, and a dispatcher — four heavy moving parts plus glue — for what is, conceptually, "retrieve over markdown."
- LLM-at-write-time for memory is friction when the consumer is already an LLM that can decide what to remember and write a clean fact itself.
The original ADR-010 explicitly parked sqlite-vec + FTS5 ("revisit when scale or zero-dependency policy demands it") and ADR-011 mirrored that reasoning. This redesign revisits exactly that: the configurable-embedding requirement and the files-as-truth requirement together change the calculus.
Decision
Drop LlamaIndex, mem0, chroma, the hand-rolled keyword store, and the dispatcher. Make markdown files on disk the sole source of truth, and keep all derived index/metadata in coffer.db via SQLite FTS5 (keyword/BM25) and sqlite-vec (vector). Embeddings come from an OpenAI-compatible, user-configurable provider. Any-format ingestion goes through a pluggable MarkdownConverter port (MarkItDown default).
Concrete shape:
- Files are truth, SQLite is a rebuildable index. KB documents live as normalized
docs/<doc-id>.md(plusraw/<doc-id>.<ext>provenance); memory facts live as per-fact<slug>.md+ a regeneratedMEMORY.md. Anything in SQLite (documents,chunks,documents_fts,vec_chunks) is reconstructable bycoffer reindexfrom the files. This kills the dual-source-of-truth bug at the root. - Three retrieval modes, all over the same files/index:
grep— ripgrep overdocs/(raw files, zero index, language-agnostic).keyword— SQLite FTS5MATCH … ORDER BY bm25(). A regular FTS5 table stores the chunk text once inside its index (still rebuildable from the markdown files, which remain the source of truth).vector— sqlite-vec KNN over chunk embeddings.hybrid— reciprocal rank fusion ofkeyword+vector(K = 60, deduped by(document_id, position)), so exact/CJK/identifier hits and paraphrase hits reinforce each other. Delivered in spec 006 (FR-011b): enablingvectorauto-enableshybridand makes it the KB's default mode. Default retrieval iskeyword+grep(zero config, offline). Vector is opt-in; if vector or hybrid is requested but embeddings are unconfigured, retrieval falls back to keyword and flags it — it never blocks.
- Configurable, OpenAI-compatible embeddings (DevPilot-style: one
AsyncOpenAIclient with a swappablebase_url). Per-corpus config:embedding_provider,embedding_model,embedding_base_url,embedding_credential_ref(keychain ref, never plaintext). Works against OpenAI / OpenRouter / Voyage / Jina / Gemini / Azure / DashScope and local Ollama / LM Studio through the same.embeddings.createcall, plus an optional in-processlocalprovider (fastembed) for zero-server offline embeddings. The embedding model is mutable — changing it re-embeds the corpus (files = truth), so there is no immutability lock. - Pluggable
MarkdownConverterport (can_handle(format)+convert(bytes) -> (markdown, metadata)), confined to infrastructure, dispatched by format. Default engine is MarkItDown (broad coverage, LLM-oriented, MIT); Docling / pandoc can be attached per format. Becauseraw/is retained, markdown can be regenerated with a better converter later. - One store, two faces. A single
knowledgeretrieval engine backs both the KB face (any-format → markdown, agent-read-only) and the memory face (per-fact markdown, shared across agents). The import-confinement contract that ADR-010 applied tollama_index*/ ADR-011 tomem0*is re-pointed at the new third-party libs (sqlite-vec loader, the converter libs, the OpenAI client) soapplication/anddomain/never import them directly.
Consequences
Positive
- One source of truth. Markdown files are authoritative; SQLite is fully rebuildable. Backup is one directory tree; corruption recovery is
reindex; a user can diff/grep/edit memory and KB content with ordinary tools. - One store, fewer deps. Everything lives in
coffer.db(FTS5 + sqlite-vec); chroma, LlamaIndex's persist layer, and mem0's vector path all leave the lockfile. The keyword path becomes BM25 instead of a hand-rolled term-frequency JSON scan, and the dispatcher disappears. - No LLM required to write memory. The agent writes a clean fact directly; the default install makes zero outbound calls. Embeddings (and thus any outbound call) are strictly opt-in per corpus.
- Configurable embeddings without a framework. A thin OpenAI-compatible client covers every mainstream provider and local server, which is the exact flexibility LlamaIndex's embedding adapters were being used for — minus the framework.
- Cheap mutability. Because files are truth, changing chunk params or the embedding model is a re-chunk/re-embed, not a store rebuild from a lossy index. This fixes ADR-011's "config is immutable post-create" friction.
Negative
- More in-house orchestration than ADR-010/011 accepted. We now own the chunker, the FTS5/vec glue, the retrieval fusion, and the converter dispatch — the very ~200–300 LOC ADR-010 and ADR-011 paid LlamaIndex/mem0 to avoid. We accept this: the surface is small, the concepts are ours, and there is no framework churn to track.
- sqlite-vec is a native extension. It must load on macOS arm64 and Linux in the PyInstaller bundle (ADR-008); packaging/loading is an open verification item.
- Converter quality is now our problem. MarkItDown's PDF/Docx fidelity, OCR, and the MarkItDown-vs-Docling choice are ours to validate per format. The
raw/retention + re-convertibility is the mitigation. - Less "resume-signal mainstream". ADR-010/011 valued legibility via well-known frameworks. SQLite FTS5 + sqlite-vec is arguably more legible to a reader (it is just SQLite), but loses the "I used LlamaIndex/mem0" signal. We judge the architectural win to dominate.
Alternatives Considered
Keep LlamaIndex (ADR-010) for KB. The strongest reason to keep it was its embedding adapters and loaders. But the configurable-embedding need is met more directly by a thin OpenAI-compatible client (one base_url swap reaches every provider), and LlamaIndex's persisted index actively fights files-as-truth: it wants to own a derived store that can diverge from the markdown. Rejected because the single-source-of-truth requirement is incompatible with a framework that insists on owning the index, and the one thing we wanted from it (embeddings) is a 50-line client.
Keep mem0 (ADR-011) for memory. mem0's value is fact-extraction + dedup at write time, which presupposes an LLM call we explicitly want to remove: the agent is the LLM and writes the fact itself. mem0 also dual-writes to chroma, the exact bug being eliminated. Rejected — the framework's core feature is the friction we are removing.
Keep chroma as the vector store, FTS5 for keyword. Workable, but it keeps a second datastore (and a second source of truth for vectors) alongside SQLite. sqlite-vec puts vectors in the same coffer.db as FTS5, the documents table, and the audit log — one file to back up, one writer, one rebuild path. Rejected for store uniformity: a second embedded DB earns nothing once vectors are rebuildable from files.
Hand-rolled keyword store (the current KB keyword backend) kept as-is. It is a term-frequency JSON scan — slower, no BM25, and a bespoke ranker to maintain. FTS5 is built into SQLite, gives bm25() for free, and removes code. Rejected; there is no reason to keep a worse re-implementation of what SQLite ships.
Files-as-truth with no SQLite index (grep only). Tempting for zero infrastructure, and grep remains a first-class mode. But keyword ranking (BM25) and vector recall need an index, and rebuilding one from files on every query does not scale. Rejected as the sole mode; retained as one of three.
Lock-in mitigation summary
The same confinement discipline as ADR-010/011 applies, re-pointed: the MarkdownConverter port and the OpenAI-compatible embedding client are the only infrastructure seams that touch third-party libs, behind kind-owned ports with fakes in tests and importlinter contracts keeping application/ and domain/ clean. Swapping the converter, the embedding provider, or even the vector extension is a one-adapter change. Crucially, because files are the source of truth, the deepest possible lock-in — a proprietary index format you cannot leave — is gone: any engine can be rebuilt from the markdown.