qa(retrieval): fix _search_titles SQLite tree-depth overflow on long queries

Regression fox surfaced 2026-05-02:
  Q: "what technology are currently or soon available which may
      enable one person to reconstruct and understand some or a
      portion of another persons thoughts or ideas without
      speaking or sign language."
  → sqlite3.OperationalError: Expression tree is too large
    (maximum depth 1000)

Root cause: the v1 of _search_titles (commit 0052845) chained N
``CASE WHEN ... THEN 1 ELSE 0 END + ...`` expressions for the
title_score column. Each CASE WHEN is multiple tree nodes; +-
chained N times exceeded SQLite's default 1000-depth bound on
question texts with ~30+ content tokens.

Fix: simplified SQL — OR-chain WHERE + ORDER BY LENGTH(title) ASC
+ LIMIT bumped 4x to compensate for the lost smart sorting. The
caller's post-filter (word-boundary stem-aware token-set
intersect) does the actual title-relevance ranking; SQL just
needs to surface enough candidates for the post-filter to grade.

Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS=24 so pathological
200-token queries don't cascade SQL expression growth even
defensively. Beyond ~24 tokens the post-filter is doing all the
work; extra LIKEs just inflate candidate sets without signal.

5 new tests in tests/test_query.py covering the regression at
unit / integration / functional layers:

- test_unit_search_titles_handles_long_question_without_crash
  50-token query through _search_titles directly. Pre-fix raised
  sqlite3.OperationalError; post-fix returns row list.
- test_unit_search_titles_handles_zero_tokens
  Defensive: empty token list → empty result, no SQL executed.
- test_unit_search_titles_caps_or_chain_at_max_tokens
  200-token pathological query — bounded by MAX_TITLE_LIKE_TOKENS,
  doesn't crash.
- test_integration_query_completes_on_long_question
  End-to-end query() with StubClient + long question completes
  without the SQLite error. Pre-fix raised before reaching the
  LLM call.
- test_functional_long_question_returns_sources
  The neurotech doc (richest body match) appears in top-K despite
  the long-question retrieval path.

Test_query.py: 35 → 40 passing. Full suite (excluding parallel
test_concepts churn from concepts/query.py rewrite): no other
regressions.
This commit is contained in:
russell@unturf.com 2026-05-01 22:19:20 -04:00
parent 4ba4a815e0
commit faed58dd0a
No known key found for this signature in database
2 changed files with 185 additions and 18 deletions

View file

