{"repo":"NousResearch/hermes-agent","public_id":"20494","display_label":"Short CJK OR queries in session search returning no hits","summary":"The members concern session_search returning zero results for OR-combined CJK queries of two characters or fewer, where the trigram approach is selected but cannot match. Two pull requests propose routing such queries to a LIKE fallback, one described as a salvage of #22649.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22847,"merged_at":"2026-05-10T00:53:03+00:00"},"resolution":{"state":"resolved","by":"pr-22847"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T00:53:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":20494,"pr_number":22847,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-10T00:53:03+00:00"}],"members":[{"kind":"issue","number":20494,"title":"session_search returns 0 hits for OR-combined ≤2-char CJK queries (trigram picked but can't match)","url":null,"state":"closed"},{"kind":"pr","number":20499,"title":"fix(session-search): route short CJK OR queries to LIKE","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"058c50816c70c5f9a1253a87776d50e8df4c5dcf","number":0},{"kind":"pr","value":"#20494","number":20494}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22847,"title":"fix(session): route OR-combined short CJK tokens to LIKE fallback (salvage #22649)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_state.py","tests/test_hermes_state.py"],"fix_hunks":[{"file":"hermes_state.py","hunks":["@@ -1958,7 +1958,19 @@ def search_messages(\n             raw_query = query.strip('\"').strip()\n             cjk_count = self._count_cjk(raw_query)\n \n-            if cjk_count >= 3:\n+            # Per-token CJK length check (#20494): trigram needs >=3 CJK chars\n+            # per token. A query like \"广西 OR 桂林 OR 漓江\" has cjk_count=6\n+            # (>=3) but each individual token is only 2 chars — trigram returns 0.\n+            # Route to LIKE when any non-operator CJK token is <3 CJK chars.\n+            _tokens_for_check = [\n+                t for t in raw_query.split()\n+                if t.upper() not in (\"AND\", \"OR\", \"NOT\") and self._contains_cjk(t)\n+            ]\n+            _any_short_cjk = any(\n+                self._count_cjk(t) < 3 for t in _tokens_for_check\n+            )\n+\n+            if cjk_count >= 3 and not _any_short_cjk:\n                 # Trigram FTS5 path — quote each non-operator token to handle\n                 # FTS5 special chars (%, *, etc.) while preserving boolean\n                 # operators (AND, OR, NOT) for multi-term queries.","@@ -2009,11 +2021,24 @@ def search_messages(\n                     else:\n                         matches = [dict(row) for row in tri_cursor.fetchall()]\n             else:\n-                # Short CJK query (1-2 chars) — trigram needs ≥3 CJK chars.\n-                # Fall back to LIKE substring search.\n-                escaped = raw_query.replace(\"\\\\\", \"\\\\\\\\\").replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\")\n-                like_where = [\"(m.content LIKE ? ESCAPE '\\\\' OR m.tool_name LIKE ? ESCAPE '\\\\' OR m.tool_calls LIKE ? ESCAPE '\\\\')\"]\n-                like_params: list = [f\"%{escaped}%\", f\"%{escaped}%\", f\"%{escaped}%\"]\n+                # Short / mixed CJK query: trigram cannot match tokens with\n+                # <3 CJK chars. Fall back to LIKE substring search.\n+                # For multi-token OR queries (e.g. \"广西 OR 桂林 OR 漓江\"),\n+                # build one LIKE condition per non-operator token so each term\n+                # is matched independently (#20494).\n+                non_op_tokens = [\n+                    t for t in raw_query.split()\n+                    if t.upper() not in (\"AND\", \"OR\", \"NOT\")\n+                ] or [raw_query]\n+                token_clauses = []\n+                like_params: list = []\n+                for tok in non_op_tokens:\n+                    esc = tok.replace(\"\\\\\", \"\\\\\\\\\").replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\")\n+                    token_clauses.append(\n+                        \"(m.content LIKE ? ESCAPE '\\\\' OR m.tool_name LIKE ? ESCAPE '\\\\' OR m.tool_calls LIKE ? ESCAPE '\\\\')\"\n+                    )\n+                    like_params += [f\"%{esc}%\", f\"%{esc}%\", f\"%{esc}%\"]\n+                like_where = [f\"({' OR '.join(token_clauses)})\"]\n                 if source_filter is not None:\n                     like_where.append(f\"s.source IN ({','.join('?' for _ in source_filter)})\")\n                     like_params.extend(source_filter)","@@ -2037,8 +2062,8 @@ def search_messages(\n                     LIMIT ? OFFSET ?\n                 \"\"\"\n                 like_params.extend([limit, offset])\n-                # instr() parameter goes first in the bound list\n-                like_params = [raw_query] + like_params\n+                # instr() for snippet uses first search token\n+                like_params = [non_op_tokens[0]] + like_params\n                 with self._lock:\n                     like_cursor = self._conn.execute(like_sql, like_params)\n                     matches = [dict(row) for row in like_cursor.fetchall()]"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -957,6 +957,39 @@ def test_cjk_trigram_preserves_boolean_operators(self, db):\n         session_ids = {r[\"session_id\"] for r in results}\n         assert session_ids == {\"s1\", \"s2\"}\n \n+    def test_cjk_or_combined_short_tokens_returns_results(self, db):\n+        \"\"\"Regression test for #20494.\n+\n+        OR-combined 2-char CJK tokens (e.g. \"广西 OR 桂林 OR 漓江 OR 旅游\")\n+        previously returned 0 results because _count_cjk of the whole query\n+        was >=3 (8 chars here), selecting the trigram path, but each individual\n+        token is only 2 CJK chars and trigram requires >=3 chars per token.\n+        The per-token check must route such queries to the LIKE fallback.\n+        \"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        db.create_session(session_id=\"s2\", source=\"telegram\")\n+        db.create_session(session_id=\"s3\", source=\"cli\")\n+        db.append_message(\"s1\", role=\"user\", content=\"广西是个好地方，去过桂林\")\n+        db.append_message(\"s2\", role=\"user\", content=\"漓江风景很美，值得旅游\")\n+        db.append_message(\"s3\", role=\"user\", content=\"unrelated English content\")\n+\n+        results = db.search_messages(\"广西 OR 桂林 OR 漓江 OR 旅游\")\n+        session_ids = {r[\"session_id\"] for r in results}\n+        assert \"s1\" in session_ids, \"广西/桂林 terms not matched\"\n+        assert \"s2\" in session_ids, \"漓江/旅游 terms not matched\"\n+        assert \"s3\" not in session_ids, \"unrelated message must not match\"\n+\n+    def test_cjk_short_token_or_query_preserves_filters(self, db):\n+        \"\"\"Source filter applies correctly in the short-token LIKE path (#20494).\"\"\"\n+        db.create_session(session_id=\"s1\", source=\"cli\")\n+        db.create_session(session_id=\"s2\", source=\"telegram\")\n+        db.append_message(\"s1\", role=\"user\", content=\"广西旅游攻略cli\")\n+        db.append_message(\"s2\", role=\"user\", content=\"广西旅游攻略telegram\")\n+\n+        results = db.search_messages(\"广西 OR 旅游\", source_filter=[\"telegram\"])\n+        assert len(results) == 1\n+        assert results[0][\"source\"] == \"telegram\"\n+\n \n # =========================================================================\n # Session search and listing\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}