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:
parent
4ba4a815e0
commit
faed58dd0a
2 changed files with 185 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue