Skip to content

Commit 073d260

Browse files
fix(search): match multi-word FTS queries (#306)
* fix(search): match multi-word FTS queries Replace blanket phrase-wrapping in _sanitize_fts5_query with tokenize -> drop stopwords/operators -> quote each term -> OR. Multi-word queries (every query the agent sends for papers/discussions/FAQ/docstrings) were exact-phrase matched and returned nothing despite populated databases. Still injection-safe (each term individually quoted); results ordered by existing BM25 rank. Affects all 6 knowledge-search call sites. Closes #305 * test(search): harden operator test; keep list/use terms Address PR review: - strengthen test_sanitize_fts5_operators to assert every term is individually quoted (no bare operator can reach MATCH) - drop 'list' and 'use' from stopwords (meaningful EEGLAB/MATLAB nouns) so multi-word queries like 'list channels' don't silently lose a term * test(bep): use non-matching query for no-results case BEP keyword search is OR-based and rank-ordered after the FTS fix, so the old phrase-only no-match query ('...data type...') now matches BEPs that mention 'data'. Use a genuinely non-matching query to keep the no-results path covered.
1 parent 72199ba commit 073d260

3 files changed

Lines changed: 165 additions & 22 deletions

File tree

src/knowledge/search.py

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,26 +98,112 @@ def _titles_are_similar(
9898
return similarity >= threshold
9999

100100

101+
# Common English words that add noise to keyword search without improving
102+
# relevance. Deliberately short and domain-agnostic so we never strip a
103+
# meaningful term (acronyms, function names, identifiers are all kept).
104+
_FTS_STOPWORDS = frozenset(
105+
{
106+
"a",
107+
"an",
108+
"and",
109+
"any",
110+
"are",
111+
"as",
112+
"about",
113+
"at",
114+
"be",
115+
"but",
116+
"by",
117+
"can",
118+
"did",
119+
"do",
120+
"does",
121+
"for",
122+
"from",
123+
"give",
124+
"has",
125+
"have",
126+
"how",
127+
"i",
128+
"in",
129+
"into",
130+
"is",
131+
"it",
132+
"its",
133+
"me",
134+
"my",
135+
"no",
136+
"not",
137+
"of",
138+
"on",
139+
"or",
140+
"paper",
141+
"papers",
142+
"please",
143+
"research",
144+
"search",
145+
"show",
146+
"some",
147+
"tell",
148+
"that",
149+
"the",
150+
"their",
151+
"them",
152+
"there",
153+
"these",
154+
"this",
155+
"to",
156+
"using",
157+
"want",
158+
"was",
159+
"we",
160+
"what",
161+
"when",
162+
"where",
163+
"which",
164+
"who",
165+
"why",
166+
"will",
167+
"with",
168+
"would",
169+
"you",
170+
"your",
171+
}
172+
)
173+
174+
101175
def _sanitize_fts5_query(query: str) -> str:
102-
"""Sanitize user input for safe FTS5 queries.
176+
"""Build a safe, forgiving FTS5 MATCH expression from raw user input.
103177
104-
IMPORTANT: This function wraps ALL input in quotes, converting queries to
105-
exact phrase searches. This prevents FTS5 operator injection but also
106-
disables legitimate FTS5 features (AND/OR/NOT, wildcards, NEAR, etc.).
178+
Splits the query into individual terms, drops noise words and any FTS5
179+
operator characters, quotes each remaining term (so reserved words like
180+
AND/OR/NEAR and punctuation cannot inject operators), and ORs them
181+
together. Callers order results by BM25 ``rank``, so documents matching
182+
the most (and rarest) terms surface first.
107183
108-
For a production system with advanced search needs, consider implementing
109-
proper query parsing instead of blanket phrase conversion.
184+
This replaces the previous behaviour of wrapping the whole query in quotes,
185+
which forced an exact consecutive-phrase match and caused multi-word
186+
natural-language queries to return nothing.
110187
111188
Args:
112189
query: Raw user input
113190
114191
Returns:
115-
Sanitized query safe for FTS5 MATCH (as a phrase search)
192+
A MATCH expression safe from FTS5 injection. Falls back to a quoted
193+
phrase of the raw input when no meaningful terms remain (e.g. a query
194+
made entirely of stopwords or symbols), preserving safe behaviour
195+
instead of producing an empty MATCH.
116196
"""
117-
# Escape internal double quotes by doubling them
118-
escaped = query.replace('"', '""')
119-
# Wrap in quotes to treat entire input as phrase search
120-
return f'"{escaped}"'
197+
# Tokens: words, numbers, and identifier-style names (pop_loadset, clean_rawdata).
198+
# The regex strips all FTS5 operator characters (quotes, *, :, (), -, etc.).
199+
tokens = re.findall(r"[A-Za-z0-9_]+", query.lower())
200+
terms = [t for t in tokens if t not in _FTS_STOPWORDS]
201+
if not terms:
202+
# Nothing meaningful left: fall back to a safe phrase match of raw input.
203+
escaped = query.replace('"', '""')
204+
return f'"{escaped}"'
205+
# Quote each term individually to neutralize operators, then OR them.
206+
return " OR ".join(f'"{t}"' for t in terms)
121207

122208

123209
@dataclass

tests/test_knowledge/test_search.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
These tests use a temporary database populated with test data.
44
"""
55

6+
import re
67
from pathlib import Path
78
from unittest.mock import patch
89

@@ -171,6 +172,28 @@ def test_filter_by_source(self, populated_db: Path):
171172
assert all(r.source == "openalex" for r in openalex_results)
172173
assert all(r.source == "semanticscholar" for r in s2_results)
173174

175+
def test_multiword_query_matches_non_adjacent_terms(self, populated_db: Path):
176+
"""Regression (issue #305): natural-language queries must match papers
177+
even when the terms are not adjacent in the document.
178+
179+
Previously the whole query was phrase-wrapped, so multi-word questions
180+
returned zero papers despite a populated database. "annotation" and
181+
"neuroimaging" both appear in a paper but never consecutively in this
182+
order, so the old phrase match returned nothing.
183+
"""
184+
with patch("src.knowledge.db.get_db_path", return_value=populated_db):
185+
results = search_papers("annotation HED neuroimaging")
186+
187+
assert len(results) >= 1
188+
assert any("HED Annotation" in r.title for r in results)
189+
190+
def test_question_phrasing_finds_papers(self, populated_db: Path):
191+
"""A full question (with stopwords) still surfaces relevant papers."""
192+
with patch("src.knowledge.db.get_db_path", return_value=populated_db):
193+
results = search_papers("what papers are about HED annotation?")
194+
195+
assert len(results) >= 1
196+
174197

175198
class TestSearchAll:
176199
"""Tests for combined search."""
@@ -317,18 +340,43 @@ def test_number_lookup_deduplicates(self, populated_db: Path):
317340
class TestFTS5Sanitization:
318341
"""Tests for FTS5 query sanitization to prevent injection."""
319342

320-
def test_sanitize_basic_query(self):
321-
"""Test that basic queries are wrapped in quotes."""
343+
def test_sanitize_splits_into_or_terms(self):
344+
"""Basic queries become an OR of individually quoted terms."""
322345
result = _sanitize_fts5_query("validation error")
323-
assert result == '"validation error"'
346+
assert result == '"validation" OR "error"'
347+
348+
def test_sanitize_drops_stopwords(self):
349+
"""Noise words are removed; meaningful terms (incl. acronyms) kept."""
350+
result = _sanitize_fts5_query("what papers are about ICA")
351+
assert result == '"ica"'
352+
353+
def test_sanitize_keeps_identifier_tokens(self):
354+
"""Function/identifier names stay intact (underscores preserved)."""
355+
result = _sanitize_fts5_query("pop_runica parameters")
356+
assert result == '"pop_runica" OR "parameters"'
357+
358+
def test_sanitize_keeps_command_noun_terms(self):
359+
"""Words that double as content/command nouns are not stopwords.
324360
325-
def test_sanitize_escapes_quotes(self):
326-
"""Test that double quotes in user input are escaped."""
361+
'list' and 'use' carry meaning in EEGLAB/MATLAB queries (e.g. channel
362+
lists), so they must survive instead of being silently dropped.
363+
"""
364+
assert _sanitize_fts5_query("list channels") == '"list" OR "channels"'
365+
assert _sanitize_fts5_query("use function") == '"use" OR "function"'
366+
367+
def test_sanitize_strips_quotes_no_injection(self):
368+
"""Double quotes in input are stripped by tokenization, not escaped in."""
327369
result = _sanitize_fts5_query('say "hello" world')
328-
assert result == '"say ""hello"" world"'
370+
assert result == '"say" OR "hello" OR "world"'
371+
assert '""' not in result
372+
373+
def test_sanitize_stopword_only_query_falls_back(self):
374+
"""A query of only stopwords falls back to a safe quoted phrase."""
375+
result = _sanitize_fts5_query("what are the")
376+
assert result == '"what are the"'
329377

330378
def test_sanitize_fts5_operators(self):
331-
"""Test that FTS5 operators are treated as literal text."""
379+
"""FTS5 operators are quoted as literal terms, never executed."""
332380
# These would be dangerous without sanitization
333381
dangerous_queries = [
334382
"test AND DROP TABLE",
@@ -339,9 +387,15 @@ def test_sanitize_fts5_operators(self):
339387
]
340388
for query in dangerous_queries:
341389
result = _sanitize_fts5_query(query)
342-
# Should be wrapped in quotes, treating operators as literals
343-
assert result.startswith('"')
344-
assert result.endswith('"')
390+
# Every OR-separated term must be individually double-quoted, so no
391+
# bare FTS5 operator (AND/OR/NOT/NEAR/wildcard) can reach MATCH.
392+
for term in result.split(" OR "):
393+
assert term.startswith('"') and term.endswith('"'), (
394+
f"unquoted term {term!r} in result for {query!r}: {result!r}"
395+
)
396+
# Removing every quoted term must leave only ' OR ' connectors.
397+
remainder = re.sub(r'"[^"]*"', "", result).replace(" OR ", "").strip()
398+
assert remainder == "", f"stray operator text in result for {query!r}: {result!r}"
345399

346400
def test_search_handles_special_characters(self, populated_db: Path):
347401
"""Test that search doesn't crash with special FTS5 characters."""

tests/test_tools/test_bep_tool.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ def test_lookup_no_results(self, bep_db: Path):
122122
patch("src.assistants.bids.tools.get_db_path", return_value=bep_db),
123123
patch("src.knowledge.db.get_db_path", return_value=bep_db),
124124
):
125-
result = lookup_bep.invoke({"query": "nonexistent data type xyz"})
125+
# Keyword search is OR-based and rank-ordered, so the query must
126+
# contain no real terms present in any BEP to return zero results
127+
# (e.g. "data" would match many BEP descriptions).
128+
result = lookup_bep.invoke({"query": "qwertyuiop zxcvbnmlkj"})
126129

127130
assert "No BEPs found" in result
128131

0 commit comments

Comments
 (0)