Retrieval pipeline
How a query becomes a ranked, budgeted list of rules. The pipeline (writ/retrieval/pipeline.py, ~900 lines) is a hybrid ranker: sparse keyword search and dense vector search run in parallel, merge by reciprocal rank, then a graph-aware second pass re-scores before a token budget shapes the response.
- What gets built at daemon startup, and why a warm restart skips the expensive part.
- The exact path from query text to ranked candidates, stage by stage.
- The scoring formula with its real weights, and the passes that run after scoring.
- Three switches that exist in the code but are inert at current defaults.
- Diagram convention: the dark green rounded block is where the flow starts (START or INPUT); orange blocks are outcomes; numbered steps show reading order.
2.1Built once at startup
build_pipeline (pipeline.py:722) runs once when the daemon starts. It loads every non-mandatory Rule plus the five ranked methodology types from Neo4j, builds both search indexes, and caches the graph's edges in memory. Mandatory rules never enter these indexes: they are excluded at build time (keyword.py:69) and delivered by the always-on channel instead (chapter 3).
Cold start is dominated by embedding the corpus (about 84% of the total, per the instrumentation cited at pipeline.py:751-760). So the vector index is persisted to disk keyed by a SHA-256 hash over every candidate's id and text (pipeline.py:512-530): if nothing changed, the daemon loads the index and skips encoding entirely; any text edit changes the hash and forces a rebuild.
flowchart LR
BOOT(["START: the daemon boots<br/>(writ serve)"]) --> LOAD
NEO[("Neo4j")] --> LOAD["_load_candidates<br/>non-mandatory Rules<br/>+ 5 methodology types"]
LOAD --> KI["KeywordIndex.build (Tantivy)<br/>trigger boosted x2, statement, tags,<br/>body diluted to x0.5"]
LOAD --> TXT["Corpus text per candidate:<br/>trigger + statement"]
TXT --> HASH["SHA-256 corpus hash<br/>over id + text pairs"]
HASH --> CHK{"Disk cache<br/>hash match?"}
CHK -->|"hit"| LOADIDX["Load hnswlib index<br/>from ~/.cache/writ/hnsw<br/>encode skipped entirely"]
CHK -->|"miss"| ENC["ONNX encode_batch<br/>all-MiniLM-L6-v2, CPU<br/>about 84% of cold start"]
ENC --> BUILDIDX["Build + persist hnswlib index<br/>cosine, 384-dim, atomic writes"]
NEO --> AC["AdjacencyCache<br/>every edge except BELONGS_TO,<br/>stored in both directions"]
NEO --> ABSL["Abstraction nodes<br/>loaded for summary mode"]
KI --> READY
LOADIDX --> READY
BUILDIDX --> READY
AC --> READY
ABSL --> READY
READY(["READY: all indexes warm,<br/>the query path is pure in-memory"])
classDef entry fill:#0B5E55,stroke:#083F39,color:#FFFFFF,font-weight:bold
classDef finish fill:#F6EEDF,stroke:#B4560F,color:#6B3D14,font-weight:bold
classDef hub fill:#DCE9E5,stroke:#0B5E55,stroke-width:2px
class BOOT entry
class READY finish
| Component | Key parameters | Anchor |
|---|---|---|
| Embedding model | all-MiniLM-L6-v2 via ONNX Runtime, CPU only, 384 dimensions, 128-token truncation, mean pooling + L2 norm | embeddings.py:70-142 |
| Vector index | hnswlib, cosine space, ef_construction 200, M 16, ef_search 50; sidecar JSON validated with Pydantic on load | embeddings.py:25-27, 197-368 |
| Keyword index | Tantivy BM25 over trigger, statement, tags, body; trigger term frequency doubled; body every-other-token diluted | keyword.py:19, 60-90 |
| Query-time encoder | LRU cache of 1,024 query embeddings; hits return defensive copies | embeddings.py:145-182 |
| Fallback policy | If the ONNX model is missing, startup fails loud; a dev-only SentenceTransformer fallback requires an explicit env opt-in | pipeline.py:624-719 |
2.2The query path, stages 1 to 4
Every call to query() (pipeline.py:231) is pure in-memory work. The two searches run first, each result set passes the same AND-chain of filters, and the union becomes the candidate pool with reciprocal-rank normalized scores.
flowchart TD Q(["START: a query arrives<br/>query(text, domain, budget_tokens,<br/>exclude, prefer, mode, project)"]) S2["Stage 2: BM25 keyword search<br/>top 50 candidates"] S3["Stage 3: vector search<br/>top 10 candidates<br/>query embedding LRU-cached"] F["Stage 1 filter, pure AND chain:<br/>already-loaded ids excluded,<br/>domain match, node-type whitelist,<br/>project scope (project + _shared)"] MG["Merge both lists<br/>reciprocal-rank normalize each signal:<br/>1 / (rank + 1)"] S4["Stage 4: adjacency enrichment<br/>each candidate's neighbors attached<br/>from the in-memory edge cache"] Q --> S2 --> F Q --> S3 --> F F --> MG --> S4 S4 --> NEXT(["NEXT: Stage 5 ranking<br/>(Figure 2.3)"]) classDef entry fill:#0B5E55,stroke:#083F39,color:#FFFFFF,font-weight:bold classDef finish fill:#F6EEDF,stroke:#B4560F,color:#6B3D14,font-weight:bold classDef hub fill:#DCE9E5,stroke:#0B5E55,stroke-width:2px class Q entry class NEXT finish
Candidate limits are named constants: BM25 takes 50, vector takes 10 (pipeline.py:55-56). The merged pool is therefore at most 60 candidates, typically fewer after filtering and overlap.
2.3Stage 5: the graph-aware second pass
Ranking runs twice on purpose. The first pass scores without any graph signal, and its top three results become seeds. The second pass then rewards candidates that sit close to those seeds in the graph. Seeding excludes AI-provisional rules (ranking.py:230-245), so unreviewed knowledge can never steer the graph signal.
flowchart TD FROM(["START: the merged candidate pool<br/>from Figure 2.2"]) --> FP FP["Stage 5a: first-pass score<br/>bm25 + vector + severity + confidence<br/>weights renormalized, graph term held at zero"] SEED["Top-3 proximity seeds<br/>ai-provisional rules excluded"] PROX["Stage 5b: graph proximity per candidate<br/>1-hop neighbor of a seed = 1.0<br/>2-hop = 0.5, otherwise 0.0<br/>seeds themselves score 0 (no self-boost)"] FINAL["Stage 5c: final weighted fusion<br/>all six terms, sorted descending"] AUTH["Authority preference pass<br/>human outranks ai-provisional within a threshold<br/>(threshold defaults to 0.0: disabled)"] STICKY["Sticky tiebreak<br/>adjacent scores within 0.02 reorder<br/>to match last-injected order<br/>(keeps prompt caches warm)"] BUDGET["Context budget<br/>summary / standard / full projection"] OUT["rules, mode, total_candidates, latency_ms"] FP --> SEED --> PROX --> FINAL --> AUTH --> STICKY --> BUDGET --> OUT classDef entry fill:#0B5E55,stroke:#083F39,color:#FFFFFF,font-weight:bold classDef finish fill:#F6EEDF,stroke:#B4560F,color:#6B3D14,font-weight:bold classDef hub fill:#DCE9E5,stroke:#0B5E55,stroke-width:2px class FROM entry class OUT finish
The scoring formula
score = w1 x bm25 + w2 x vector + w3 x severity + w4 x confidence + w5 x graph proximity + w6 x bundle cohesion (ranking.py:169-176). Weights must sum to 1.0 and are validated (ranking.py:94-100). The caller picks one of two profiles per query.
| Signal | Semantic (default) | Literal mode | Detail |
|---|---|---|---|
| Vector similarity | 0.594 | 0.396 | Dense cosine, rank-normalized. Semantic mode is vector-dominant for paraphrase matching. |
| BM25 keyword | 0.198 | 0.396 | Literal mode equal-weights the two for exact-phrase and rationalization queries. |
| Severity | 0.099 | 0.099 | critical 1.0, high 0.75, medium 0.5, low 0.25 (ranking.py:53). |
| Confidence | 0.099 | 0.099 | Static tier map (ranking.py:60), replaced by the rule's empirical positive ratio once graduated (50+ observations, ratio 0.75+). |
| Graph proximity | 0.010 | 0.010 | Discrete 0 / 0.5 / 1.0 relative to the top-3 seeds. |
| Bundle cohesion | 0.000 | 0.000 | Implemented but zero-weighted; the computation is skipped entirely when the weight is 0 (pipeline.py:454-474). |
The token budget picks the response shape
| Budget (tokens) | Mode | Returns |
|---|---|---|
| under 2,000 | summary | Top 10. Abstraction summaries substitute for their member rules when available (deduplicated); ungrouped rules fall back to statement + trigger. |
| 2,000 to 8,000 | standard | Top 5 with statement, trigger, violation, pass example. |
| over 8,000 | full | Top 10 with rationale and graph relationships included. |
Thresholds and per-mode limits: ranking.py:47-51; the projection logic is one shared function so the three modes cannot drift in shape (ranking.py:248-309).
1. The graph signal cannot reorder results at current weights. Its maximum contribution is 0.01 x 1.0 = 0.01, but the sticky tiebreak treats adjacent scores within 0.02 as tied (pipeline.py:107). The proximity computation runs on every query and is effectively inert.
2. The route-based Stage 1 filter is wired (updated 2026-07-31). build_pipeline now loads the Category route map (get_category_routes_by_node) and passes it into the pipeline (pipeline.py:863-898). The legacy fallback (Rule-only plus a three-domain exclusion) applies only when the route map is empty or incomplete, in which case the whole pipeline reverts wholesale rather than silently dropping uncategorized nodes.
3. The authority preference pass is disabled at its default. It only swaps when its threshold is above 0.0 and nothing configures it (pipeline.py:195, ranking.py:208). The protection that does bind is seed filtering: AI-provisional rules never seed the graph signal.