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:
parent
d3b78025ff
commit
5fdd573c0a
3 changed files with 170 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue