feat: trigram FTS5 index for CJK search, replace LIKE fallback (#16651)
* fix: bypass FTS5 for CJK queries in session_search FTS5 default tokenizer splits CJK characters into individual tokens, so multi-character queries like "大别山项目" become AND of single chars. This produces few/no results compared to LIKE substring search. For CJK queries, skip FTS5 entirely and use LIKE for accurate phrase matching. Fixes NousResearch/hermes-agent#15500 * fix: cache _contains_cjk, escape LIKE wildcards, add regression tests On top of the CJK FTS5 bypass from #15509: - Cache _contains_cjk() result in a local var to avoid redundant O(n) scans on every CJK query - Escape %, _ in LIKE queries so literal wildcards in user input are not treated as SQL wildcards (consistent with other LIKE queries in hermes_state.py that use ESCAPE '\') - Fix misleading comment ('or CJK fallback' → accurate description) - Add 3 regression tests: - test_cjk_partial_fts5_results_supplemented_by_like (#15500 / #14829) - test_cjk_like_dedup_no_duplicates - test_cjk_like_escapes_wildcards (new wildcard escaping) * feat: trigram FTS5 index for CJK search, replace LIKE fallback Replace the LIKE '%query%' full-table-scan fallback for CJK queries with a proper trigram FTS5 index (messages_fts_trigram). The trigram tokenizer creates overlapping 3-byte sequences so substring matching works natively for any script — CJK, Thai, etc. For queries with 3+ CJK characters: uses the trigram FTS5 table with proper ranking, snippets, and indexed lookups. For shorter queries (1-2 CJK chars): falls back to LIKE since the trigram tokenizer needs ≥9 UTF-8 bytes (3 CJK chars) minimum. Schema v10 migration creates the trigram table and backfills existing messages. Triggers keep the index in sync on INSERT/UPDATE/DELETE. Builds on top of #16276 (bypass FTS5 for CJK, escape LIKE wildcards). --------- Co-authored-by: vominh1919 <vominh1919@gmail.com>
This commit is contained in:
@@ -772,6 +772,51 @@ class TestCJKSearchFallback:
|
||||
results = db.search_messages("Agent通信")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_cjk_partial_fts5_results_supplemented_by_like(self, db):
|
||||
"""When FTS5 returns *some* CJK results, LIKE must still find all matches.
|
||||
|
||||
Regression test for #15500 / #14829: FTS5 unicode61 tokenizer drops
|
||||
certain CJK characters, so multi-character queries may return partial
|
||||
results. The LIKE path must always run for CJK queries.
|
||||
"""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.create_session(session_id="s2", source="telegram")
|
||||
db.append_message("s1", role="user", content="昨晚讨论了记忆系统")
|
||||
db.append_message("s2", role="user", content="昨晚的会议纪要已发送")
|
||||
results = db.search_messages("昨晚")
|
||||
assert len(results) == 2
|
||||
session_ids = {r["session_id"] for r in results}
|
||||
assert session_ids == {"s1", "s2"}
|
||||
|
||||
def test_cjk_like_dedup_no_duplicates(self, db):
|
||||
"""When FTS5 and LIKE both find the same message, no duplicates."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="测试去重逻辑")
|
||||
results = db.search_messages("测试")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_cjk_like_escapes_wildcards(self, db):
|
||||
"""Special characters (%, _) in CJK queries are treated as literals."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.create_session(session_id="s2", source="cli")
|
||||
db.append_message("s1", role="user", content="达成100%完成率")
|
||||
db.append_message("s2", role="user", content="达成100完成率是目标")
|
||||
# The % in the query must be literal — should only match s1
|
||||
results = db.search_messages("100%完成")
|
||||
assert len(results) == 1
|
||||
assert results[0]["session_id"] == "s1"
|
||||
|
||||
def test_cjk_trigram_preserves_boolean_operators(self, db):
|
||||
"""Boolean operators (OR, AND, NOT) work in CJK trigram queries."""
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.create_session(session_id="s2", source="cli")
|
||||
db.append_message("s1", role="user", content="记忆系统很好用")
|
||||
db.append_message("s2", role="user", content="断裂连接需要修复")
|
||||
results = db.search_messages("记忆系统 OR 断裂连接")
|
||||
assert len(results) == 2
|
||||
session_ids = {r["session_id"] for r in results}
|
||||
assert session_ids == {"s1", "s2"}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Session search and listing
|
||||
@@ -1229,7 +1274,7 @@ class TestSchemaInit:
|
||||
def test_schema_version(self, db):
|
||||
cursor = db._conn.execute("SELECT version FROM schema_version")
|
||||
version = cursor.fetchone()[0]
|
||||
assert version == 9
|
||||
assert version == 10
|
||||
|
||||
def test_title_column_exists(self, db):
|
||||
"""Verify the title column was created in the sessions table."""
|
||||
@@ -1290,7 +1335,7 @@ class TestSchemaInit:
|
||||
|
||||
# Verify migration
|
||||
cursor = migrated_db._conn.execute("SELECT version FROM schema_version")
|
||||
assert cursor.fetchone()[0] == 9
|
||||
assert cursor.fetchone()[0] == 10
|
||||
|
||||
# Verify title column exists and is NULL for existing sessions
|
||||
session = migrated_db.get_session("existing")
|
||||
|
||||
Reference in New Issue
Block a user