Fix FTS arm recall for natural-language queries (OR lexemes, not AND)#1970
Conversation
The full-text arm of CoreAnnotationVectorStore built its query with plainto_tsquery (SearchQuery's default), which ANDs every lexeme. A multi-concept natural-language question therefore matched no single annotation and the FTS arm returned zero rows -- so a corpus with no embeddings (hybrid degrading to FTS-only) answered 'I found nothing'. _run_fts_query now OR-combines per-token plainto_tsquery sub-queries: any term match qualifies, while SearchRank still ranks annotations matching more terms higher. Per-token plainto safely elides stopwords without raising a tsquery syntax error, so no manual stopword list or raw-tsquery escaping is needed. This is the single FTS entry point for the hybrid, fts-only and global_search paths, so the fix applies to all. Added test_fts_matches_natural_language_query_spanning_annotations (RED before the change: 0 matches; GREEN after: all three annotations whose terms collectively satisfy the query).
Code ReviewOverviewThis PR fixes a real, measurable regression: the FTS arm of hybrid search was silently returning zero results for any natural-language query because The motivation is clear, the implementation is minimal, and the regression test was written first. This is a well-executed targeted fix. Code quality
The implementation is clean. The One substantive concern — token count with long inputs
Suggested fix — cap tokens before reducing: MAX_FTS_TOKENS = 30 # or a constant in opencontractserver/constants/search.py
tokens = re.findall(r"\w+", query_text)[:MAX_FTS_TOKENS]This is non-breaking (callers already accept approximate recall) and prevents a latent slowdown on long inputs. Minor — duplicate tokens If the query repeats a word ("obligations and obligations"), two identical Test coverageThe new test A few edge cases not currently covered that would be nice to add in a follow-up:
SecurityNo new injection surface — Summary
The token cap is the only change I'd call out before merge; the dedup and extra test cases are nice-to-haves for a follow-up. |
Why
CoreAnnotationVectorStorealready defaults to hybrid retrieval (pgvector + Postgres full-text, fused with RRF), and its_fuse_resultsalready returns whichever arm is non-empty — so the intended "embeddings improve results, FTS still answers when they're missing" behavior is the design. But it didn't actually degrade gracefully, because the FTS arm could not match real questions._run_fts_querybuilt its query withSearchQuery(query_text, config=...), whose defaultplainto_tsqueryANDs every lexeme. A natural-language question references multiple concepts at once, and no single annotation contains all of them — so the FTS arm returned zero rows for any realistic query. When a corpus has no embeddings, hybrid degraded to that empty FTS arm and the agent answered "I found nothing."Measured on a 53-document SEC-filing corpus:
"What are the main risks or obligations across these documents?"(plain AND — what the agent sends)search_type="websearch"What
_run_fts_querynow tokenizes the query and OR-combines per-tokenplainto_tsquerysub-queries:SearchRankstill ranks annotations matching more terms higher (precision preference preserved), and RRF + the optional reranker clean up ordering downstream.plainto_tsquerysafely elides stopwords without raising a tsquery syntax error, so no manual stopword list or raw-tsquery escaping (and no injection surface) is needed.global_searchpaths, so the recall fix applies everywhere at once.This does not make a zero-embedding corpus as good as an embedded one (FTS can't do synonyms — "liabilities" won't hit "obligations"), but it turns "I found nothing" into "here are the keyword matches" — the graceful degradation hybrid was supposed to provide.
Tests
test_hybrid_search.py::TestHybridSearch::test_fts_matches_natural_language_query_spanning_annotations— a natural-language query whose three concepts are spread across three annotations. RED before the change (0 matches, AND semantics); GREEN after (all three, OR semantics). Written test-first.test_hybrid_search.py— 37 passed.test_django_annotation_vector_store,test_corpus_isolation_vector_store,test_subtree_group_vector_search,test_version_aware_vector_store,test_conversation_search) — 122 passed.pre-commit(black/isort/flake8/mypy + changelog validation) clean.Notes
Independent of the Corpus Intelligence home PR (#1969) — that PR surfaces the cross-document "ask" UI; this fixes the retrieval recall behind it. Found while verifying #1969 against a freshly-embedded corpus.