|
38 | 38 | #: Matches txtai's minscore default. |
39 | 39 | DEFAULT_SEMANTIC_GRAPH_MIN_SCORE = 0.1 |
40 | 40 |
|
| 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 | + |
41 | 60 | if TYPE_CHECKING: # pragma: no cover - typing only |
42 | 61 | from .algorithms import Community |
43 | 62 | from .ingest_report import IndexReport, SemanticGraphReport |
@@ -782,8 +801,14 @@ def hybrid_search( |
782 | 801 | Degrades rather than fails: with no text index configured, or no vector |
783 | 802 | index, whichever side works is returned on its own. |
784 | 803 |
|
| 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 | +
|
785 | 808 | 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. |
787 | 812 | k: Number of results. |
788 | 813 | index: Vector index to search. |
789 | 814 | vector_k, text_k: Candidates to draw from each side before fusing. |
@@ -833,15 +858,28 @@ def hybrid_search( |
833 | 858 | # No vector index, or no embedding function to turn text into one. |
834 | 859 | vector_hits = [] |
835 | 860 |
|
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 [ |
839 | 863 | {"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) |
841 | 865 | if row.get("entity_type") == "node" |
842 | 866 | ] |
| 867 | + |
| 868 | + text_hits: list[dict[str, Any]] = [] |
| 869 | + try: |
| 870 | + text_hits = lexical_side(query) |
843 | 871 | 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 = [] |
845 | 883 |
|
846 | 884 | if not text_hits: |
847 | 885 | return vector_hits[:k] |
@@ -1157,16 +1195,33 @@ def text_subgraph( |
1157 | 1195 | subgraph. |
1158 | 1196 |
|
1159 | 1197 | 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. |
1161 | 1202 | k: Number of seed hits. |
1162 | 1203 | search_labels: Restrict the text search to these labels. |
1163 | 1204 | expand, direction, rel_types, exclude_rel_types, labels, max_nodes, |
1164 | 1205 | include_edges: As in :meth:`subgraph`. |
1165 | 1206 |
|
1166 | 1207 | Returns: |
1167 | 1208 | 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. |
1168 | 1214 | """ |
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) |
1170 | 1225 | seeds = [ |
1171 | 1226 | {"node": hit["entity"], "score": hit["score"]} |
1172 | 1227 | for hit in hits |
@@ -2409,7 +2464,17 @@ def text_search( |
2409 | 2464 | labels: list[str] | None = None, |
2410 | 2465 | rel_types: list[str] | None = None, |
2411 | 2466 | ) -> 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 | + """ |
2413 | 2478 | if not query or not query.strip(): |
2414 | 2479 | raise DatabaseError("Query cannot be empty") |
2415 | 2480 | if k is None: |
@@ -2476,9 +2541,14 @@ def build_query(entity: str, label_filter: list[str] | None) -> tuple[str, list[ |
2476 | 2541 | sql += " ORDER BY score ASC LIMIT ?" |
2477 | 2542 | params.append(k) |
2478 | 2543 |
|
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 | + |
2480 | 2550 | results = [] |
2481 | | - for row in cursor.fetchall(): |
| 2551 | + for row in rows: |
2482 | 2552 | entity_type = row["entity_type"] |
2483 | 2553 | entity_id = int(row["entity_id"]) |
2484 | 2554 | score = float(row["score"]) |
|
0 commit comments