Skip to content

Commit 844e137

Browse files
jpmansonclaude
andauthored
fix(search): tolerate natural-language queries in hybrid and text retrieval (v0.8.2) (#17)
* fix(search): tolerate natural-language queries in hybrid and text retrieval SQLite FTS5 parses its MATCH argument as a query language, so a user-typed question was a syntax error rather than a search: a trailing "?", a comma, an "&" or a hyphen raised sqlite3.OperationalError straight out of text_search. hybrid_search caught DatabaseError but not the raw sqlite3 error, so the exception escaped the fusion logic entirely. Wrapping it is only half the fix, though — a caught error still dropped the whole lexical half, turning hybrid retrieval into vector-only search precisely for the identifiers and error codes that are the reason to fuse in the first place. Both retrieval entry points now retry an unparseable query with each token quoted as an FTS5 string literal: - hybrid_search falls back to the vector side if that retry also fails. - text_subgraph propagates instead: with no second half to fall back on, an empty subgraph would report "no matches" for a broken search. text_search keeps raising, since its documented contract is FTS5 syntax (AND/OR/NEAR/term*), and silently rewriting those operators would break it. It now raises DatabaseError rather than leaking sqlite3.OperationalError, and SQLiteFTSIndex.search does the same for custom text-index backends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(release): bump to v0.8.2 Patch: natural-language queries no longer raise out of hybrid_search and text_subgraph. No new API. The one behaviour change to code that already worked is text_search's exception type, from sqlite3.OperationalError to DatabaseError — the type the rest of the library already raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 017e573 commit 844e137

10 files changed

Lines changed: 213 additions & 26 deletions

File tree

docs/analysis/subgraphs.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ arguments:
9191
sub = db.text_subgraph("attention mechanism", k=20, expand=1)
9292
```
9393

94+
Unlike [`text_search()`](../search/fulltext.md#query-syntax), it accepts
95+
user-typed questions: a query FTS5 cannot parse is retried as literal terms
96+
instead of raising.
97+
9498
`hybrid_subgraph()` seeds from both at once, fused with Reciprocal Rank Fusion:
9599

96100
```python

docs/search/fulltext.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@ for r in results:
106106

107107
## Query Syntax
108108

109+
!!! warning "`text_search` takes FTS5 syntax, not free text"
110+
The query is parsed as a query language, so characters FTS5 reserves are
111+
syntax rather than terms — a user-typed question raises `DatabaseError`.
112+
Either quote the terms yourself (`'"graph" "databases"'`), or use
113+
[`hybrid_search`](hybrid.md), which accepts natural language and retries
114+
unparseable queries as literal terms.
115+
116+
```python
117+
db.text_search('graph databases?') # DatabaseError: fts5: syntax error near "?"
118+
db.text_search('cost-benefit') # DatabaseError: no such column: benefit
119+
db.text_search('"graph" "databases"') # works: two literal terms
120+
```
121+
109122
### Basic Terms
110123

111124
```python

docs/search/hybrid.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ Combining full-text search (BM25) with vector search for better results.
77
use `DocumentIngestor.hybrid_search()` (RRF fusion, ownership/generation
88
filters). Manual recipes below still apply to ad-hoc indexes.
99

10+
!!! warning "Recipes below pass the raw query to `text_search`"
11+
That method takes [FTS5 syntax](fulltext.md#query-syntax), so a user-typed
12+
question raises `DatabaseError` on its punctuation. `db.hybrid_search()`
13+
handles this for you by retrying as literal terms; in your own recipe,
14+
quote the tokens (`' '.join(f'"{t}"' for t in re.findall(r'\w+', query))`)
15+
before calling `text_search`.
16+
1017
## Why Hybrid Search?
1118

1219
| Search Type | Strengths | Weaknesses |

grafito/database.py

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@
3838
#: Matches txtai's minscore default.
3939
DEFAULT_SEMANTIC_GRAPH_MIN_SCORE = 0.1
4040

41+
#: Word characters FTS5 keeps; everything else is query syntax to its parser.
42+
_FTS_TOKEN_RE = re.compile(r"\w+")
43+
44+
45+
def _as_fts_literals(query: str) -> str:
46+
"""Rewrite free text as a conjunction of FTS5 string literals.
47+
48+
FTS5 parses its MATCH argument as a query language, so a question mark or a
49+
comma in a natural-language question is a syntax error rather than a term.
50+
Quoting each token turns the whole thing back into plain terms — the same
51+
AND-of-terms FTS5 applies to a bare word list, minus the parse failure.
52+
53+
Returns an empty string when the query holds no word characters at all,
54+
which callers should read as "there is no lexical query to run".
55+
"""
56+
tokens = _FTS_TOKEN_RE.findall(query)
57+
return " ".join(f'"{token}"' for token in tokens)
58+
59+
4160
if TYPE_CHECKING: # pragma: no cover - typing only
4261
from .algorithms import Community
4362
from .ingest_report import IndexReport, SemanticGraphReport
@@ -782,8 +801,14 @@ def hybrid_search(
782801
Degrades rather than fails: with no text index configured, or no vector
783802
index, whichever side works is returned on its own.
784803
804+
Unlike :meth:`text_search`, this takes natural language. A query FTS5
805+
cannot parse — a trailing ``?``, a comma, an ``&`` — is retried as plain
806+
terms instead of raising, so questions keep their lexical half.
807+
785808
Args:
786-
query: The query text, used for both sides.
809+
query: The query text, used for both sides. Valid FTS5 operators
810+
(``AND``, ``OR``, ``NEAR``, ``term*``) still reach the lexical
811+
side; anything FTS5 rejects is re-run as literal terms.
787812
k: Number of results.
788813
index: Vector index to search.
789814
vector_k, text_k: Candidates to draw from each side before fusing.
@@ -833,15 +858,28 @@ def hybrid_search(
833858
# No vector index, or no embedding function to turn text into one.
834859
vector_hits = []
835860

836-
text_hits: list[dict[str, Any]] = []
837-
try:
838-
text_hits = [
861+
def lexical_side(text_query: str) -> list[dict[str, Any]]:
862+
return [
839863
{"node": row["entity"], "score": row["score"]}
840-
for row in self.text_search(query, k=text_k, labels=labels)
864+
for row in self.text_search(text_query, k=text_k, labels=labels)
841865
if row.get("entity_type") == "node"
842866
]
867+
868+
text_hits: list[dict[str, Any]] = []
869+
try:
870+
text_hits = lexical_side(query)
843871
except DatabaseError:
844-
text_hits = []
872+
# `query` here is natural language, not the FTS5 syntax text_search
873+
# documents, so punctuation is a parse error rather than an empty
874+
# result. Retry as literals before giving up the lexical half — the
875+
# exact terms it contributes are the reason to fuse at all.
876+
literals = _as_fts_literals(query)
877+
if literals:
878+
try:
879+
text_hits = lexical_side(literals)
880+
except DatabaseError:
881+
# No FTS index, or no fts5 in this SQLite build.
882+
text_hits = []
845883

846884
if not text_hits:
847885
return vector_hits[:k]
@@ -1157,16 +1195,33 @@ def text_subgraph(
11571195
subgraph.
11581196
11591197
Args:
1160-
query: FTS5 query string.
1198+
query: FTS5 query string. Unlike :meth:`text_search`, a query FTS5
1199+
cannot parse is retried as literal terms rather than raising —
1200+
seeding a subgraph is a retrieval task, and callers reach it
1201+
with user-typed questions.
11611202
k: Number of seed hits.
11621203
search_labels: Restrict the text search to these labels.
11631204
expand, direction, rel_types, exclude_rel_types, labels, max_nodes,
11641205
include_edges: As in :meth:`subgraph`.
11651206
11661207
Returns:
11671208
A :class:`~grafito.subgraph.Subgraph`.
1209+
1210+
Raises:
1211+
DatabaseError: If the query holds no terms to quote, ``k`` is not
1212+
positive, or FTS5 is unavailable. A query with no matches is an
1213+
empty subgraph, not an error.
11681214
"""
1169-
hits = self.text_search(query, k=k, labels=search_labels)
1215+
try:
1216+
hits = self.text_search(query, k=k, labels=search_labels)
1217+
except DatabaseError:
1218+
# The same retry hybrid_search performs. Unlike there, a second
1219+
# failure propagates: with no vector half to fall back on, returning
1220+
# an empty subgraph would report "no matches" for a broken search.
1221+
literals = _as_fts_literals(query)
1222+
if not literals:
1223+
raise
1224+
hits = self.text_search(literals, k=k, labels=search_labels)
11701225
seeds = [
11711226
{"node": hit["entity"], "score": hit["score"]}
11721227
for hit in hits
@@ -2409,7 +2464,17 @@ def text_search(
24092464
labels: list[str] | None = None,
24102465
rel_types: list[str] | None = None,
24112466
) -> list[dict[str, Any]]:
2412-
"""Search nodes/relationships using FTS5 BM25."""
2467+
"""Search nodes/relationships using FTS5 BM25.
2468+
2469+
``query`` is FTS5 query syntax, not free text: operators like ``AND``,
2470+
``OR``, ``NEAR/10`` and ``term*`` work, and punctuation FTS5 reserves
2471+
raises rather than matching literally. Pass user-typed questions to
2472+
:meth:`hybrid_search`, which retries unparseable queries as terms.
2473+
2474+
Raises:
2475+
DatabaseError: If the query is empty, ``k`` is not positive, the
2476+
filters are malformed, or the query is not valid FTS5 syntax.
2477+
"""
24132478
if not query or not query.strip():
24142479
raise DatabaseError("Query cannot be empty")
24152480
if k is None:
@@ -2476,9 +2541,14 @@ def build_query(entity: str, label_filter: list[str] | None) -> tuple[str, list[
24762541
sql += " ORDER BY score ASC LIMIT ?"
24772542
params.append(k)
24782543

2479-
cursor = self.conn.execute(sql, params)
2544+
try:
2545+
cursor = self.conn.execute(sql, params)
2546+
rows = cursor.fetchall()
2547+
except sqlite3.Error as exc:
2548+
raise DatabaseError(f"Failed to search text index: {exc}", exc) from exc
2549+
24802550
results = []
2481-
for row in cursor.fetchall():
2551+
for row in rows:
24822552
entity_type = row["entity_type"]
24832553
entity_id = int(row["entity_id"])
24842554
score = float(row["score"])

grafito/text_index/sqlite_fts.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sqlite3
66
from typing import Any
77

8+
from ..exceptions import DatabaseError
89
from .base import TextIndex
910

1011

@@ -78,22 +79,29 @@ def search(self, query: str, k: int) -> list[tuple[int, float]]:
7879
7980
Returns:
8081
List of (id, score) tuples, ordered by relevance.
82+
83+
Raises:
84+
DatabaseError: If the query is not valid FTS5 syntax.
8185
"""
8286
if not query or not query.strip():
8387
return []
84-
85-
cursor = self.conn.execute(
86-
"""
87-
SELECT entity_id, bm25(fts_index) AS score
88-
FROM fts_index
89-
WHERE label_type = ? AND fts_index MATCH ?
90-
ORDER BY score ASC
91-
LIMIT ?
92-
""",
93-
(self.name, query, k),
94-
)
95-
96-
return [(int(row[0]), float(row[1])) for row in cursor.fetchall()]
88+
89+
try:
90+
cursor = self.conn.execute(
91+
"""
92+
SELECT entity_id, bm25(fts_index) AS score
93+
FROM fts_index
94+
WHERE label_type = ? AND fts_index MATCH ?
95+
ORDER BY score ASC
96+
LIMIT ?
97+
""",
98+
(self.name, query, k),
99+
)
100+
rows = cursor.fetchall()
101+
except sqlite3.Error as exc:
102+
raise DatabaseError(f"Failed to search text index: {exc}", exc) from exc
103+
104+
return [(int(row[0]), float(row[1])) for row in rows]
97105

98106
def save(self, path: str) -> None:
99107
"""Save is a no-op for SQLite FTS (data is in the database)."""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "grafitodb"
3-
version = "0.8.1"
3+
version = "0.8.2"
44
description = "SQLite-based Property Graph Database"
55
readme = "README.md"
66
requires-python = ">=3.11"

tests/test_hybrid_search.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,41 @@ def test_without_a_vector_index_the_text_side_carries_it():
168168
database.close()
169169

170170

171+
@pytest.mark.parametrize(
172+
"punctuated,plain",
173+
[
174+
("graph databases?", "graph databases"),
175+
("graph & databases", "graph databases"),
176+
("what are graph databases, exactly?", "what are graph databases exactly"),
177+
],
178+
)
179+
def test_punctuation_does_not_change_the_ranking(db, punctuated, plain):
180+
"""FTS5 parses '?' and ',' as syntax; a question must rank like a bare query."""
181+
assert _ids(db.hybrid_search(punctuated, k=3)) == _ids(db.hybrid_search(plain, k=3))
182+
183+
184+
def test_the_lexical_side_survives_punctuation():
185+
"""With no vector index there is nowhere else for these hits to come from."""
186+
database = GrafitoDatabase(':memory:')
187+
database.create_text_index("node", "Doc", ["text"])
188+
for row in DOCS:
189+
database.create_node(labels=["Doc"], properties=row)
190+
191+
assert "d1" in _ids(database.hybrid_search("graph databases?", k=2))
192+
database.close()
193+
194+
195+
def test_a_query_of_pure_punctuation_finds_nothing():
196+
"""Nothing to quote as a term, and no vector index to fall back on."""
197+
database = GrafitoDatabase(':memory:')
198+
database.create_text_index("node", "Doc", ["text"])
199+
for row in DOCS:
200+
database.create_node(labels=["Doc"], properties=row)
201+
202+
assert database.hybrid_search("?!", k=2) == []
203+
database.close()
204+
205+
171206
def test_with_neither_index_it_returns_nothing():
172207
database = GrafitoDatabase(':memory:')
173208
database.create_node(labels=["Doc"], properties={"id": "d1", "text": "graph"})

tests/test_subgraph.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,23 @@ def test_text_subgraph_seeds_from_fts(chain):
217217
assert sub.hops[chain.node_ids["C"]] == 0
218218

219219

220+
@pytest.mark.parametrize("query", ["gamma?", "gamma, retrieval", "gamma & retrieval"])
221+
def test_text_subgraph_tolerates_natural_language_punctuation(chain, query):
222+
"""FTS5 would reject these outright; seeding retries them as literal terms."""
223+
sub = chain.text_subgraph(query, k=2, expand=0)
224+
assert "C" in _names(sub)
225+
226+
227+
def test_text_subgraph_still_raises_when_the_retry_cannot_help(chain):
228+
"""Tolerating punctuation must not turn real failures into empty graphs."""
229+
# Nothing to quote as a term: the FTS syntax error stands.
230+
with pytest.raises(DatabaseError, match="Failed to search text index"):
231+
chain.text_subgraph("?!", k=2)
232+
# And validation errors survive the retry rather than being swallowed.
233+
with pytest.raises(DatabaseError, match="k must be"):
234+
chain.text_subgraph("gamma?", k=0)
235+
236+
220237
def test_empty_search_yields_an_empty_subgraph(chain):
221238
sub = chain.subgraph([], expand=2)
222239
assert sub.is_empty()

tests/test_text_search.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import pytest
44

55
from grafito import GrafitoDatabase
6+
from grafito.exceptions import DatabaseError
7+
from grafito.text_index.sqlite_fts import SQLiteFTSIndex
68

79

810
def _fts5_available() -> bool:
@@ -77,3 +79,34 @@ def test_text_search_rebuild_index(db):
7779
results = db.text_search("Graph", labels=["Doc"])
7880
assert len(results) == 1
7981
assert results[0]["entity"].id == node.id
82+
83+
84+
@pytest.mark.parametrize("query", ["graph databases?", "graph, databases", '"unbalanced'])
85+
def test_text_search_reports_invalid_fts_syntax_as_a_database_error(db, query):
86+
"""text_search takes FTS5 syntax, so bad syntax fails — but as our error type.
87+
88+
Free-text callers want :meth:`hybrid_search`, which retries these as literals.
89+
"""
90+
db.create_text_index("node", "Doc", ["title"])
91+
db.create_node(labels=["Doc"], properties={"title": "Graph Databases"})
92+
93+
with pytest.raises(DatabaseError, match="Failed to search text index"):
94+
db.text_search(query, labels=["Doc"])
95+
96+
97+
def test_text_search_still_honours_fts_operators(db):
98+
"""The wrapping must not swallow the query language the docs promise."""
99+
db.create_text_index("node", "Doc", ["title"])
100+
db.create_node(labels=["Doc"], properties={"title": "Graph Databases"})
101+
102+
assert len(db.text_search("graph AND databases", labels=["Doc"])) == 1
103+
assert len(db.text_search("graph OR nothing", labels=["Doc"])) == 1
104+
assert len(db.text_search("dat*", labels=["Doc"])) == 1
105+
106+
107+
def test_custom_fts_backend_reports_invalid_syntax_as_a_database_error(db):
108+
index = SQLiteFTSIndex(db.conn, "custom")
109+
index.add([1], ["graph databases"])
110+
111+
with pytest.raises(DatabaseError, match="Failed to search text index"):
112+
index.search("graph databases?", k=2)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)