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 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.

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 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.