diff --git a/arborist/qa/corpus_query.py b/arborist/qa/corpus_query.py index 9c18eb2..f82dbef 100644 --- a/arborist/qa/corpus_query.py +++ b/arborist/qa/corpus_query.py @@ -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.(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. diff --git a/arborist/qa/retrieval_routes.py b/arborist/qa/retrieval_routes.py index b998b29..f85bd97 100644 --- a/arborist/qa/retrieval_routes.py +++ b/arborist/qa/retrieval_routes.py @@ -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, diff --git a/tests/test_qa_corpus_query.py b/tests/test_qa_corpus_query.py index 7c93fea..941584f 100644 --- a/tests/test_qa_corpus_query.py +++ b/tests/test_qa_corpus_query.py @@ -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