fix(agent): recover pure-Latin search matches embedded in CJK text (#54242)

A pure-Latin query (no CJK characters) routes to the unicode61
`messages_fts` table, whose tokenizer does not insert a boundary between
Latin letters and adjacent CJK characters. Content like "修改youer服务端" is
indexed as a single token, so `search_messages("youer")` returned zero
results even though the substring is present, and the Latin path had no
fallback.

Add a zero-result trigram fallback to the pure-Latin path: when the
unicode61 search misses, retry against the existing `messages_fts_trigram`
table, which matches substrings regardless of word boundaries. The fallback
is gated on `_trigram_available` and on every token being >=3 chars (the
trigram minimum), and only fires on a zero-result miss, so successful Latin
searches keep their unicode61 ranking unchanged.

The trigram query construction shared with the CJK path is extracted into a
`_run_trigram_search()` helper; the CJK branch is refactored to use it with
no behavior change.

Adds regression tests in tests/test_hermes_state.py::TestCJKSearchFallback.
This commit is contained in:
gnanam1990
2026-06-28 19:39:45 +05:30
committed by Teknium
parent 59614ef9af
commit 96560ee60f
2 changed files with 191 additions and 0 deletions
+151
View File
@@ -6777,6 +6777,102 @@ class SessionDB:
run = 0
return run == 1
@staticmethod
def _trigram_eligible_tokens(query: str) -> bool:
"""True when every non-operator token is long enough for the trigram
tokenizer to match (>=3 chars).
The trigram tokenizer indexes overlapping 3-character sequences, so a
token shorter than 3 chars produces no trigrams and can never match.
With FTS5's implicit-AND between tokens, a single short token makes the
whole MATCH return nothing, so the trigram path is only worth taking
when every searchable token qualifies.
"""
tokens = [
t for t in query.strip('"').strip().split()
if t.upper() not in {"AND", "OR", "NOT"}
]
return bool(tokens) and all(len(t) >= 3 for t in tokens)
def _run_trigram_search(
self,
raw_query: str,
*,
table: str = "messages_fts_trigram",
order_by_sql: str,
include_inactive: bool,
source_filter: List[str] = None,
exclude_sources: List[str] = None,
role_filter: List[str] = None,
limit: int = 20,
offset: int = 0,
) -> Optional[List[Dict[str, Any]]]:
"""Run a search against a substring-capable FTS index.
``table`` is ``messages_fts_trigram`` (default) or
``messages_fts_cjk``. The trigram tokenizer indexes overlapping
3-byte sequences, so it matches substrings regardless of word
boundaries — both CJK phrases the unicode61 tokenizer splits into
single characters and Latin runs the unicode61 tokenizer fuses onto
adjacent CJK (e.g. ``修改youer服务端``). The cjk-bigram tokenizer
splits Latin runs off adjacent CJK, giving the same recovery as an
exact ranked token match. Each non-operator token is quoted to
neutralise FTS5 special characters while boolean operators
(AND/OR/NOT) are preserved.
Returns the matching rows, or ``None`` when the query cannot be
executed (e.g. the tokenizer is unavailable at runtime) so the
caller can fall back to another strategy.
"""
tokens = raw_query.split()
parts = []
for tok in tokens:
if tok.upper() in {"AND", "OR", "NOT"}:
parts.append(tok)
else:
parts.append('"' + tok.replace('"', '""') + '"')
trigram_query = " ".join(parts)
tri_where = [f"{table} MATCH ?"]
tri_params: list = [trigram_query]
if not include_inactive:
tri_where.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
tri_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
tri_params.extend(source_filter)
if exclude_sources is not None:
tri_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
tri_params.extend(exclude_sources)
if role_filter:
tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
tri_params.extend(role_filter)
tri_sql = f"""
SELECT
m.id,
m.session_id,
m.role,
snippet({table}, -1, '>>>', '<<<', '...', 40) AS snippet,
m.content,
m.timestamp,
m.tool_name,
s.source,
s.model,
s.started_at AS session_started
FROM {table}
JOIN messages m ON m.id = {table}.rowid
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(tri_where)}
{order_by_sql}
LIMIT ? OFFSET ?
"""
tri_params.extend([limit, offset])
with self._lock:
try:
tri_cursor = self._conn.execute(tri_sql, tri_params)
except sqlite3.OperationalError:
# Query failed at runtime — let the caller fall back.
return None
return [dict(row) for row in tri_cursor.fetchall()]
def search_messages(
self,
query: str,
@@ -7279,6 +7375,61 @@ class SessionDB:
except sqlite3.OperationalError as exc:
logger.debug("Unindexed-gap supplement skipped: %s", exc)
# Pure-Latin queries run against the unicode61 ``messages_fts`` table,
# whose tokenizer does not insert a boundary between Latin letters and
# adjacent CJK characters: "修改youer服务端" is indexed as one token,
# so MATCH "youer" finds nothing even though the substring is present
# (#54242). When the exact-token search returns nothing, retry on the
# substring-capable indexes. Preference order:
# 1. messages_fts_cjk (when built): its tokenizer splits Latin runs
# off adjacent CJK, so "youer" is an exact ranked token match.
# 2. messages_fts_trigram: substring matching, needs >=3-char
# tokens (shorter tokens produce no trigrams).
# Gated on a zero-result miss so successful Latin searches keep their
# unicode61 ranking — strictly additive, never reorders existing
# hits. Trade-off on the trigram leg: any zero-result Latin query
# gains substring semantics (e.g. "cat" can then match
# "concatenate"). Genuinely absent terms still return []. Skipped for
# role_filter=['tool'] queries — both fallback indexes exclude tool
# rows (v23), so a retry could never add hits.
if (
not matches
and not is_cjk
and not (bool(role_filter) and "tool" in role_filter)
):
_fb_query = query.strip('"').strip()
if self._fts_cjk_available:
cjk_fb = self._run_trigram_search(
_fb_query,
table="messages_fts_cjk",
order_by_sql=order_by_sql,
include_inactive=include_inactive,
source_filter=source_filter,
exclude_sources=exclude_sources,
role_filter=role_filter,
limit=limit,
offset=offset,
)
if cjk_fb:
matches = cjk_fb
if (
not matches
and self._trigram_available
and self._trigram_eligible_tokens(query)
):
tri_matches = self._run_trigram_search(
_fb_query,
order_by_sql=order_by_sql,
include_inactive=include_inactive,
source_filter=source_filter,
exclude_sources=exclude_sources,
role_filter=role_filter,
limit=limit,
offset=offset,
)
if tri_matches:
matches = tri_matches
# Add surrounding context (1 message before + after each match).
# Done outside the lock so we don't hold it across N sequential queries.
for match in matches:
+40
View File
@@ -2425,6 +2425,46 @@ class TestCJKSearchFallback:
results = db.search_messages("Agent通信")
assert len(results) == 1
def test_pure_latin_word_embedded_in_cjk_is_found(self, db):
"""Regression for #54242.
A pure-Latin query (no CJK chars) routes to the unicode61 ``messages_fts``
table, whose tokenizer fuses a Latin run onto the adjacent CJK characters
("修改youer服务端" is indexed as a single token), so MATCH "youer" returns
nothing. The zero-result trigram fallback must recover the match.
"""
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="修改youer服务端的计划")
results = db.search_messages("youer")
assert len(results) == 1
assert results[0]["session_id"] == "s1"
def test_pure_latin_query_with_normal_match_is_unaffected(self, db):
"""A normal space-delimited Latin query still resolves on the unicode61
path; the zero-result trigram fallback only fires when it misses."""
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="deploy the docker container")
results = db.search_messages("docker")
assert len(results) == 1
assert results[0]["session_id"] == "s1"
def test_pure_latin_query_absent_term_returns_empty(self, db):
"""A Latin term that is genuinely absent must still return nothing,
even with the trigram fallback enabled."""
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="修改youer服务端的计划")
assert db.search_messages("kubernetes") == []
def test_pure_latin_embedded_fallback_preserves_source_filter(self, db):
"""The embedded-Latin trigram fallback must honour source_filter."""
db.create_session(session_id="s1", source="cli")
db.create_session(session_id="s2", source="telegram")
db.append_message("s1", role="user", content="修改youer服务端cli")
db.append_message("s2", role="user", content="修改youer服务端telegram")
results = db.search_messages("youer", source_filter=["telegram"])
assert len(results) == 1
assert results[0]["source"] == "telegram"
def test_cjk_partial_fts5_results_supplemented_by_like(self, db):
"""When FTS5 returns *some* CJK results, LIKE must still find all matches.