Architecture¶
Audience: anyone about to change
nanorag/, and anyone who wants to explain this system in an interview. Read the README's architecture section first for the context and HLD diagrams; this document is the level below that.
Contents¶
- Design principles
- Module map
- Data model
- Component detail (LLD)
- The seams — where to plug things in
- Local → AWS
- Performance notes
- Invariants the tests enforce
Design principles¶
Five decisions shape everything else. Each has an ADR with the alternative that lost.
| # | Principle | Consequence you will feel |
|---|---|---|
| 1 | Everything runs offline and deterministically | Two runs produce identical numbers, so a delta is a real delta. Non-determinism would make the whole measurement curriculum untellable. |
| 2 | Concepts in notebooks, infrastructure in the package | You read BM25 in notebook 04, not in a library you are asked to trust. But you do not re-implement SQLite. |
| 3 | Swapping a backend must not change the harness | Hit, Embedder, Generator and Reranker are the only interfaces that matter. Bedrock plugs into all four. |
| 4 | Gold labels are true by construction | The corpus is generated from a fact graph, so there is no annotation-error floor under any number. |
| 5 | A measurement without an interval is an anecdote | paired_bootstrap is in the critical path of the PR template and the CI gate, not an optional extra. |
Module map¶
graph TB
subgraph data["Data layer"]
CO["corpus.py<br/><i>fact graph → docs, chunks, eval set</i>"]
CH["chunking.py<br/><i>7 strategies, stable ids</i>"]
EM["embed.py<br/><i>LSA · Hashing · ST · Bedrock</i>"]
end
subgraph store["Storage layer"]
ST["store.py<br/><i>sqlite :memory: — FTS5, vectors,<br/>NSW graph, ACL, versions, aliases</i>"]
TR["trace.py<br/><i>queryable trace store + diff</i>"]
end
subgraph retrieval["Retrieval layer"]
RE["retrieve.py<br/><i>fusion · rerankers · packing</i>"]
CX["context.py<br/><i>prompt assembly, volatility order</i>"]
GE["generate.py<br/><i>extractive · Bedrock · Claude</i>"]
end
subgraph measure["Measurement layer"]
ME["metrics.py<br/><i>recall, nDCG, κ, bootstrap</i>"]
JU["judge.py<br/><i>rubrics, calibration, bias probes</i>"]
PI["pipeline.py<br/><i>config object + evaluate()</i>"]
CS["costs.py<br/><i>tokens, cache, latency, unit economics</i>"]
end
subgraph teach["Teaching layer"]
CA["catalog.py<br/><i>deck trees + matrices as data</i>"]
TE["trees.py<br/><i>render · tabulate · execute</i>"]
VI["viz.py"]
TA["tables.py"]
end
AG["agent.py<br/><i>decompose → tool → sufficiency → stop</i>"]
BE["bedrock.py<br/><i>KB retriever + local→AWS map</i>"]
BO["bootstrap.py<br/><i>one-click env + seeding</i>"]
CO --> CH --> EM --> ST
ST --> RE --> CX --> GE
GE --> TR
RE --> PI
PI --> ME
ME --> JU
PI --> CS
RE --> AG
ST -.->|same Hit| BE
CA --> TE --> VI
TE --> TA
classDef d fill:#EAF4F7,stroke:#2F8CA3,color:#101318
classDef s fill:#EFEDFB,stroke:#6C5CE0,color:#101318
classDef r fill:#FBF1E2,stroke:#E9A83C,color:#101318
classDef m fill:#E9F3EE,stroke:#3F8F6E,color:#101318
classDef t fill:#F6F4EF,stroke:#C9C4B8,color:#3A414B
class CO,CH,EM d
class ST,TR s
class RE,CX,GE,AG r
class ME,JU,PI,CS m
class CA,TE,VI,TA,BO,BE t
| Module | Lines | Responsibility | Do not put here |
|---|---|---|---|
corpus.py |
1,220 | Fact graph, document rendering, chunk/eval schemas, MultiHop-RAG loader | Retrieval logic |
chunking.py |
288 | Seven strategies; stable chunk ids; token estimation | Anything that needs an index |
embed.py |
305 | Encoder implementations behind one interface; EmbedderInfo version pinning |
Similarity search |
store.py |
444 | SQLite schema, FTS5 lexical, exact + ANN vector search, ACL scoping, versions/aliases/tombstones | Ranking policy |
retrieve.py |
617 | Fusion, rerankers, pair features, dedup, ordering, packing | Prompt strings |
context.py |
178 | Prompt assembly, budget slices, provenance blocks | Model calls |
generate.py |
247 | Readers behind one interface + the fault-injection fixture | Scoring |
metrics.py |
283 | Every metric, plus κ and the paired bootstrap | Anything that runs a pipeline |
judge.py |
271 | Rubrics as artefacts, calibration, bias probes | Retrieval metrics |
pipeline.py |
190 | The config object, run(), evaluate() |
New metrics |
agent.py |
279 | Decomposition, tool choice, sufficiency, stop conditions, trace scoring | Single-shot logic |
costs.py |
206 | Token categories, prompt cache, latency model, unit economics | Real provider calls |
trace.py |
142 | Trace record, trace store, diff_traces |
Analysis |
catalog.py |
640 | The deck's trees and matrices as executable data | Behaviour |
bedrock.py |
249 | KB retriever, preflight, local→AWS mapping | Offline logic |
Data model¶
erDiagram
DOCUMENT ||--o{ PASSAGE : contains
DOCUMENT ||--o{ CHUNK : "chunked into"
CHUNK ||--o| VECTOR : "embedded as"
EVAL_QUESTION }o--o{ PASSAGE : "gold evidence anchors"
TRACE }o--|| EVAL_QUESTION : "answers"
TRACE ||--o{ PACKED_BLOCK : "packs"
PACKED_BLOCK }o--|| CHUNK : "references"
DOCUMENT {
string doc_id PK
string title
string source "newswire|filings|techblog|transcript|support_kb|incident"
string published "ISO date"
tuple acl "groups that may read it"
string tenant
string content_hash "drives the incremental path"
}
CHUNK {
string chunk_id PK "doc_id + ordinal + content hash"
string doc_id FK
int ordinal
string heading "carried from the document's own structure"
string text
string embedder_tag "pinned; a mixed index is an outage"
}
EVAL_QUESTION {
string qid PK
string query
string answer
string question_type "inference|comparison|temporal|null"
tuple evidence_anchors "gold, true by construction"
int hops
string slice "dev|frozen"
string persona "drives the ACL scope"
}
TRACE {
string trace_id PK
json candidates "id, score, rank, method"
json packed "sid, chunk_id, tokens, score"
json stage_ms "retrieve|rerank|pack|generate"
int k_collapse "post-filter damage, if any"
}
Why chunk_id = doc_id + ordinal + content hash. An unchanged chunk keeps its id across a
re-chunk and needs no new vector; a changed one gets a new id an upsert can write over.
Delete-then-insert orphans rows; upsert-then-tombstone does not. This single choice is what
makes the incremental freshness path cheap — see ADR-0004.
Component detail (LLD)¶
store.InMemoryIndex¶
classDiagram
class InMemoryIndex {
+db: sqlite3.Connection ":memory:"
-_cache: dict "version → ids, matrix, graph"
-_acl_cache: dict "(version, acl, filters) → visible ids"
+upsert(chunks, vectors, version, embedder_tag) int
+tombstone(chunk_ids, version) int
+compact(version) int
+set_alias(alias, version)
+resolve(alias) str
+lexical(query, n, acl_groups, filters) List~Hit~
+exact_vector(qvec, n, acl_groups, filters) List~Hit~
+ann_vector(qvec, n, ef_search, filter_mode) List~Hit~
+mixed_version_check(version) dict
-_allowed_ids(version, acl, filters) set
-_matrix(version, build_graph) tuple
}
class Hit {
+chunk_id: str
+score: float
+rank: int
+method: str
+doc_id, text, title, source, published, acl
}
InMemoryIndex ..> Hit : returns
Three implementation details worth knowing before you change this file:
tokenchars '_-'on the FTS5 table is load-bearing. The defaultunicode61tokenizer splitsERR_CONN_RESETintoerr/conn/reset, which appear in every incident report — so the one query lexical retrieval should win outright silently becomes its worst case. Notebook 04 §4.3 measures it.- The k-NN graph has long-range links. A pure k-NN graph is a lattice of tight neighbourhoods with no shortcuts; greedy search walks into the nearest cluster and cannot leave, so recall collapses as the corpus grows even though every edge is correct. Four random links per node (Kleinberg's construction, which HNSW's upper layers provide) restore the logarithmic hop count.
- The ACL set is cached, and the cache is cleared on every write. Re-deriving the visible
set per query costs more than the vector search it supports. The
_acl_cacheclear onupsert/tombstone/compactis the correctness half of that optimisation.
retrieve.ProxyCrossEncoder¶
Eight features that only a pair can produce, then logistic regression. It is far weaker
than a trained transformer and architecturally the same animal: it scores the pair, nothing is
precomputable, cost is linear in N, and batching rather than looping is what keeps latency
in the tens of milliseconds.
| Feature | What it measures | Why a bi-encoder cannot produce it |
|---|---|---|
coverage |
Query-term coverage, BM25-style length-normalised | Needs both sides |
proximity |
Tightness of the window containing matched terms | Needs the query's terms |
phrase |
Longest contiguous query n-gram appearing verbatim | Needs the query |
title |
Overlap with title + heading path | Needs the query |
maxsim |
Mean over query tokens of best-matching passage token | The key one — a bi-encoder compresses the passage before it has seen the query |
doc_cosine |
Whole-passage similarity in latent space | This one a bi-encoder can produce |
exact_id |
An identifier matched literally | Needs the query |
length |
Log passage length | Lets the model learn its own length prior |
fit() is plain gradient-descent logistic regression, deliberately small enough to read. The
interesting part is not the optimiser — it is that the training set must come from questions
the frozen slice does not contain, or the number you report at the end is a memory rather
than a result.
pipeline.RagPipeline¶
classDiagram
class RetrievalConfig {
+n_candidates: int = 100
+k: int = 8
+evidence_token_cap: int = 6000
+fusion: str "rrf|weighted|dense|lexical"
+rrf_k: int = 60
+alpha: float "dense share in weighted fusion"
+rerank: str "none|cross|late"
+rerank_depth: int = 50
+ann: bool
+ef_search: int = 64
+order: str "score|edges"
+index_version: str
+acl_groups: tuple
+filter_mode: str "pre|post"
}
class RagPipeline {
+index, embedder, cfg, generator, reranker, trace_store
+variant(name, **cfg_updates) RagPipeline
+run(query, qid, acl_groups) Trace
}
RagPipeline *-- RetrievalConfig
variant() is the whole ergonomics of the curriculum: change one thing, re-run, write down
the delta. It returns a copy with some knobs changed and shares the index, the encoder and
the trace store, so a sweep costs nothing but the evaluation itself.
The seams — where to plug things in¶
Every extension in EXTENSION-POINTS.md attaches at exactly one of these. If your idea does not fit one, it is probably two ideas.
flowchart LR
S1(["① Corpus / chunking<br/><code>chunking.STRATEGIES</code>"]) --> S2
S2(["② Encoder<br/><code>BaseEmbedder</code>"]) --> S3
S3(["③ First-stage retriever<br/><code>.search(query, cfg) → List[Hit]</code>"]) --> S4
S4(["④ Fusion<br/><code>rrf()</code> / <code>weighted_fusion()</code>"]) --> S5
S5(["⑤ Reranker<br/><code>.rerank(query, hits, depth)</code>"]) --> S6
S6(["⑥ Packer<br/><code>pack_context()</code> / <code>order_for_position()</code>"]) --> S7
S7(["⑦ Prompt<br/><code>context.build_prompt()</code>"]) --> S8
S8(["⑧ Generator<br/><code>.generate(query, packed) → Answer</code>"]) --> S9
S9(["⑨ Judge / metrics<br/><code>judge_all()</code>, <code>metrics.*</code>"])
S3 -.-> A(["⑩ Agent loop<br/><code>decompose</code> · <code>choose_tool</code> · <code>sufficiency_check</code>"])
A -.-> S6
classDef seam fill:#FBF1E2,stroke:#E9A83C,color:#101318
class S1,S2,S3,S4,S5,S6,S7,S8,S9,A seam
| Seam | Interface to implement | Example already in the repo |
|---|---|---|
| ① Chunking | fn(documents, **params) -> list[Chunk], registered in STRATEGIES |
chunking.contextual |
| ② Encoder | fit, encode_documents, encode_queries, .info: EmbedderInfo |
BedrockEmbedder |
| ③ Retriever | .search(query, n, cfg) -> list[Hit] |
GrepRetriever, BedrockKnowledgeBaseRetriever |
| ④ Fusion | fn(list_of_hit_lists, **kw) -> list[Hit] |
rrf, weighted_fusion |
| ⑤ Reranker | .rerank(query, hits, depth) -> list[Hit] |
LateInteractionReranker, BedrockReranker |
| ⑥ Packer | fn(hits, k, token_cap, ...) -> (list[Hit], int) |
pack_context, notebook 02's quota_pack |
| ⑦ Prompt | build_prompt(...) -> PackedContext |
volatility-ordered default |
| ⑧ Generator | .generate(query, packed) -> Answer |
BedrockGenerator, UngroundedGenerator |
| ⑨ Judge | .judge_all(question, answer, packed) -> dict[str, Verdict] |
BedrockJudge |
| ⑩ Agent | decompose, choose_tool, sufficiency_check |
rule-based defaults |
Local → AWS¶
nanorag.bedrock.LOCAL_TO_AWS holds this mapping in code so it stays honest.
| Local | Managed equivalent | What changes when you move |
|---|---|---|
chunking.* |
Knowledge Base chunking strategy | FIXED_SIZE / HIERARCHICAL / SEMANTIC / NONE, set at ingest. HIERARCHICAL is parent-document retrieval. Changing it is a full re-ingest. |
| SQLite FTS5 | The KB's vector store with overrideSearchType: HYBRID |
You no longer tune BM25 directly. Measure before assuming equivalence. |
InMemoryIndex vectors |
OpenSearch Serverless / Aurora pgvector / Pinecone / Redis | Mostly an ops and residency decision, not a recall one |
LsaEmbedder |
amazon.titan-embed-text-v2:0, cohere.embed-* |
Set on the KB at creation; changing it is a full re-ingest |
ProxyCrossEncoder |
amazon.rerank-v1:0, cohere.rerank-v3-5:0 |
rerankingConfiguration on the retrieve call; priced per document |
build_prompt + generator |
retrieve_and_generate, or Converse with your own prompt |
Keep your own packing if citations matter. You cannot debug a context you did not assemble. |
acl_groups pre-filter |
retrievalConfiguration.filter |
Same rule: pre-filter, never post-filter |
HeuristicJudge |
Converse with a judge model, or Bedrock Evaluations | Pin the model and rubric version; do not judge with the generator's family |
TraceStore |
CloudWatch + model invocation logging + your own store | Retrieved text in traces inherits the corpus's compliance boundary |
Performance notes¶
Two optimisations account for most of the speed, and both were found by profiling rather than by guessing. They are worth reading because the shape of both problems recurs constantly.
| Problem | Symptom | Fix | Effect |
|---|---|---|---|
resolve_gold re-normalised all 2,430 chunk texts per question |
Evaluation dominated by re.sub |
Memoise normalised chunk text keyed on list identity | Full eval 40 s → 9.7 s |
exact_vector fetched every row's full record to answer "which are visible?" |
The ACL check cost more than the vector search | Cache the visible-id set per (version, ACL, filters); fetch only the rows actually returned | ~4.6× on the hot path |
Both are the same mistake in different costumes: doing per-item work for a question whose answer only changes on a write.
Invariants the tests enforce¶
tests/ is not decoration. These are the properties a reviewer should not have to re-check.
| Invariant | Test |
|---|---|
| Reranking can never exceed the first-stage ceiling | test_reranking_can_never_exceed_the_first_stage_ceiling |
ANN recall rises monotonically with efSearch and reaches ≥0.9 |
test_ann_recall_rises_monotonically_with_ef_search |
| No persona ever receives a chunk outside its groups | test_no_persona_ever_receives_a_chunk_outside_its_groups |
Post-filtering collapses k; pre-filtering does not |
test_post_filtering_collapses_k_and_pre_filtering_does_not |
| A mixed-encoder index is detected | test_mixed_version_index_is_detected |
| RRF ignores score magnitude | test_rrf_is_rank_based_and_ignores_score_magnitude |
| Every gold anchor resolves under the shipped chunking | test_every_gold_anchor_resolves_under_the_shipped_chunking |
| Chunk ids are stable across rebuilds | test_chunk_ids_are_stable_across_rebuilds |
| Full-chain recall is never above evidence recall | test_full_chain_is_never_above_evidence_recall |
| κ punishes a judge that always passes | test_cohens_kappa_punishes_a_judge_that_always_passes |
| Every citation resolves to a packed chunk | test_every_citation_resolves_to_a_packed_chunk |
| Evidence never exceeds the token cap | test_evidence_never_exceeds_the_token_cap |
| The pipeline is deterministic | test_the_pipeline_is_deterministic |