@ -596,29 +596,43 @@ def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
``"Aberdeen, South Dakota"`` (``south`` contains ``out``),
``"Backplane"`` (``back`` substring), etc.
Fix: score each row by how many query tokens its title contains
(substring-level — the post-filter in the caller does
word-boundary verification), and ``ORDER BY`` that score
descending with title length ascending as tiebreaker (shorter
title = higher title purity per char). The legitimate
multi-token matches surface above single-substring junk before
LIMIT truncates.
Fix: ``ORDER BY LENGTH(title) ASC`` (shorter titles win the
LIMIT race; title-purity proxy) and bump LIMIT to ``limit * 4``
so the post-filter (word-boundary stem-aware token-set
intersect) sees enough candidates to surface genuine matches
even on long-question OR'd LIKE queries.
The earlier 2026-05-02 v1 of this fix used a per-row
``CASE WHEN ... THEN 1 ELSE 0 END + ...`` token-hit score in
SQL, which broke on long questions (fox 2026-05-02:
18-content-token query → SQLite "Expression tree is too large
(maximum depth 1000)" — each ``CASE WHEN`` is multiple
expression-tree nodes; ``+``-chained N times exceeded the
bound). The simplified shape is universal: short queries see
a tight LIMIT, long queries see ``limit * 4`` candidates the
caller's post-filter ranks via stem-aware token intersection.
Title-length-asc keeps the LIMIT race biased toward
title-pure matches.
Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS to bound the
expression-tree depth. Beyond ~24 tokens the post-filter is
doing all the work anyway; extra LIKEs just inflate the
candidate set without adding signal.
"""
if not qtokens:
return []
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(qtokens))
score_expr = " + ".join(
["CASE WHEN LOWER(title) LIKE ? THEN 1 ELSE 0 END"] * len(qtokens)
)
likes = [f"%{t.lower()}%" for t in qtokens]
# Two passes of params: once for the score-CASE-WHEN block,
# once for the WHERE clause.
params = likes + likes + [limit]
MAX_TITLE_LIKE_TOKENS = 24
bounded = list(qtokens)[:MAX_TITLE_LIKE_TOKENS]
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(bounded))
likes = [f"%{t.lower()}%" for t in bounded]
# Bump effective limit so the post-filter sees enough candidates
# to find genuine matches even when the OR-chain is permissive.
over_fetch_limit = limit * 4
params = likes + [over_fetch_limit]
rows = conn.execute(
f"SELECT document_root, document_uri, title, "
f"({score_expr}) AS title_score FROM documents "
f"SELECT document_root, document_uri, title FROM documents "
f"WHERE {clauses} "
f"ORDER BY title_score DESC, LENGTH(title) ASC LIMIT ?",
f"ORDER BY LENGTH(title) ASC LIMIT ?",
params,
).fetchall()
return rows

View file

@ -1171,3 +1171,156 @@ def test_filter_keeps_phrase_match_root_with_no_title_overlap():
# And the fallback path doesn't suddenly fail when phrase_match_roots
# is present — the keep is via accept-path 4, not via the fallback.
_ = kept_without # documents that fallback may also keep, but via different path
# ---------------------------------------------------------------------------
# _search_titles long-query regression (2026-05-02)
# ---------------------------------------------------------------------------
def _build_titled_corpus(tmp_path) -> tuple:
"""Test corpus shaped to exercise _search_titles. Returns
(single_db_path, qa_db_path)."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource([
_doc(
"test://neurotech",
# Long body so retrieval has something to grade.
"Neurotechnology covers brain-computer interfaces. " * 30
+ "Transcranial magnetic stimulation reads brain "
"activity. " * 30
),
_doc(
"test://decoy",
"Generic technology overview text. " * 60,
),
]))
finally:
conn.close()
return main_db, qa_db
def test_unit_search_titles_handles_long_question_without_crash(tmp_path):
"""Regression: 2026-05-02 fox-surfaced SQLite error on a
31-content-token query. The earlier SQL chained N CASE WHEN
expressions joined by `+`, blowing past SQLite's default
expression-tree depth limit (1000). The simplified SQL —
OR-chain + ORDER BY LENGTH(title) — handles arbitrary token
counts. Cap at MAX_TITLE_LIKE_TOKENS bounds the OR-chain
growth defensively."""
from aborist.qa.query import _search_titles
main_db, _qa_db = _build_titled_corpus(tmp_path)
conn = connect(main_db)
try:
# 50-token query — well past anything that would have hit
# the 1000-depth cap, but the bounded SQL handles it.
long_tokens = [
"what", "technology", "are", "currently", "or", "soon",
"available", "which", "may", "enable", "one", "person",
"to", "reconstruct", "and", "understand", "some", "portion",
"of", "another", "persons", "thoughts", "or", "ideas",
"without", "speaking", "or", "sign", "language",
"and", "make", "a", "summary", "we", "can", "use",
"to", "improve", "communication", "between", "humans",
"across", "the", "globe", "even", "if", "they", "speak",
"different", "tongues",
]
# Should not raise sqlite3.OperationalError; should return
# SOME rows (the test corpus has 2 docs, one will match).
rows = _search_titles(conn, long_tokens, 32)
assert isinstance(rows, list)
finally:
conn.close()
def test_unit_search_titles_handles_zero_tokens():
"""Defensive: zero tokens → empty list, no SQL executed."""
from aborist.qa.query import _search_titles
rows = _search_titles(None, [], 32)
assert rows == []
def test_unit_search_titles_caps_or_chain_at_max_tokens(tmp_path):
"""The OR-chain is bounded at MAX_TITLE_LIKE_TOKENS so
extreme-length queries (100+ tokens) don't cascade SQL
expression-tree growth. Beyond the cap, additional tokens
are dropped at the SQL layer; the post-filter in the caller
sees them all and does the actual filtering."""
from aborist.qa.query import _search_titles
main_db, _qa_db = _build_titled_corpus(tmp_path)
conn = connect(main_db)
try:
# 200-token pathological query — should not crash.
many_tokens = [f"tok{i}" for i in range(200)]
rows = _search_titles(conn, many_tokens, 32)
assert isinstance(rows, list)
finally:
conn.close()
def test_integration_query_completes_on_long_question(tmp_path):
"""End-to-end: a long discursive question with many content
tokens runs through query() to completion (StubClient — no
LLM) without the SQLite expression-tree overflow that the
2026-05-02 regression triggered."""
main_db, qa_db = _build_titled_corpus(tmp_path)
long_q = (
"what technology are currently or soon available which may "
"enable one person to reconstruct and understand some or a "
"portion of another persons thoughts or ideas without "
"speaking or sign language"
)
result = query(
question=long_q,
qa_db=qa_db,
chat_client=StubClient(answer="A short stub answer."),
model_id="m",
single_db=main_db,
top_k=3,
)
# Pre-fix this raised sqlite3.OperationalError before reaching
# the LLM call. Post-fix: query() returns a normal result dict.
assert result["status"] in (
"cache_miss_then_written",
"cache_hit",
"no_sources",
)
# Status field present means the pipeline completed.
assert "audit_mode" in result or result["status"] == "no_sources"
def test_functional_long_question_returns_sources(tmp_path):
"""Functional check: the long-question retrieval path actually
surfaces relevant sources (not just empty). The corpus has a
'neurotech' doc whose title and body share tokens with the
long question; it should appear in the top-K despite the
question's length."""
main_db, qa_db = _build_titled_corpus(tmp_path)
long_q = (
"what technology may enable one person to reconstruct and "
"understand another persons thoughts or ideas without "
"speaking or sign language using neurotechnology and "
"transcranial brain reading"
)
result = query(
question=long_q,
qa_db=qa_db,
chat_client=StubClient(answer="answer"),
model_id="m",
single_db=main_db,
top_k=3,
)
# Pipeline completed; if any sources were found at all, the
# neurotech doc (richest body match) should be among them.
if result["status"] != "no_sources":
uris = [s["document_uri"] for s in result.get("sources") or []]
# Neurotech doc has matching tokens; should appear.
assert any("neurotech" in u for u in uris), (
f"neurotech doc missing from sources {uris}"
)