fix(knowledge): restore CJK recall on the FTS keyword leg - #7647
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause index-side fix with measured rejection of alternatives, disclosed one-way migration on rebuildable derived data, and gaps scoped out explicitly rather than silently. [DESIGN-REVIEWED] e540e8f |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/_sqlite_compat.py:159 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All counts verified. Producing the review. First-Principles-Verdict: PASS A reported recall-zero defect fixed at its root — the index representation — with every rider derived from the same issue and every count checked. What this change shipsIntent: make a knowledge search typed in Chinese/Japanese actually return keyword matches. FIX.
WatchSibling count confirmed: 3 FTS5 product tables (grepped [FIRST-PRINCIPLES-REVIEWED] e540e8f |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Review round 1 disposition (head
|
| before | after | |
|---|---|---|
| construction (boot path, on the loop) | ~137 ms | 1.3 ms |
user_version after construction |
migrated | 0 (not migrated) |
| first search (worker thread) | - | 137 ms |
| subsequent readers | - | ~1.3 us |
Three new tests pin it: test_construction_does_not_rebuild_the_index,
test_rebuild_runs_once_across_concurrent_readers,
test_retriever_leg_migrates_a_legacy_index.
PR Hygiene -- FAILURE: missing ## Pattern harvest -- FIXED
Added to the PR body. Rule candidate is a semgrep pattern for direct writes to an
FTS5 shadow table outside its owning wrapper: the un-index half of a derived-term
index disagreeing with the write half is invisible to FTS5 ('integrity-check'
passes over a mismatched 'delete'), so the guard has to be structural. The
constructor-migration half is already covered by the existing
no-blocking-call-on-event-loop anchor, so I did not propose a second rule for it.
Verification on this head
619 tests pass across nine targeted suites; flake8, isort, mypy,
./scripts/docs-lint.sh and git diff --check all clean. Specs moved with the
code (knowledge.md, memory-skills-hooks.md). Still one commit.
5acac01 to
95d6781
Compare
Review round 2 disposition (head
|
95d6781 to
74225f5
Compare
74225f5 to
955792a
Compare
Review round 3 disposition (head
|
955792a to
fe4ffd0
Compare
Review round 4 disposition (head
|
fe4ffd0 to
175a6c1
Compare
Round 5 (head
|
d34a699 to
cc551c7
Compare
buluoray
left a comment
There was a problem hiding this comment.
Verdict: 0 blocking, 2 non-blocking. Substantive review of cc551c7c0a236b5f8de99fe0cbe220fd3b101147.
This is a real root-cause fix. unicode61 stores a spaceless CJK run as a single term, so a query only matched when it equalled an entire indexed run byte-for-byte; the fix segments the index copy to one term per CJK character and expands a query to OR-ed adjacent-character phrases. I verified it against the FTS-injection standard rather than trusting the green lanes.
What I verified
- Injection safety (the primary concern). Every user-derived substring reaching an FTS5 MATCH expression is wrapped in a double-quoted phrase with internal
"doubled. In_sqlite_compat.py,fts5_cjk_match_groupsquotes non-CJK chunks ('"' + chunk.replace('"', '""') + '"') and builds CJK bigram phrases from characters that by construction (_CJK_RANGES) can never be",*,^, or an operator word. Only(,),OR,ANDare emitted unquoted, never from user content. So*,^, a lone", orAND/OR/NOTin user input become literal phrase text — no operator injection and no FTS5 syntax error on ordinary input.test_quotes_in_input_cannot_escape_the_literal(a" OR body:*->['"a"""', '"OR"', '"body:*"']) pins this. - All three FTS readers route through the shared dialect.
KnowledgeStore._sanitize_fts5(store.py:1561," AND ".join(fts5_cjk_match_groups(...))),HybridRetriever._sanitize_fts5_query(retrieval.py:320," OR ".join(...)), and_entity_items_rows(dashboard/handlers/knowledge.py:600, single segmented phrase). All useitems_fts MATCH ?parameter binding; the expression string is the only thing constructed, which is inherent to FTS5 and correctly escaped. - Latin/ASCII recall unchanged.
fts5_cjk_match_groupsis proven byte-identical to the previousfts5_quote_tokensfor non-CJK input (test_non_cjk_expression_is_unchanged), the join operators are unchanged from before, and space-join vs" AND "are equivalent in FTS5.test_ascii_search_behaviour_is_unchangedconfirmsokedoes not matchtokens(no trigram-style substring bleed). - Existing rows are reindexed, not just new ones.
_migrate_fts_indexrebuildsitems_ftsfromitems, gated onPRAGMA user_version(the CREATE text is identical across formats), triggered lazily by the first reader off the event loop, atomic, and idempotent on interruption. Legacy-rebuild is pinned (test_legacy_index_is_rebuilt_on_open,test_rebuild_spans_more_than_one_batch,test_retriever_leg_migrates_a_legacy_index). The one-way-door downgrade cost is documented indocs/system-specs/modules/knowledge.md. - Un-index correctness.
_fts_unindexdeletes with the same representation the index holds (_fts_terms_segmented), avoiding the silent stale-hit /malformed imagefailure thatintegrity-checkdoes not catch. The write funnel is mechanically enforced (test_every_items_fts_write_goes_through_the_wrappers, mutation-verified per the round-6 disposition). - Pathological input. The graph-leg substring enumeration is explicitly bounded (
_CJK_SUBRUN_MAX_LEN=6,_CJK_SUBRUN_MAX_CANDIDATES=24,retrieval.py). Query-side bigram expansion is O(n) in query length, not exponential. - Spec/AGENTS requirement.
knowledge.mdandmemory-skills-hooks.mdmoved in the same commit; Docs Lint is green.
Findings
-
Non-blocking —
store.pyensure_fts_index_currentcatchesOperationalErrormore broadly than its docstring claims. The docstring says "Only lock/contention errors are absorbed," butsqlite3.OperationalErroralso covers disk-full and disk-I/O errors. On such an error the migration is swallowed with a WARNING and the search is served against the un-migrated (legacy) index. Consequence: a persistent disk fault degrades CJK recall silently and indefinitely (retried every read) rather than surfacing. This is graceful degradation, not a crash or data loss — matching GPT 5.6's own non-blocking finding. Suggestion: narrow the catch to the SQLITE_BUSY/LOCKED codes (inspectexc.sqlite_errorcode), or reword the docstring to state that allOperationalErrors are absorbed by design. -
Non-blocking (observation, not a defect) — query-side CJK expansion has no explicit length cap. A pathologically long spaceless CJK query expands to n-1 OR-ed bigram phrases in one MATCH group. This is linear and bounded by the user's own query length (a search box never realistically sees multi-thousand-character input), and the far more dangerous graph-leg enumeration is already capped. No change needed; noted only because the changed path is a user-text-to-FTS surface.
What I could not verify
- I did not re-run the suite or the "red on base" claim — the shared checkout is read-only for this session and no writes were permitted. I relied on tests that structurally cannot pass on
main(e.g.fts5_cjk_match_groupsdoes not exist there) plus the green CI lanes and the mutation evidence in the author's round dispositions. - The 137 ms migration / write-path segmentation cost figures are taken from the PR body, not independently measured.
- The PR is currently
mergeable=dirty(conflicting) and needs a rebase before merge; the full green check suite ran on this SHA before the conflict appeared, so signals remain valid, but a rebase will re-dispatch the lanes.
cc551c7 to
f4f118d
Compare
FTS5's default unicode61 tokenizer classifies CJK ideographs as letters, so it stores an entire spaceless run as ONE token: a whole clause becomes a single term and no query can address a word inside it. The query side split on whitespace, which makes a spaceless CJK query one token too, so both sides were wrong in the same direction and therefore agreed. The keyword leg returned nothing for an ordinary CJK query, and the vector leg rescued the hybrid result set, which is why the recall loss was invisible. The index copy of title/content/tags is now written with a boundary around each CJK character, so the tokenizer emits one term per character. A query run expands to its overlapping adjacent-character pairs as FTS5 phrases, OR-ed. A phrase over per-character terms is exact substring matching, so a four-character query matches a document that spells the run verbatim and one that spells the two words apart, while a document that merely reuses those characters in other words is excluded -- the same adjacency floor the session search already applies (parse_search_query, PR #3681). Query-side expansion alone cannot fix this and was measured, not assumed: it recovers only the case where the document itself has spaces between the CJK words, which is not how CJK prose is written. The trigram tokenizer was rejected on two measured counts: a two-character query returns nothing (trigram needs three), and it turns on substring matching for every language ("oke" starts matching "tokens"). Only the copy handed to the index is transformed. items_fts is an external-content table, so snippet()/highlight() and every read of the item still see the original text. All five FTS writers now route through _fts_index / _fts_unindex. FTS5's 'delete' command subtracts the terms it is handed, so un-indexing with raw text against a segmented index leaves the old terms in place and keeps serving superseded content -- and 'integrity-check' does not report it. The term representation is versioned by PRAGMA user_version, because the CREATE VIRTUAL TABLE text is identical before and after and no schema probe can tell the two apart. The rebuild is batched and bumps the marker only after it commits, so an interrupted rebuild restarts on the next open. The rebuild is triggered by readers, not by the constructor. __init__ runs on the event-loop thread (setup_knowledge_routes reads the lazy state.knowledge_store property during dashboard startup) while FTS readers run on worker threads, so migrating in __init__ would stall the gateway at boot for the length of a full reindex -- measured at ~137 ms for 4,000 CJK items, growing linearly. ensure_fts_index_current puts that cost on the first search instead, lock-guarded so concurrent readers wait rather than each starting a rebuild; steady state is one boolean check. Because the rebuild is deferred, a writer can reach a not-yet-migrated index, so writes follow the representation the database declares rather than always segmenting. Handing segmented terms to a raw index does not merely mismatch, it raises "database disk image is malformed": FTS5's 'delete' subtracts the exact terms it is given. A legacy database has writers that run before any reader can migrate it -- the orphan reclaim in _migrate, inside the constructor, and the startup watcher sweep updating or deleting an item before the first search -- so unconditional segmentation is not available. The declaration is latched only in the True direction, since another process on the same database may migrate it at any time and a cached False would keep writing raw terms into a migrated index. That declaration is serialized by SQLite's writer lock, not a Python one: every FTS-touching transaction is BEGIN IMMEDIATE, so a reader of the declaration already excludes the rebuild that changes it -- across processes as well as threads. The FTS write path therefore takes no Python lock at all. Taking one there inverts against SQLite's: a writer holding SQLite's lock waits on Python's while the rebuilding reader holds Python's and waits on SQLite's, which deadlocks until busy_timeout and returns 500 from an event-loop write. The remaining _fts_lock guards only the rebuild, so two reader threads in one process do not both start one, and it is always acquired before SQLite's. The entity-items endpoint is the third FTS reader and built its own query by quoting the whole entity name, which matches nothing against a character-segmented index. It now matches the name as ONE phrase over the segmented text -- byte-identical to the old quoting for a name with no CJK, so a multi-word ASCII entity still requires its words adjacent -- and runs off the loop via asyncio.to_thread, like the other knowledge readers in that file. The spaceless-script character set now has one owner: history_search's _is_cjk_char delegates to _sqlite_compat.is_cjk_char instead of hand-carrying a second copy of the same ranges, so session search and knowledge search cannot drift apart on what they segment. Moving the entity-items lookup off the loop removes one on-loop blocking call, so .github/sync-io-in-async-baseline.txt drops that file from 63 to 62 -- the shrink-only ratchet requires recorded progress. Closes #3691
f4f118d to
e540e8f
Compare
buluoray
left a comment
There was a problem hiding this comment.
Verdict: Approve. 0 blocking, 2 non-blocking. This is a re-review after a rebase that dismissed an earlier approval of cc551c7.
What I verified
This is a re-review triggered by the rebase from the previously-approved cc551c7c0a236b5f8de99fe0cbe220fd3b101147 to the current head e540e8f094f6fa01205fcab64cd5942bb0b2f05a. I confirmed the rebase is content-neutral before re-deriving anything: the blob SHAs of all six source files, test/test_knowledge.py, and docs/system-specs/modules/knowledge.md are byte-identical between cc551c7 and e540e8f. The base moved (which auto-dismissed the approval and re-ran the bots); the PR's own tree did not. All four AI lanes re-passed on the current head (Design PASS, First Principles PASS, GPT 5.6 no blocking, Opus 4.8 no findings), 60+ checks green/skipped, none failing, mergeable_state=blocked is just the missing approval.
Re-confirmed at the current head that the properties the earlier review relied on still hold:
- FTS injection safety holds across every reader. There are exactly three FTS readers and each binds
MATCH ?with user text carried only as a bound parameter, never interpolated:HybridRetriever._keyword_search(retrieval.py:280-282):WHERE items_fts MATCH ?,params=[safe_query], and the execute is wrapped inexcept sqlite3.OperationalError: return [](retrieval.py:294-297).KnowledgeStore.search_items_fts(store.py:1556):WHERE items_fts MATCH ?, bound to_sanitize_fts5(query)._entity_items_rows(dashboard/handlers/knowledge.py):MATCH ?bound to(phrase,).- The expression builders (
fts5_cjk_match_groups,_sanitize_fts5, the entityphrase) wrap user text as double-quote-escaped FTS5 phrases (internal"doubled), so*,^, a lone quote andAND/OR/NOTbecome literal phrase text and cannot inject operators or raise a syntax error.test_quotes_in_input_cannot_escape_the_literalpins this ('a" OR body:*'->['"a"""', '"OR"', '"body:*"']).
- ASCII/Latin recall is unchanged, not merely CJK improved.
fts5_cjk_match_groupsreturns exactly whatfts5_quote_tokensreturns for non-CJK input, andfts5_segment_for_indexreturns non-CJK text byte-identical, so both index and query paths are untouched for Latin. The store's direct search moved from space-joined phrases to" AND ".join(...), which is the same AND semantics FTS5 already applied to space-separated phrases. Pinned bytest_ascii_search_behaviour_is_unchanged(incl. theoke/tokensnon-match that rules out substring/trigram behavior) andtest_non_cjk_expression_is_unchanged. - The reindex covers pre-existing rows.
_migrate_fts_indexdoes'delete-all'then re-inserts every row fromitemsin_FTS_REBUILD_BATCHbatches, gated onPRAGMA user_versionvsFTS_INDEX_VERSION, triggered by all three readers viaensure_fts_index_current. Not new-documents-only. Pinned bytest_legacy_index_is_rebuilt_on_open,test_retriever_leg_migrates_a_legacy_index, andtest_rebuild_spans_more_than_one_batch. - Un-index uses the same representation as index.
_fts_unindexand_fts_indexboth derive terms through_fts_terms/_fts_terms_segmented, so a'delete'subtracts the terms that were actually stored. All five writers (add_item,update_item,_delete_item_cascade,delete_source_cascade,import_bundle) route through the two wrappers, enforced structurally bytest_every_items_fts_write_goes_through_the_wrappers; the legacy-before-migration window is handled because_fts_terms_segmented()reads the DB's declared representation and writes raw terms to a raw index (test_legacy_write_before_any_search_does_not_corrupt,test_legacy_delete_of_cjk_item_does_not_raise). - The diff matches what the body claims (index-side segmentation, per-character-pair phrase query OR-ed, five writers funneled, versioned lazy migration, reader-triggered rebuild, three readers migrating). The system-specs update
knowledge.md/memory-skills-hooks.mdis in-commit and the Docs/Feature-Map gates are green.
Findings (non-blocking)
-
_sqlite_compat.py:159(fts5_cjk_match_groups) — a non-CJK chunk inside a mixed token is emitted as its own quoted phrase and AND-ed with the CJK group. A query that appends fullwidth punctuation to a CJK run (e.g.内存泄漏?) splits into a CJK run plus the punctuation-only chunk"?", which tokenizes to zero terms underunicode61, so the AND yields no keyword hits. Consequence: a residual CJK recall gap for punctuation-bearing queries. Non-blocking because it is not a regression — CJK keyword recall was entirely absent onmain, so this is an improvement with a remaining edge, and the keyword leg is guarded (return []) so nothing crashes. Suggestion: drop chunks that tokenize to nothing (punctuation-only non-CJK runs) before building the group. This matches GPT 5.6's single advisory finding. -
PR body, "What tests we did" — states
TestCjkKeywordRecall (17 tests); the class at head actually has 28 tests (TestCjkFts5Primitives's 7 is correct). Stale prose count only, no code impact; flagged for accuracy since I cannot edit the body.
What I could not verify
- I did not execute the test suite (shared checkout is diverged from main and read-only here); I relied on the green Backend Tests lanes at the head SHA and on reading each test against the code it pins. I therefore did not independently mutation-verify that each new test reddens on revert, though the tests target behavior that does not exist without this diff and the body reports 18/21 red on base for the first revision.
- The FTS5 zero-token-phrase behavior in finding 1 is reasoned from the tokenizer's treatment of fullwidth punctuation, not executed.
What is the problem?
Knowledge search loses the entire keyword leg for ordinary CJK queries. A
four-character Chinese query for "memory leak" returns zero FTS hits against
documents that plainly contain it.
The issue body names three whitespace-splitting sites and proposes a query-side
fix. That framing is only half the mechanism, and the proposed fix does not work.
Measured on
main:unicode61actually stores['<the entire sentence as ONE token>']['<word1>', '<word2>']unicode61classifies CJK ideographs as letters, so a whole spaceless runbecomes a single term. The query side then split on whitespace, which makes a
spaceless query a single token too. Both sides were wrong in the same
direction, so they agreed -- and a query only matched when it equalled an
entire indexed run byte for byte.
That agreement is why nobody saw it: the vector leg handles CJK, so RRF fusion
rescued the result set and the pure-keyword loss stayed invisible. On any
deployment with no embedding model configured, there is no vector leg to hide
behind and CJK keyword search is simply empty.
Why this issue matters to the user
CJK prose is written without spaces between words, so this is not an edge case
in those languages -- it is every query. A user searching their own knowledge
library in Chinese or Japanese gets a silently short result list: no error, no
log line, nothing to indicate the keyword half of a hybrid search contributed
nothing. Two characters is an ordinary word length in these scripts, and a
two-character query was equally dead.
How our fix solves it
Symptom: a CJK query returns no keyword hits. Root cause: the index stores one
term per spaceless run, so no sub-word term exists to match. Therefore the fix
has to be index-side -- and I verified that before choosing it, rather than
taking the issue thread's word for it.
Query-side expansion alone was measured and rejected. Expanding the query
into bigrams against the unchanged index recovers only the case where the
document has spaces between the CJK words -- exactly the toy shape in the
issue's reproduction step, and not how CJK is written. Against real spaceless
prose it still returns nothing, because the bigrams have no counterpart in the
index.
The
trigramtokenizer was measured and rejected on two counts. Atwo-character query returns nothing at all (trigram needs three characters), and
it switches on substring matching for every language -- with trigram,
okestarts matching
tokens. Both are regressions in exchange for the fix.What ships instead. The copy of
title/content/tagshanded to the indexis written with a boundary around each CJK character, so the tokenizer emits one
term per character. A query run expands to its overlapping adjacent-character
pairs, each as an FTS5 phrase, OR-ed together. A phrase over per-character
terms is exact substring matching, which buys recall without buying noise:
("internal", "to save", "relief valve", "water leak") does not match
That is deliberately the same adjacency floor
parse_search_queryalreadyapplies to session search (#3681), so the two surfaces now agree on what a CJK
hit means. The helpers live in
_sqlite_compat.py, the module that alreadydeclares itself the single FTS5 dialect and is the one module both knowledge and
memory import.
Three consequences worth calling out, each pinned by a test:
fts5_cjk_match_groupsreturns exactly what
fts5_quote_tokensreturns for any input with no CJK,and
fts5_segment_for_indexreturns non-CJK text unmodified.fts5_quote_tokensitself is untouched, so memory search is not perturbed.
items_ftsis an external-contenttable, so
snippet()/highlight()and every read of the item still returnthe original text.
_fts_index/_fts_unindex.This is the subtle one. FTS5's
'delete'command subtracts the terms it ishanded, so un-indexing with raw text against a segmented index leaves the
original terms in place -- the row keeps serving deleted or superseded
content as a live hit. I confirmed
'integrity-check'does not reportthis, so it would have been a silent stale-result bug with nothing
downstream to catch it. Centralising the two paths makes the segmentation
impossible to omit at a call site.
Migration. The term representation is versioned by
PRAGMA user_version(previously unused in this database) rather than a schema probe, because the
CREATE VIRTUAL TABLEstatement is byte-identical before and after -- nothingin the schema records which representation a database holds.
_migrate_fts_indexrebuilds from
itemsin batches and bumps the marker only after the wholerebuild commits, so an interrupted rebuild restarts on the next open instead of
resting half-built.
The rebuild is triggered by readers, not by the constructor (GPT review
round 1, fixed).
KnowledgeStore.__init__runs on the event-loop thread --setup_knowledge_routesreads the lazystate.knowledge_storeproperty duringdashboard startup -- while its FTS readers run on worker threads via
run_in_embed_pool/asyncio.to_thread. That split is the store's owndocumented threading contract and the reason it hands each thread a separate
connection. Migrating in
__init__therefore put a data-scaled reindex on thegateway boot path: measured at 4,000 CJK items it is ~137 ms, and it grows
linearly, so a large legacy library would visibly stall startup.
ensure_fts_index_currentmoves that cost to the first search instead. It islock-guarded, so concurrent readers wait rather than each starting a rebuild,
and steady state is a single boolean check (~1 us). Measured after the change:
construction of that same 4,000-item legacy database is 1.3 ms and leaves
user_versionat 0; the first search absorbs the 137 ms.Pattern harvest
Rule candidate: semgrep
Pattern: direct write to an FTS5 shadow table outside its owning wrapper
A transformation applied on a search index's write path has to be applied
identically on its read path and on its un-index path. This defect was
the read/write halves disagreeing; the sharpest hazard found while fixing it was
the un-index half disagreeing, which FTS5 reports nowhere --
'integrity-check'passes over a mismatched
'delete'. The generalizable guard is structuralrather than semantic: once a table's terms are derived rather than literal, every
writer must go through the one function that derives them. A rule that flags
INSERT INTO <name>_ftsappearing anywhere outside that table's owningwrapper methods would have caught the omission at any of the five call sites,
and would keep catching it as new writers are added.
Secondary, not proposed as a rule because it needs judgement rather than a
pattern: a migration added to a constructor is only safe if the constructor is
off the event loop, and in this codebase it is not. The existing
no-blocking-call-on-event-loopanchor already covers it -- it is what GPTmatched on -- so no new rule is warranted.
What tests we did
New:
TestCjkKeywordRecall(17 tests) andTestCjkFts5Primitives(7 tests) intest/test_knowledge.py. Vocabulary is shared withTestCjkSearchintest_history.pyso both search surfaces are read against the same examples.Red on base. With the tests present and
src/reverted toorigin/main,18 of 21 fail (measured on the first revision; the 3 that pass assert
unchanged behaviour -- ASCII parity, non-CJK segmentation, and the tags
limitation below -- which is what they are for).
Coverage: spaceless query recall; scattered-character exclusion; two-character
words; title matching; the retriever leg with no embedder (so only keyword can
answer); mixed Latin+CJK tokens; no stale hit after
update_item; no stale hitafter
delete_item; ASCII behaviour unchanged including theoke/tokensnon-match that rules out trigram; stored text not segmented; the graph leg
finding an entity named inside a run; legacy-index rebuild; a rebuild spanning
more than one batch; construction not rebuilding (the event-loop fix); one
rebuild across four concurrent reader threads; and the retriever leg migrating a
legacy index.
Suites run (targeted,
-n 2):test_knowledge.py,test_knowledge_search_upgrade.py,test_mcp_knowledge_search.py,test_knowledge_dedup.py,test_knowledge_handlers_coverage.py,test_knowledge_cross_thread.py,test_knowledge_agent_source.py,test_knowledge_delete_off_loop.py,test_memory_markdown_read.py(theexisting
fts5_quote_tokenscontract) -- 619 passed, 0 failed. The fullsuite was not run.
Gates:
flake8,isort --check-only src/kiro_crew test,mypyon the threechanged modules,
./scripts/docs-lint.sh,git diff --check-- all clean.Write-path cost measured, since segmentation now runs on every FTS write:
105 KB of pure ASCII costs 0.71 ms and returns byte-identical text (one C-level
regex pass, no rebuild); a 54k-character CJK document costs 9.2 ms and grows the
index copy 3x. The regex character class is derived from the same range tuple
is_cjk_charuses, so the two cannot drift apart.Any other suggestions on the work
Four things I found and deliberately did not fold in, each a separate root
cause with its own blast radius:
add_itempersiststags via
json.dumpsat its defaultensure_ascii=True, so a CJK tag isstored backslash-escaped and reaches the index as terms like
u6a21. Noquery-side change can reach it -- the CJK never arrives in the column. Fixing
it means changing a stored data format for existing rows. Pinned as an
explicit known-limitation test (
test_cjk_tags_are_stored_ascii_escaped) sothe boundary is visible and a future fix must update it deliberately.
memory_ftshas the identical latent gap (tokenize='porter unicode61',matching through
fts5_quote_tokens). The helpers were put in the sharedmodule specifically so that surface can adopt them, but it needs its own
index rebuild and its own decision about AND semantics. Documented in
memory-skills-hooks.mdrather than silently left as a surprise.history_search._is_cjk_char,vector_memory._DENSE_SCRIPT_RANGES(whichincludes Hangul),
chat_title._UNSPACED_SCRIPT_RANGES(which includes Thaiand excludes Hangul), and the new one here. I mirrored the session-search set
deliberately, since this is a search surface, and left the others alone --
consolidating them would change session-search and chat-title behaviour,
which does not belong in a knowledge-recall fix.
len(w) > 2CJK-dropping filter survives atvector_memory.py:2966(lesson-dedup keyword extraction). fix(memory): keep two-character CJK words in the episodic keyword fallback #6460 fixed the episodic-search instance
of this family; this one is in a different helper and out of scope here.
Closes #3691