@@ -40,13 +40,22 @@ pub struct KbQueryPlan {
4040
4141/// 查询 → 候选规划(纯函数;空/全停用词 → 全空三件套——调用方按"无命中"
4242/// 处理,诚实明说不猜)。
43+ ///
44+ /// @ai-context: 分类前剥离成对引号("…" 与 “…”/‘…’)——trigram 只索引字母
45+ /// 数字连续段,带引号的 "配色" 若按原文 4 字符判长会进 FTS 而
46+ /// 内部 token 仅 2 字符致静默零命中(审查 M1:先剥引号再按内容
47+ /// 判档——剥后 <3 字转 LIKE 子串,与不带引号行为一致)。
4348pub fn plan_query ( query : & str ) -> KbQueryPlan {
4449 let mut fts_terms: Vec < String > = Vec :: new ( ) ;
4550 let mut like_terms: Vec < String > = Vec :: new ( ) ;
4651 let mut highlight: Vec < String > = Vec :: new ( ) ;
4752 for token in query. split_whitespace ( ) {
48- if contains_cjk ( token) {
49- for seg in split_cjk_stops ( token) {
53+ let stripped = strip_outer_quotes ( token) ;
54+ if stripped. is_empty ( ) {
55+ continue ;
56+ }
57+ if contains_cjk ( stripped) {
58+ for seg in split_cjk_stops ( stripped) {
5059 if seg. chars ( ) . count ( ) == 2 {
5160 push_unique ( & mut like_terms, & seg) ;
5261 push_unique ( & mut highlight, & seg) ;
@@ -58,12 +67,12 @@ pub fn plan_query(query: &str) -> KbQueryPlan {
5867 }
5968 // 1 字残段丢弃(trigram 与 LIKE 均无意义——诚实舍弃)
6069 }
61- } else if token . chars ( ) . count ( ) >= 3 {
62- push_unique ( & mut fts_terms, token ) ;
63- push_unique ( & mut highlight, token ) ;
64- } else if token . chars ( ) . count ( ) == 2 {
65- push_unique ( & mut like_terms, token ) ;
66- push_unique ( & mut highlight, token ) ;
70+ } else if stripped . chars ( ) . count ( ) >= 3 {
71+ push_unique ( & mut fts_terms, stripped ) ;
72+ push_unique ( & mut highlight, stripped ) ;
73+ } else if stripped . chars ( ) . count ( ) == 2 {
74+ push_unique ( & mut like_terms, stripped ) ;
75+ push_unique ( & mut highlight, stripped ) ;
6776 }
6877 }
6978 let fts = ( !fts_terms. is_empty ( ) ) . then ( || {
@@ -76,6 +85,27 @@ pub fn plan_query(query: &str) -> KbQueryPlan {
7685 KbQueryPlan { fts, like_terms, highlight_terms : highlight }
7786}
7887
88+ /// 剥离最外层成对引号(ASCII 直引号 + 中文全角引号;不成对/内嵌 → 原样)。
89+ fn strip_outer_quotes ( token : & str ) -> & str {
90+ let b = token. as_bytes ( ) ;
91+ if b. len ( ) < 2 {
92+ return token;
93+ }
94+ let first = token. chars ( ) . next ( ) . expect ( "len≥1" ) ;
95+ let last = token. chars ( ) . last ( ) . expect ( "len≥1" ) ;
96+ let paired = matches ! (
97+ ( first, last) ,
98+ ( '"' , '"' ) | ( '\'' , '\'' ) | ( '\u{201C}' , '\u{201D}' ) | ( '\u{2018}' , '\u{2019}' )
99+ ) ;
100+ if paired {
101+ let inner = & token[ first. len_utf8 ( ) ..token. len ( ) - last. len_utf8 ( ) ] ;
102+ // 剥一层即可(内嵌引号属词内符号——由 fts 短语/LIKE 字面按原样处理)
103+ inner
104+ } else {
105+ token
106+ }
107+ }
108+
79109/// 段落含 CJK 判定(CJK 统一表意区含扩展——覆盖日常用语)。
80110fn contains_cjk ( s : & str ) -> bool {
81111 s. chars ( ) . any ( |c| matches ! ( c as u32 , 0x4E00 ..=0x9FFF | 0x3400 ..=0x4DBF ) )
@@ -105,16 +135,44 @@ fn split_cjk_stops(run: &str) -> Vec<String> {
105135/// 段 → 整段 + 3~6 字滑动窗口短语(>6 字时展开:防"学色彩搭配"式前缀粘连
106136/// 致整段短语落空——整段 verbatim 命中 + 滑窗 OR,bm25 自然把真关键词
107137/// 所在块排前)。
138+ ///
139+ /// @ai-context: 膨胀上界(审查 M3:聊天消息 ≤16000 字符时按 n 全展开会生成
140+ /// ~4n 短语 + 去重 O(n²),秒级卡顿):>32 字段不再发整段(verbatim
141+ /// 命中概率趋零);滑窗只在**首 24 / 尾 24** 两区取 k=3..6,且总量
142+ /// 硬顶 96——任何输入下表达式有界(毫秒级),超长问句语义集中在
143+ /// 头尾关键词,尾区保尾词召回)。
144+ const WINDOW_WHOLE_MAX_CHARS : usize = 32 ;
145+ const WINDOW_EDGE_CHARS : usize = 24 ;
146+ const WINDOW_TERMS_CAP : usize = 96 ;
147+
108148fn cjk_windows ( seg : & str ) -> Vec < String > {
109149 let chars: Vec < char > = seg. chars ( ) . collect ( ) ;
110150 let n = chars. len ( ) ;
111151 if n <= 6 {
112152 return vec ! [ chars. iter( ) . collect( ) ] ;
113153 }
114- let mut wins = vec ! [ chars. iter( ) . collect( ) ] ;
115- for k in 3 ..=6 {
116- for start in 0 ..=n - k {
117- wins. push ( chars[ start..start + k] . iter ( ) . collect ( ) ) ;
154+ let mut wins: Vec < String > = Vec :: new ( ) ;
155+ if n <= WINDOW_WHOLE_MAX_CHARS {
156+ wins. push ( chars. iter ( ) . collect ( ) ) ;
157+ }
158+ // 首/尾两区的滑动窗(n ≤ 48 时头尾重叠——第二区置空即可全覆盖)
159+ let edge2 = WINDOW_EDGE_CHARS * 2 ;
160+ let regions: [ ( usize , usize ) ; 2 ] = if n > edge2 {
161+ [ ( 0 , WINDOW_EDGE_CHARS ) , ( n - WINDOW_EDGE_CHARS , WINDOW_EDGE_CHARS ) ]
162+ } else {
163+ [ ( 0 , n) , ( 0 , 0 ) ]
164+ } ;
165+ for ( base, len) in regions {
166+ if len == 0 {
167+ continue ; // 第二区占位空(len=0 无窗)
168+ }
169+ for k in 3 ..=6 {
170+ for start in 0 ..=len. saturating_sub ( k) {
171+ if wins. len ( ) >= WINDOW_TERMS_CAP {
172+ return wins;
173+ }
174+ wins. push ( chars[ base + start..base + start + k] . iter ( ) . collect ( ) ) ;
175+ }
118176 }
119177 }
120178 wins
0 commit comments