qa/corpus_query: multi-route retrieval when policy.multi_route=True (6c)

When the caller passes policy={"multi_route": True}, run_query now
fans out across four retrieval routes in parallel and merges:

  1. fts_body          — body BM25 (the only route in pre-6c)
  2. fts_title         — title-only BM25 via documents_fts
  3. fts_phrase        — verbatim 4-gram phrase MATCH; closes the
                         allusion gap ("always been at war" → 1984)
  4. core_keyword_match — TF-IDF core route for neologisms

Each route is fail-open: NotSupportedError → []. core_keyword
returns [] on SidecarBucketCorpus (no derivations in slim sidecar);
phrase / title / body all work cloud-side via the slim FTS5 sidecar.

Merge by MIN bm25 per document_root (FTS5 returns negative; lower
wins). core_keyword's positive match_count scores are kept only as
a tiebreaker when no FTS5 route surfaced that doc — handled
explicitly via score-sign discrimination since the scales are
incomparable.

Lifted helper: question_phrases(question, n=4) from query.py's
_question_phrases into arborist.qa.retrieval_routes — pure-stdlib
n-token window extractor, no stopword stripping (the diagnostic
signal IS the stopword).

policy=None / policy={} (no multi_route flag) keep the existing
body-only path — byte-identity gate from step 5 still green. 263
existing tests + 1 new multi-route test pass.

Still missing for full legacy parity: filter_by_title_relevance
integration in run_query (the 5-accept-path filter is available in
retrieval_routes.py since step 3 but isn't wired into the
orchestrator yet). That's the next sub-step — without it, the
multi-route merge over-recalls on noisy title overlaps.
This commit is contained in:
russell@unturf.com 2026-05-31 12:48:35 -04:00
parent d3b78025ff
commit 5fdd573c0a
No known key found for this signature in database
3 changed files with 170 additions and 10 deletions

View file

