Writ Architecture.
Chapter 2 of 4

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.

This chapter answers

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.

Figure 2.1 Startup build and the corpus-hash cache Click to enlarge
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
ComponentKey parametersAnchor
Embedding modelall-MiniLM-L6-v2 via ONNX Runtime, CPU only, 384 dimensions, 128-token truncation, mean pooling + L2 normembeddings.py:70-142
Vector indexhnswlib, cosine space, ef_construction 200, M 16, ef_search 50; sidecar JSON validated with Pydantic on loadembeddings.py:25-27, 197-368
Keyword indexTantivy BM25 over trigger, statement, tags, body; trigger term frequency doubled; body every-other-token dilutedkeyword.py:19, 60-90
Query-time encoderLRU cache of 1,024 query embeddings; hits return defensive copiesembeddings.py:145-182
Fallback policyIf the ONNX model is missing, startup fails loud; a dev-only SentenceTransformer fallback requires an explicit env opt-inpipeline.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.

Figure 2.2 From query text to a merged candidate pool Click to enlarge
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.

Figure 2.3 Two-pass ranking and the post passes Click to enlarge
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 five terms, sorted descending"]
  STICKY["Sticky tiebreak<br/>adjacent scores within 0.02 reorder<br/>to match last-injected order<br/>(keeps prompt caches warm)<br/>requires descending input"]
  AUTH["Authority preference pass<br/>human outranks ai-provisional within a threshold<br/>(config: [retrieval] authority_preference_threshold, default 0.0 = off)<br/>runs LAST so the hard preference is final"]
  BUDGET["Context budget<br/>summary / standard / full projection"]
  OUT["rules, mode, total_candidates, latency_ms"]
  FP --> SEED --> PROX --> FINAL --> STICKY --> AUTH --> 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 (ranking.py). Weights must sum to 1.0 and are validated. The caller picks one of two profiles per query. A sixth term, bundle cohesion, was deleted on 2026-08-06: no weight improved all three retrieval metrics, and the methodology channel it was designed for shipped deterministic instead (benchmarks/RANKING-LEVERS-2026-08-06.md).

SignalSemantic (default)Literal modeDetail
Vector similarity0.5940.396Dense cosine, rank-normalized. Semantic mode is vector-dominant for paraphrase matching.
BM25 keyword0.1980.396Literal mode equal-weights the two for exact-phrase and rationalization queries.
Severity0.0990.099critical 1.0, high 0.75, medium 0.5, low 0.25 (ranking.py:53).
Confidence0.0990.099Static tier map (ranking.py:60), replaced by the rule's empirical positive ratio once graduated (50+ observations, ratio 0.75+).
Graph proximity0.0100.010Discrete 0 / 0.5 / 1.0 relative to the top-3 seeds.
Bundle cohesion0.0000.000Implemented 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)ModeReturns
under 2,000summaryTop 10. Abstraction summaries substitute for their member rules when available (deduplicated); ungrouped rules fall back to statement + trigger.
2,000 to 8,000standardTop 5 with statement, trigger, violation, pass example.
over 8,000fullTop 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).

As-built findings worth knowing

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 configurable but ships disabled (updated 2026-08-06). It only swaps when its threshold is above 0.0, which is now set by [retrieval] authority_preference_threshold in writ.toml and read at daemon startup. The default stays 0.0 because a sweep of the 193-query gold set measured no change from enabling it: the corpus holds one ai-provisional node and its category is not semantic-routed, so the default query path never ranks one. Enabling it is only meaningful for literal mode or an explicit node_types whitelist, which do admit it. The pass now runs after the sticky tiebreak; running it first inverted the preference outright, because the tiebreak could then promote a preferred ai-provisional rule above every human rule in its group. The protection that binds regardless is seed filtering: AI-provisional rules never seed the graph signal.