Phase 2 pipeline-shape items

August 9, 2026 · View on GitHub

Investigated and shipped 2026-08-09.

The four items this page covers, from Documentation/research_roadmap.md §Phase 2:

#ItemOutcome
2.5Delete the graph moduleRemoved. Code, config keys, two dependencies, and every doc section
2.1Evidence-sufficiency retryShipped ON in default, off in fast. Fires on 9.7–11.1% of mixed, +0.008 to +0.017 nDCG@10 and +0.014 recall@10, zero per-query regressions across four runs
2.2Decomposition at rerankShape change shipped; sub-query scoring measured NEGATIVE (−0.046 max, −0.012 mean) on the 6 affected queries and is enabled by no shipped profile. First stage no longer fans out over sub-queries except behind compose_from_sub_answers
2.4Verifier model seamSeam shipped, default unchanged. ThinknCheck has no public weights; two suitable substitutes were found, wired and smoke-tested

Every number below was produced by running eval/run_eval.py on this tree. Where a measurement is missing or was not affordable, this page says so rather than estimating.


0. The baseline, and an honest note about corpus drift

The docs and mixed corpora index live Documentation/*.md, so this work's own documentation edits changed the corpus underneath the metric. Every run below therefore names its chunk count, and only same-chunk-count runs are compared to each other.

Runchunks (mixed)mixed nDCG@10mixed r@10docs nDCG@10docs r@10
Pre-change tree (snapshotted first, phase2_before.json)3170.9130.9580.7530.875
Settled tree, retry off (phase2_final_retry_off.json)3310.8880.9580.6800.875
Settled tree, shipped defaults, run 13310.8960.9580.7350.917
Settled tree, shipped defaults, run 2 (phase2_after.json)3310.9010.9720.7350.917

Two shipped-defaults runs are listed because the retry makes one LLM call when it fires, so the arm is not bit-reproducible. The spread (0.896–0.901 on mixed) is the size of that nondeterminism; the firing set is identical between them.

The pre-change 0.913 reproduces DECISIONS.md §4 exactly, so the snapshot is sound. The 0.913 → 0.888 gap is entirely corpus-side, and that is checkable rather than asserted: with the retry off, the first-stage code path is unchanged, and of the 72 mixed queries only 11 moved at all — every one of them docs-anchored, zero PDF-anchored. The graph-removal rewrites plus the new retry/decomposition/verifier sections added 14 chunks of fresh distractor prose to a 317-chunk corpus.

One gold row was orphaned by item 2.5

docs_d10 asks "How many model calls does knowledge-graph extraction spend on each chunk?" and anchors on the string "makes two LLM calls per chunk", which lived only in the indexing_pipeline.md knowledge-graph section that item 2.5 deleted. The row is now structurally unanswerable. The harness reports it as a coverage failure on both corpora rather than hiding it, which is Gate 2 working as designed — but it also scores 0 by construction and drags every future run down by ~0.014 on mixed and ~0.042 on docs.

It is left in place, not quietly editedeval/goldset/ is not this work's to change, and silently repairing a gold row to make one's own change look better is exactly the failure mode the honesty rule exists to prevent. Excluding it:

Run (mixed, n=71, docs_d10 excluded)nDCG@10recall@10
Pre-change tree0.91140.9577
Settled tree, retry off0.90060.9718
Settled tree, shipped defaults, run 10.90860.9718
Settled tree, shipped defaults, run 20.91410.9859
Run (docs, n=23, docs_d10 excluded)nDCG@10recall@10
Pre-change tree0.74270.8696
Settled tree, retry off0.70920.9130
Settled tree, shipped defaults0.76680.9565

So the shipped stack scores 0.9086–0.9141 on mixed against a pre-change 0.9114 — i.e. it straddles the baseline, well inside a noise floor of one query ≈ 0.014, while carrying 14 extra distractor chunks. Recall@10 is up in both runs (0.9577 → 0.9718/0.9859), and docs beats the pre-change baseline outright (+0.024 nDCG@10, +0.087 recall@10). Nothing regressed.


1. Item 2.5 — the graph module is gone

Deleted: rag_system/indexing/graph_extractor.py; GraphRetriever (retrieval/retrievers.py); GraphQueryTranslator (retrieval/query_transformer.py); the extraction block and networkx import in pipelines/indexing_pipeline.py; the graph_strategy constructor branch, _run_graph_query() and the graph_query routing branch in agent/loop.py; and the retrieval.graph keys in main.py.

networkx, fuzzywuzzy and python-Levenshtein had no other consumer once GraphRetriever went (verified by grep across *.py) and were removed from requirements.txt, requirements-docker.txt and rag_system/requirements.txt.

Triage is now two-way. Agent._normalize_triage() runs on every verdict from both routers and maps anything that is not an explicit direct_answer to rag_query, so a small utility model that still emits the retired graph_query label lands on the RAG path instead of a hasattr check that no longer exists.

Evidence (Documentation/research/academic-evidence-2026.md §6): GraphRAG loses on single-hop retrieval (64.78 vs 63.01 F1 on NQ; 60.92% vs 60.14% on GraphRAG-Bench); its multi-hop gains span +3 to +27 points depending entirely on how well the vector baseline is tuned; and it costs 41–57× at indexing (135s → 5,560–7,702s) and up to ~377× in query tokens (879 → 331,375 prompt tokens/query for MS-GraphRAG global). It was also unreachable — no shipped profile ever set graph_strategy.

Docs updated in the same change: retrieval_pipeline.md, indexing_pipeline.md, system_overview.md, architecture_overview.md, triage_system.md, prompt_inventory.md, verifier.md, docker_usage.md, rag_system/README.md, rag_system/DOCUMENTATION.md, README.md, DOCKER_README.md.

improvement_plan.md §9's graph bullet resolves to removed (proposed row in §5).


2. Item 2.1 — evidence-sufficiency retry

2.1 The signal, and the one that did not work

The roadmap says to trigger on the top score. Measured on the gold set, the raw top cosine similarity is anti-correlated with success and is unusable:

mixed, top cosinevalue
The 3 first-stage misses0.609, 0.629, 0.642
Successful queriesmin 0.441, p25 0.544, median 0.576, max 0.753

All three failures score above the median success. Any threshold catching all three fires on 94% of successful queries. Absolute similarity mostly encodes how close a query's phrasing sits to the corpus register, not whether the answer was found. RRF scores are worse still — every query's top RRF is 0.031–0.033.

What does carry signal is contrast — how far the best candidate stands above the background of everything else the query pulled in:

evidence = (cos_top − cos_background) / (1 − cos_background)

with cos_background the mean cosine of candidates from rank 6 down, and cos = 1 − _distance/2 on the L2-normalized v4 tables. The denominator rescales against this query's reachable headroom, keeping the score in 0–1 and comparable across queries.

2.2 Calibration

thresholdmixed fails caughtmixed successes firedmixed fire rate
0.101/33/69 (4.3%)5.6%
0.12 ← shipped1/35/69 (7.2%)8.3%
0.141/310/69 (14.5%)15.3%
0.203/318/69 (26.1%)29.2%

The brief's target — fire on the genuine failures without firing on >10% of successes — is not achievable on this gold set, and that is a finding, not a tuning failure. Catching all three misses costs 26% false-fire on mixed and 67% on docs. 0.12 was chosen as the largest threshold that respects the ≤10% budget; it catches one of the three real misses.

2.3 Measured effect

Retry off vs on, same tree, same corpus snapshot. Two on-runs, because the reformulation is an LLM call:

Armmixed nDCG@10mixed r@10docs nDCG@10docs r@10mixed fireddocs fired
off (mid-work tree, 313 ch)0.88890.95830.67810.8750
on, run 1 (same tree)0.90110.97220.70960.87507/72 (9.7%)5/24 (20.8%)
on, run 2 (same tree)0.90630.97220.73040.91677/72 (9.7%)5/24 (20.8%)
off (settled tree, 331 ch)0.88810.95830.67960.8750
on, run 1 (settled tree)0.89600.95830.73480.91677/72 (9.7%)5/24 (20.8%)
on, run 2 (settled tree)0.90140.97220.73480.91678/72 (11.1%)6/24 (25.0%)

Compare only within a chunk count. Both tree snapshots give the same verdict: the retry is positive on both corpora and on both metrics, four runs out of four.

  • Firing rate: 9.7% on mixed on the tree the threshold was calibrated against — inside the ≤10% budget — drifting to 11.1% on the settled tree as the corpus grew. That drift is the honest caveat on the threshold: it is a property of the corpus, not a constant.
  • The firing set is deterministic for a given corpus; only the rewrites vary, which is why the two runs on each tree differ.
  • Zero per-query regressions on mixed in any run. Three queries improved.
  • It repaired docs_d16 ("Does this project build an approximate-nearest-neighbour index…"), a genuine recall@10 = 0 miss, by rewriting it to "approximate nearest neighbor (ANN) data structure implementation and vector search execution…".
  • Cost: one enrichment-model call plus one extra retrieval, on ~10% of queries. mixed mean latency 96 ms → 186 ms averaged over all queries.

Verdict: ship enabled in default, disabled in fast. The delta is positive on both corpora and no query got worse. It is small — +0.008 to +0.017 nDCG@10 on mixed — and it is bought with an LLM call, so it belongs in the quality profile and not in the speed one.

Caveats worth carrying: atlas7 and hr are 1- and 2-chunk tables, so the background term is degenerate there and hr fires on 62.5% of queries. It is harmless (the better result set is kept either way) but those rows measure nothing. The retry is inert on legacy unnormalized tables and in fts_only mode, by design — no signal, no retry.


3. Item 2.2 — decomposition at rerank, not first stage

3.1 What was happening before

Sub-query fan-out did happen at the first stage. Agent._run_async submitted one full RetrievalPipeline.run() per sub-query to a 3-worker pool, for both the compose_from_sub_answers path and the aggregate path.

3.2 What runs now

query_decompositionrerankerFirst stageRerank scored against
offeitheronce, full queryfull query
on, compose_from_sub_answers: true (profile default)eitheronce per sub-query, parallelthat sub-query
on, compose_from_sub_answers: falseononce, full queryall sub-queries, aggregated
on, compose_from_sub_answers: falseoffonce, full query— (no rerank stage; sub-queries unused)
on, one sub-query after decompositioneitheronce, the resolved querythe resolved query

The compose_from_sub_answers path keeps first-stage fan-out behind its existing flag, as the brief allows: it needs a separate answer per sub-question to compose from, which one shared candidate set cannot produce. Everything else now retrieves once on the full original query.

3.3 Measurement

docs (24 queries), Qwen/Qwen3-Reranker-4B, retry off so it does not confound the arms. mixed with the reranker on was not run: at ~12.5 s/query it is ~15 minutes per arm × 3 arms, and the brief explicitly allows skipping it.

Armfirst-stage nDCG@10post-rerank nDCG@10rerank ms/query
decompose off0.67960.837712,471
decompose on, max0.67960.841516,984
decompose on, mean0.67960.849916,755

The first-stage number is byte-identical across all three arms, which is the structural check that matters: decomposition provably never touches the first stage any more. A fourth arm — decompose on with the reranker off — reproduced the off arm exactly (0.680, no rerank stage), confirming the documented no-op.

The headline number is confounded; the honest split is worse

Only 6 of 24 queries decompose into more than one sub-query. The other 18 return a single (pronoun-resolved) sub-query, and those are not testing decomposition at all — they are testing "rerank against the decomposer's rewrite of the query". Splitting them:

Subsetoffmaxmean
All 240.83770.84150.8499
The 6 genuinely decomposed0.88620.8406 (−0.046)0.8740 (−0.012)
The 18 single-sub-query (rewrite only)0.82150.8418 (+0.020)0.8418 (+0.020)

On the queries decomposition actually affects, scoring against sub-queries at rerank is negative under both aggregates. Two queries carry it: docs_d14 (0.500 → 0.431 under max) and docs_d17 (0.818 → 0.613 under both). The whole- corpus "gain" comes entirely from the single-sub-query rows, where the win is query rewriting, not decomposition — and even there only 2 of 18 rows moved.

Verdict

  • The shape change ships. First-stage retrieval always uses the full original query. This is the part the evidence supports, and it is a strict reduction in work: the aggregate path used to issue N first-stage retrievals and now issues one.
  • Sub-query scoring at rerank is not switched on anywhere by default. The default profile ships compose_from_sub_answers: true, which never reaches the aggregation path. Nothing in a shipped profile enables it.
  • mean is the default aggregate, because it beat max on every subset measured (−0.012 vs −0.046 where it matters). Less bad, not good.
  • n_effective = 6 queries on one corpus. This measurement is too small to call the 2026 MultiConIR/SSRB finding wrong; it is big enough to say it did not reproduce here, so nothing was turned on because of it.

4. Item 2.4 — verifier model seam

4.1 Availability, checked 2026-08-09 against the HuggingFace Hub API

CandidateVerdict
ThinknCheck (arXiv 2604.01652, UPenn; 1B, 78.1 BAcc on LLMAggreFact)No public weights. The paper is real and checks out, but a Hub search for thinkncheck returns zero models and the paper links no release. Cannot be wired.
ibm-granite/granite-guardian-3.3-8bExists, Apache-2.0. 8B / ~16 GB — an order of magnitude over the "small local verifier" budget.
ibm-granite/granite-guardian-hap-38mExists, 38M, Apache-2.0 — but it is a hate/abuse/profanity RoBERTa classifier. Wrong task entirely: it does not score answer-vs-evidence.
MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli✅ MIT, 369 MB, no custom code. Generic NLI.
lytang/MiniCheck-DeBERTa-v3-Large✅ MIT, 1.74 GB, no custom code. Purpose-built grounded claim verification — the baseline ThinknCheck itself benchmarks against.
vectara/hallucination_evaluation_model (HHEM-2.1-open)Apache-2.0, 438 MB, but ships custom modelling code — gated behind VERIFIER_TRUST_REMOTE_CODE=1.

So the roadmap's two named candidates both fail — one has no weights, the other is either too big or the wrong task — but two suitable substitutes do exist, so the seam was wired and exercised rather than left as a stub.

4.2 What shipped

VERIFIER_MODEL env var / verification.model config key, plus verification.threshold (default 0.5). Unset ⇒ the LLM-prompt verifier, unchanged. Set ⇒ LocalNLIVerifier loads the model lazily on first use, splits the answer into sentences, scores each against the retrieved evidence as premise, and takes the minimum — one unsupported sentence makes the answer ungrounded, matching the binary semantics eval/judge.py already uses.

A model that cannot be loaded raises, printing the table above, rather than falling back to the LLM prompt. A verifier that silently is not the verifier you configured is worse than an error.

4.3 Smoke test on judge_validation.jsonl

The brief asked for a 5-case smoke test. Both models passed 5/5 on a balanced 5-case sample, so all 20 hand-labelled cases were run — it costs a minute once the weights are cached and 5 cases cannot distinguish the two:

VerifieragreementTPR (grounded, n=10)TNR (ungrounded, n=10)notes
lytang/MiniCheck-DeBERTa-v3-Large (1.74 GB)19/2010/109/10one false positive: u03_boilers_swapped scored 63% — it did not notice the two boilers' pressures had been swapped
MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli (369 MB)18/208/1010/10two false negatives: g07_sick_pay (22%) and g10_contractors (2%) — both grounded answers it called unsupported

The two fail in opposite directions, which is the useful result: MiniCheck is the more permissive of the pair and misses a swapped-entity error, the generic NLI model is stricter and rejects two correct answers. Neither is a drop-in improvement on the LLM-prompt verifier without its own validation run — eval/judge.py --validate reports TPR/TNR for the judge, and the same discipline should apply before any of these becomes a default. Nothing was made the default here.

One practical note: MiniCheck-DeBERTa-v3-Large ships only pytorch_model.bin (no safetensors) and its 1.74 GB blob stalled twice on first download in this environment before completing; the 369 MB model is the faster thing to try first.

Reproduce:

VERIFIER_MODEL=MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli \
  .venv/bin/python -c "
from rag_system.agent.verifier import LocalNLIVerifier; import json, os
v = LocalNLIVerifier(os.environ['VERIFIER_MODEL'])
for r in (json.loads(l) for l in open('eval/judge_validation.jsonl')):
    print(r['id'], r['label_grounded'], v.verify(r['question'], chr(10).join(r['evidence']), r['answer']).is_grounded)
"

[Confidence: N%] remains UX, not a calibrated measurement, and Documentation/verifier.md now says so in a callout. Changing the backend changes where the number comes from; it does not calibrate it.


5. Proposed improvement_plan.md Landed rows

For the gate to graduate — this page does not edit improvement_plan.md or research_roadmap.md.

AreaChangeVerify at
Retrieval2.5 Graph module removedGraphExtractor, GraphRetriever, GraphQueryTranslator, the graph_query triage outcome and the retrieval.graph / graph_strategy config keys are gone; networkx, fuzzywuzzy and python-Levenshtein dropped from all three requirements files. Contested gains, 41–57× indexing and up to ~377× query-token cost (Documentation/research/academic-evidence-2026.md §6)rag_system/indexing/ has no graph_extractor.py; requirements.txt; eval/decisions/phase2-pipeline.md §1
Retrieval2.1 Evidence-sufficiency retry — one conditional second retrieval on weak evidence, on in default and off in fast, triggered by candidate-set contrast rather than raw top similarity (which measured anti-correlated with success). Fires on 9.7–11.1% of mixed, +0.008–0.017 nDCG@10 and +0.014 recall@10, zero per-query regressions across four runsrag_system/pipelines/retrieval_pipeline.py::retrieve_candidates, rag_system/main.py retrieval.retry; numbers in eval/decisions/phase2-pipeline.md §2
Retrieval2.2 Decomposition applied at rerank — the first stage always runs once on the full original query; sub-queries score candidates at the rerank stage, aggregated by query_decomposition.rerank_aggregate. First-stage fan-out survives only behind the pre-existing compose_from_sub_answers flagrag_system/pipelines/retrieval_pipeline.py::_rerank_stage, rag_system/agent/loop.py; numbers in eval/decisions/phase2-pipeline.md §3
Verification2.4 Verifier model seamVERIFIER_MODEL / verification.model swaps the LLM-prompt verifier for a local NLI/verifier model; default unchanged. ThinknCheck has no public weights and Granite Guardian is either 8B or the wrong task, so two verified substitutes were wired and smoke-tested insteadrag_system/agent/verifier.py::LocalNLIVerifier, Documentation/verifier.md; numbers in eval/decisions/phase2-pipeline.md §4
Evaleval/run_eval.py now drives RetrievalPipeline.retrieve_candidates() instead of calling the retriever directly, so first stage, rerank and retry are all the shipped code path; --retry, --decompose and --aggregate addedeval/run_eval.py, eval/README.md
Hygiene§9's Graph-RAG bullet ("finish it or delete it") resolves to deletedeval/decisions/phase2-pipeline.md §1

Not resolved here

docs_d10 in eval/goldset/docs.jsonl is orphaned by item 2.5 and needs retiring or re-anchoring by whoever owns the gold set (§0).


6. Limits of this evidence

  • 72 English queries on one laptop. One query is ~0.014 nDCG@10 on mixed. The retry's +0.008 is inside that noise floor on mixed; the reason to ship it is that it is positive on both corpora across three runs with zero regressions, not that any single delta is significant.
  • The retry's calibration set is the same 72 queries it is evaluated on. With three first-stage misses to calibrate against, the threshold is fitted to a handful of points. Treat 0.12 as a starting value, not a tuned constant.
  • atlas7 and hr are 1- and 2-chunk tables. Their contrast scores are meaningless and their rows are plumbing checks, not measurements.
  • Latency numbers are from a shared GPU on an M2 Max and are indicative only.
  • Nothing here measures answer quality. These are retrieval metrics; the verifier smoke test is 20 hand-labelled cases, which is a small sample.

Gate resolution (2026-08-09): the orphaned gold row docs_d10 flagged above was re-anchored at the validation gate (embedder-identity-guard prose, topic graphindex_safety, recorded in the row's verification field). The eval numbers in this file predate that repair. Also fixed at the gate: the OCR probe's stale rapidocr_onnxruntime module name (Q4 of the GLM-OCR spike), an explicit lang=['english'] (+ OCR_LANG env) for RapidOCR replacing docling's ['chinese'] default, and ocrmac installed so macOS resolves OcrMacOptions.