Skip to content

Commit 7c49ff3

Browse files
committed
Fix FTS arm recall for natural-language queries (OR lexemes)
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).
1 parent 32fda57 commit 7c49ff3

3 files changed

Lines changed: 57 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- **Full-text search recall for natural-language queries** (`opencontractserver/llms/vector_stores/core_vector_stores.py`, `_run_fts_query`). The FTS arm built its query with `plainto_tsquery` (via `SearchQuery`'s default), which ANDs every lexeme — so a multi-concept question like *"What are the main risks or obligations across these documents?"* matched **no** annotation (no single row contains all terms) and the arm returned zero rows. When a corpus has no embeddings, hybrid search degraded to that empty FTS arm and the agent answered "I found nothing." `_run_fts_query` now OR-combines per-token `plainto_tsquery` sub-queries, so 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 (no manual stopword list or raw-tsquery escaping). This is the single FTS entry point for the hybrid, fts-only, and `global_search` paths, so the recall fix applies across all of them. Measured on a 53-document SEC-filing corpus: the example question went from 0 → 8 full-text matches. Regression test: `test_hybrid_search.py::TestHybridSearch::test_fts_matches_natural_language_query_spanning_annotations`.

opencontractserver/llms/vector_stores/core_vector_stores.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import asyncio
44
import logging
5+
import operator
6+
import re
57
from dataclasses import dataclass, field
8+
from functools import reduce
69
from typing import Any, Literal, Optional, Union
710

811
from asgiref.sync import async_to_sync, sync_to_async
@@ -1096,7 +1099,24 @@ def _run_fts_query(queryset, query_text: str, oversample_k: int) -> list:
10961099
"""
10971100
from django.contrib.postgres.search import SearchQuery, SearchRank
10981101

1099-
search_query = SearchQuery(query_text, config=FTS_CONFIG)
1102+
# plainto_tsquery (SearchQuery's default) ANDs every lexeme, so a
1103+
# multi-concept natural-language question ("risks AND obligations AND
1104+
# documents ...") matches no single annotation and the FTS arm returns
1105+
# nothing — leaving a corpus without embeddings at zero recall. OR-
1106+
# combine per-token plainto queries instead: any term match qualifies,
1107+
# while SearchRank still ranks annotations matching more terms higher.
1108+
# Per-token plainto safely elides stopwords without raising a tsquery
1109+
# syntax error, so no manual stopword list (or raw-tsquery escaping) is
1110+
# needed. This is the single entry point for every FTS arm (hybrid,
1111+
# fts-only, and global_search), so the recall fix applies everywhere.
1112+
tokens = re.findall(r"\w+", query_text)
1113+
if tokens:
1114+
search_query = reduce(
1115+
operator.or_,
1116+
(SearchQuery(token, config=FTS_CONFIG) for token in tokens),
1117+
)
1118+
else:
1119+
search_query = SearchQuery(query_text, config=FTS_CONFIG)
11001120
text_qs = (
11011121
queryset.filter(search_vector=search_query)
11021122
.annotate(text_rank=SearchRank("search_vector", search_query))

opencontractserver/tests/test_hybrid_search.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,41 @@ def test_hybrid_search_text_only_fallback(self, mock_embed):
184184
# Text-only arm should still find annotations via search_vector
185185
self.assertIsInstance(results, list)
186186

187+
def test_fts_matches_natural_language_query_spanning_annotations(self):
188+
"""FTS arm must match a natural-language query whose content terms are
189+
spread across several annotations.
190+
191+
The three fixture annotations each carry a *different* concept
192+
("indemnification", "termination", "force majeure ... obligations").
193+
A real user question references more than one of them at once. With
194+
``plainto_tsquery`` (AND of every lexeme) no single annotation
195+
contains them all, so the FTS arm returns nothing and a corpus with
196+
no embeddings answers "I found nothing". OR-ing the content lexemes
197+
keeps recall: each annotation that matches *any* term is returned,
198+
and ``SearchRank`` still favours annotations matching more terms.
199+
200+
Isolated to ``mode="fts"`` so this asserts the full-text arm alone,
201+
independent of the vector arm / embeddings.
202+
"""
203+
store = self._make_store()
204+
query = VectorSearchQuery(
205+
query_text=(
206+
"What indemnification, termination, and force majeure "
207+
"obligations apply?"
208+
),
209+
similarity_top_k=10,
210+
mode="fts",
211+
)
212+
results = store.search(query)
213+
matched = {r.annotation.id for r in results}
214+
self.assertEqual(
215+
matched,
216+
{self.anno_alpha.id, self.anno_beta.id, self.anno_gamma.id},
217+
"FTS should return every annotation whose terms collectively "
218+
"satisfy the natural-language query (OR semantics), not zero "
219+
"(AND semantics from plainto_tsquery).",
220+
)
221+
187222
@patch(
188223
"opencontractserver.llms.vector_stores.base_vector_store"
189224
".agenerate_embeddings_from_text"

0 commit comments

Comments
 (0)