@ -48,6 +48,80 @@ from arborist.qa.verify import verify_claim_lattice
_POINTER_RE = _re.compile(r"\[E\d+(?:,\s*E\d+)*\]")
def _safe_route(corpus, method_name: str, query: str, limit: int) -> list:
"""Call corpus.<method_name>(query, limit=limit), returning [] on
NotSupportedError. Used for body + title routes both have the
same (query, *, limit) signature."""
fn = getattr(corpus, method_name, None)
if fn is None:
return []
try:
return fn(query, limit=limit)
except NotSupportedError:
return []
def _safe_phrase_route(corpus, phrases: list, limit: int) -> list:
"""fts_phrase wrapper — same fail-open contract."""
try:
return corpus.fts_phrase(phrases, limit=limit)
except NotSupportedError:
return []
def _safe_core_route(corpus, qtokens: list, limit: int) -> list:
"""core_keyword_match wrapper — same fail-open contract. Returns
[] on SidecarBucketCorpus (no derivations in slim sidecar)."""
fn = getattr(corpus, "core_keyword_match", None)
if fn is None:
return []
try:
return fn(qtokens, limit=limit)
except NotSupportedError:
return []
def _merge_routes_min_bm25(*route_results) -> list:
"""Merge per-route hit lists by MIN bm25 per document_root.
All FTS5-backed routes return negative bm25 (lower = better), so
MIN per doc_root gives the best score across routes. core_keyword
returns positive integer match_counts (higher = better) those
are NOT merged on raw value (incomparable scale); we keep them
in the result if no FTS5 route also matched, but FTS5 hits win
on tie. Result sorted ASC by score (so FTS5 negatives float to
the top; core_keyword positives sink to the bottom and only
appear when FTS5 returned nothing for that doc).
"""
from arborist.qa.corpus import Hit
by_root: dict[str, Hit] = {}
for route_hits in route_results:
for h in route_hits:
cur = by_root.get(h.document_root)
if cur is None:
by_root[h.document_root] = h
continue
# Replace if new score is "better": lower for FTS5 (negative),
# higher for core_keyword (positive int). Use sign as the
# "is bm25" tell — bm25 scores are always ≤ 0, match_counts
# always > 0.
cur_is_bm25 = cur.score <= 0
new_is_bm25 = h.score <= 0
if cur_is_bm25 and new_is_bm25:
if h.score < cur.score:
by_root[h.document_root] = h
elif new_is_bm25 and not cur_is_bm25:
# FTS5 route hit beats core_keyword-only hit
by_root[h.document_root] = h
# else: keep cur (either both core_keyword and we like
# higher count first, OR cur is FTS5 and new is core).
elif not cur_is_bm25 and not new_is_bm25:
if h.score > cur.score:
by_root[h.document_root] = h
return sorted(by_root.values(), key=lambda h: h.score)
def run_query(
corpus: Corpus,
question: str,
@ -99,17 +173,48 @@ def run_query(
timings: dict[str, float] = {}
t_start = _time.time()
# 1. Retrieve — fts_body is the only route currently shared across
# adapters. Oversample 4× so the post-retrieval title-boost has
# more candidates to rerank (without oversampling, the right
# primary-source article can be at rank 7-15 in body BM25 output
# and get cut before reranking sees it). Future: add fts_title /
# fts_phrase / core_keyword with NotSupported skip-and-merge.
# 1. Retrieve. Two shapes:
#
# - policy=None OR policy without "multi_route": body-FTS5 only
# (existing minimal pipeline; byte-identical to pre-step-6c).
# - policy.get("multi_route", False) is truthy: fan out across
# body + title + phrase + core_keyword, merge by MIN bm25 per
# document_root (best score across routes wins). Each route
# catches a different failure class of body-only retrieval —
# title for short authoritative pages, phrase for allusions
# ("always been at war" → 1984), core_keyword for neologisms
# that only surface in TFIDF cores.
#
# Oversample 4× per route so the post-merge title-boost has more
# candidates to rerank (without oversampling, the right primary-
# source article can be at rank 7-15 in body BM25 output and get
# cut before reranking sees it).
ts = _time.time()
try:
hits = corpus.fts_body(question, limit=top_k * 4)
except NotSupportedError:
hits = []
multi_route = bool(policy and policy.get("multi_route", False))
if multi_route:
from arborist.qa.retrieval_routes import question_phrases
per_route_limit = top_k * 4
body_hits = _safe_route(corpus, "fts_body", question, per_route_limit)
title_hits = _safe_route(corpus, "fts_title", question, per_route_limit)
phrases = question_phrases(question)
phrase_hits = _safe_phrase_route(
corpus, phrases, per_route_limit,
) if phrases else []
# core_keyword needs qtokens; reuse the title_query_tokens helper
# since legacy query() does the same thing.
from arborist.qa.query import _title_query_tokens
qtokens = list(_title_query_tokens(question))
core_hits = _safe_core_route(
corpus, qtokens, per_route_limit,
) if qtokens else []
hits = _merge_routes_min_bm25(
body_hits, title_hits, phrase_hits, core_hits,
)
else:
try:
hits = corpus.fts_body(question, limit=top_k * 4)
except NotSupportedError:
hits = []
# Shared title-boost rerank — lifts "Homer Simpson" main article
# above "You Only Move Twice" sibling on the homer query, etc.
# Same logic for SqliteShardCorpus + SidecarBucketCorpus.

View file

@ -10,11 +10,47 @@ expose ``.title`` and ``.document_root``.
"""
from __future__ import annotations
import re as _re
from typing import Callable, Iterable
from arborist.qa._text_norm import stem_for_match
def question_phrases(question: str, *, n: int = 4) -> list[str]:
"""Extract verbatim n-token sliding-window phrases from the question.
Used by the phrase-pattern retrieval route to catch allusions /
idioms / fictional-world references whose diagnostic signal is
the EXACT sequence including function words. Stopword stripping
would kill this:
"always been at war" diagnostic Orwell signal
"always war" generic, useless
So this does NOT strip stopwords. Skips phrases whose tokens are
all 3 chars (pure boilerplate, no diagnostic value). Output is
lowercase, deduped, in source order. Default ``n=4`` is the sweet
spot empirically: 3-grams are too noisy ("the cat in" matches
loads of things), 5-grams miss shorter idioms ("winter is coming"
3 tokens).
"""
tokens = _re.findall(r"[A-Za-z][A-Za-z0-9]+", question)
if len(tokens) < n:
return []
out: list[str] = []
seen: set[str] = set()
for i in range(len(tokens) - n + 1):
window = tokens[i:i + n]
if max(len(t) for t in window) < 4:
continue # all-short-tokens → boilerplate
phrase = " ".join(t.lower() for t in window)
if phrase in seen:
continue
seen.add(phrase)
out.append(phrase)
return out
def filter_by_title_relevance(
hits: list,
question: str,

View file

@ -205,6 +205,25 @@ def test_run_query_policy_classifies_source_role(corpus):
assert src["source_role"] == "primary_answer_source"
def test_run_query_multi_route_merges_body_and_title(corpus):
"""policy={"multi_route": True} fans body + title + phrase +
core_keyword. core_keyword raises NotSupportedError on
SqliteShardCorpus when the shard has no derivations fail-open
means the merge still produces hits from body and title.
For the Anarchism fixture (no derivations), this test just
confirms multi-route doesn't blow up and the merged hit set is
non-empty."""
result = run_query(
corpus, "anarchism",
StubClient(answer="Anarchism is a philosophy. [E1]"),
model_id="stub", top_k=2,
policy={"multi_route": True},
)
assert result["sources"]
assert any("Anarchism" in s["title"] for s in result["sources"])
def test_run_query_policy_ignores_unknown_keys(corpus):
"""policy with verifier-irrelevant keys (e.g. base_version that
Phase 1 step 6a doesn't honor yet) must not blow up — unknown keys