Architecture¶
Every stage below is configured from config.yaml and can be swapped, tuned, or
turned off. Solid paths are always on; the dotted paths (HyDE, the scope lanes,
the routed lane) are off by default; the code lane opens only when a query calls for it.
The query path¶
flowchart TD
Q["Question"] --> R{Intent routing}
R -->|prose, HyDE off| HY["Hybrid retrieval"]
R -.->|prose, HyDE on| H["Optional HyDE query expansion<br/>LLM drafts a hypothetical answer to embed"]
R -->|code intent| C0["Skip HyDE · widen pool · open code lane"]
H -.-> HY
C0 --> HY
HY --> D["Dense · ChromaDB<br/>bge-small-en-v1.5"]
HY --> S["Sparse · bm25s"]
HY -.-> SL["Scope lanes<br/>domain / path / file-type filters"]
HY --> CL["Code lane<br/>.ipynb / .py / .R / .sql / …"]
HY -.-> RT["Routed lane · generated_mode route<br/>primary pages a generated hit cites"]
D --> GF["Generated filter · inside every lane<br/>drop or route: generated chunks stay out"]
S --> GF
SL -.-> GF
CL --> GF
GF --> RRF["Reciprocal Rank Fusion (k=60)<br/>+ optional metadata boosts"]
RT -.-> RRF
RRF --> RR["Configured rerank policy<br/>cross-encoder / HTTP / lexical / none<br/>(experimental: Laya)"]
RR --> GT["Optional relevance gate<br/>drops weak chunks · abstains if none pass"]
GT --> EX["Optional small-to-big<br/>context expansion"]
EX --> G["Grounded generation<br/>answer + [n] citations + confidence"]
G --> V["Optional second-pass<br/>citation verification"]
Ingestion¶
The index is built from JSONL chunk files, one per source family. Each loader
normalises its inputs into the same chunk schema (text + metadata: source_file,
domain, file_type, has_code, wikilinks, and a configurable grouping
label — taxonomy.label, which defaults to course but is just as happily
project, client, or topic):
| Loader | Handles |
|---|---|
obsidian_parser |
Markdown notes; heading-aware sectioning; group/domain tagging; wikilink capture. |
pdf_loader |
Lecture PDFs and textbooks; OCR-capable for scanned pages. |
ipynb_loader |
Jupyter and R notebooks (.ipynb, .R, .Rmd, .py). |
code_loader |
Other source languages (.js/.ts/.sql/.go/.java/.c/.cpp/.rs/.sh/…) into a dedicated code lane. |
canvas_loader |
Obsidian .canvas files: one chunk per text node, its id keyed on the canvas path and node id (so an edited node keeps its id), with the node's labelled edges flattened into its text and metadata, so the graph needs no retrieval-time code. With canvas.label_tags on, #tags written in an edge label also become tags of the chunks of both nodes it joins. |
ocr_vlm |
Vision-model OCR path for hard scanned material. |
Selectable chunking
Oversized sections are split by one of four strategies: heading (the default —
packs paragraphs and sentences, so it respects document structure and wins on
well-structured PDFs), fixed (a strict sliding window, better for OCR walls of
text with no paragraph breaks), document (element-aware packing that keeps each
fenced code block, table, list and paragraph whole, falling back to the sliding window
only for one element that alone exceeds the size) or none (no splitting: each
section stays one chunk, which an embedding model truncates when it is long). Small
sections chunk the same under every strategy. Choose per run with
--chunking heading|fixed|document|none.
Hybrid retrieval¶
- Dense — sentence embeddings (bge-small-en-v1.5) in ChromaDB capture meaning and paraphrase.
- Sparse — BM25 (
bm25s) captures exact terminology, symbols, rare names, and code tokens that embeddings blur. -
Fusion — the two ranked lists are combined by Reciprocal Rank Fusion:
$$\text{RRF}(d) = \sum_{\ell \in \text{lanes}} \frac{1}{k + r_\ell(d)}, \quad k = 60$$
where $r_\ell(d)$ is document $d$'s rank in lane $\ell$. RRF needs no score normalisation across incompatible scales, and $k=60$ damps the influence of any one lane's top ranks so a single list can't dominate.
Lane weights are opt-in
Each lane's term can be scaled — w_ℓ / (k + r) — with retrieval.lane_weights, per
preset or per call. Every weight defaults to 1.0, so an untouched config is plain
RRF. See Lane weights.
Generated content never grounds an answer
Chunks an LLM wrote (knowledge maps, summaries, agent-instruction files) are
kept out of every lane by retrieval.generated_mode, whose shipped default is
drop: the dense lanes over-fetch and check each row's stored provenance, the
sparse lanes remove those rows before the top-k cut so each lane refills. Under route one more lane runs: it
searches the generated rows, reads the pointer sidecar
(retrieval.route.pointers_file) for the primary pages each hit cites, and
brings those pages into the pool as ordinary candidates. They fuse (the routed
lane weight) and rerank like any other, and each carries found_via. Route needs
retrieval.sparse_backend: bm25s. A call can also restrict every lane to one
file's primary chunks, or a page range of it, with source_file and pages. See
Route mode.
Scope routing¶
Queries that name a domain or content type are routed toward where they point:
retrieval.domain_signalsmaps aliases (e.g. "BI", "DataViz", "pytorch") todomainmetadata values.retrieval.content_signalsmaps phrases ("the handbooks", "meeting notes", "the reference books", "cheat sheet") to path substrings or file types.
With retrieval.scope_lanes on (it ships off; a call can also set scope_lanes), a
detected scope adds filtered dense + sparse lanes to the fusion. With it off, the
scope is still detected and reported in the retrieval echo, but no scope lane runs.
Routing is soft: in-scope chunks are guaranteed seats in the candidate pool, but the
reranker still makes the final call — so a wrong hint degrades gracefully instead of
returning nothing. Both dictionaries are config-only; extend them without touching code.
The code lane¶
Code and notebook chunks are a small slice of the corpus. For a query like "show me a complex ggplot from my code", a prose-oriented pipeline with HyDE on writes a prose draft that lands near lecture notes, and BM25's code hits get outvoted in fusion by textbook pages that merely repeat the keyword — so the reranker never even sees the user's own code.
The fix, triggered by a configurable code-intent signal list:
- Skip HyDE if it is on (a prose hypothetical hurts code retrieval).
- Widen the dense/sparse candidate pool.
- Reserve a filtered lane for script/notebook chunks so they always reach the reranker.
Concept queries are untouched; code queries get their own material back at the top.
Reranking and context expansion¶
The configured rerank policy is one of several deliberately different lanes:
cross_encoderreads each query/candidate pair in process.httpdelegates the same candidates to a configured OpenAI-style/v1/rerankservice.lexicalmeasures model-free query-term coverage.nonepreserves the fused RRF order for a no-rerank baseline.laya(experimental, off by default) scores each passage with a fine-tuned Laya checkpoint. It is refused unlessretrieval.laya.enabled, and never replaced by another mode.
Known in-process models carry their context limits in the reranker registry.
BAAI/bge-reranker-base, for example, accepts at most 512 input tokens; an
over-length configuration is rejected explicitly instead of failing later with an
opaque tensor-index error. Runtime model failures are returned as reranking errors,
distinct from generation-provider failures.
After reranking, an optional relevance gate (off by default) drops chunks scoring below a threshold and, when none pass, makes the answer abstain with a fixed "nothing relevant" reply and no LLM call. It judges the original question, reads either the active reranker's own score or the experimental Laya scorer's probability, and runs before the next step; see Relevance gate.
After that, an optional small-to-big step expands each survivor with its surrounding parent context before generation, trading prompt length for completeness.
Grounded generation¶
The generator answers from the retrieved excerpts only, emits inline [n]
citations and a confidence line, and can run a second pass that checks each citation
actually supports the sentence it's attached to. A provider registry resolves the
wire protocol, endpoint, default model, and secret environment variable; a query may
override only the configured provider/model pair, never inject an endpoint or key. If
generation is unavailable, the API returns a provider-aware error object and
retrieval-only continues to work.
Serving¶
Two FastAPI services, designed to run side by side:
serve_api(:8051) — the warm query endpoint./queryand/searchreturn stable evidence ids plus their retrieval origins;/chunks/{id}dereferences evidence whenlookup_availableis true;/providersreports backend readiness without secrets; and/compareruns bounded preset, reranker, or generation-provider branches. It loads the indexes and models once at startup and stays hot.manage_api(:8052) — the Corpus Ledger console backend: browse, retag, and delete documents; run ingest and maintenance jobs; and an in-app Info tab that draws this pipeline with a query/ingestion toggle.