@@ -15,7 +15,9 @@ use rusqlite::Connection;
1515
1616use crate :: db:: Db ;
1717use crate :: error:: Result ;
18+ use crate :: kb_embed:: EmbeddingEngine ;
1819use crate :: kb_fts:: { build_snippet, like_pattern, plan_query} ;
20+ use crate :: kb_search_semantic:: semantic_merge;
1921
2022/// 默认/上限命中数(命令层 clamp——防超大 payload)。
2123pub const KB_SEARCH_DEFAULT_LIMIT : usize = 10 ;
@@ -46,6 +48,7 @@ pub struct KbHit {
4648}
4749
4850/// 查询行(snippet 后处理前置结构)。
51+ #[ derive( Clone ) ]
4952struct HitRow {
5053 chunk_id : i64 ,
5154 source_kind : String ,
@@ -83,13 +86,24 @@ impl Db {
8386 } )
8487 }
8588
86- /// 全库混合检索(FTS-only 现状;embedding 就绪后本签名不变——融合在
87- /// 本层内部演化,调用方零感知)。
88- ///
89- /// @ai-context: 语义:空查询/全停用词 → 空列表("库内未找到"诚实口径由
90- /// 命令层措辞);FTS 语法意外(tokenizer 拒绝奇形 token)
91- /// → 降级整句 LIKE,检索不因单个查询语法被击穿。
89+ /// 全库混合检索 FTS-only 等价入口(仅测试保留——生产走 *_hybrid)。
90+ #[ cfg( test) ]
9291 pub fn kb_search ( & self , query : & str , limit : usize ) -> Result < Vec < KbHit > > {
92+ self . kb_search_hybrid ( None , query, limit)
93+ }
94+
95+ /// 混合检索入口(REQ-259):调用方持引擎时传 Some(命令层经状态槽取)。
96+ ///
97+ /// @ai-context: 融合口径(设计 §5.4):FTS 候选(保词法精度)∪ 向量余弦
98+ /// top-K(保语义召回)→ rrf_merge(k=60)→ limit 截断;向量
99+ /// 仅当 kb_meta.embedding_dim 与引擎 dim 一致时参与(模型更换
100+ /// 未重建 → 降级 + 日志提示重建,不产出维度错乱结果)。
101+ pub fn kb_search_hybrid (
102+ & self ,
103+ engine : Option < & dyn EmbeddingEngine > ,
104+ query : & str ,
105+ limit : usize ,
106+ ) -> Result < Vec < KbHit > > {
93107 let limit = limit. clamp ( 1 , KB_SEARCH_MAX_LIMIT ) ;
94108 let plan = plan_query ( query) ;
95109 if plan. fts . is_none ( ) && plan. like_terms . is_empty ( ) {
@@ -128,6 +142,38 @@ impl Db {
128142 } ,
129143 None => like_hits ( conn, & plan. like_terms , limit) ?,
130144 } ;
145+ // 语义合流(可选):向量候选 + RRF 融合(任何失败/不一致 → 降级直通)
146+ let hybrid = if let Some ( eng) = engine {
147+ let fts_ids: Vec < i64 > = rows. iter ( ) . map ( |r| r. chunk_id ) . collect ( ) ;
148+ semantic_merge ( conn, eng, query, & fts_ids, limit) ?
149+ } else {
150+ None
151+ } ;
152+ if let Some ( ( merged, used_semantic) ) = hybrid {
153+ let merged_ids: Vec < i64 > = merged;
154+ // 补齐向量独有命中(FTS 未召回但语义召回的 chunk 行)
155+ let have: std:: collections:: HashSet < i64 > =
156+ rows. iter ( ) . map ( |r| r. chunk_id ) . collect ( ) ;
157+ let missing: Vec < i64 > = merged_ids
158+ . iter ( )
159+ . copied ( )
160+ . filter ( |id| !have. contains ( id) )
161+ . collect ( ) ;
162+ if !missing. is_empty ( ) {
163+ rows. extend ( rows_by_ids ( conn, & missing) ?) ;
164+ }
165+ let by_id: std:: collections:: HashMap < i64 , HitRow > =
166+ rows. into_iter ( ) . map ( |r| ( r. chunk_id , r) ) . collect ( ) ;
167+ rows = merged_ids
168+ . iter ( )
169+ . filter_map ( |id| by_id. get ( id) . cloned ( ) )
170+ . collect ( ) ;
171+ if used_semantic {
172+ for r in rows. iter_mut ( ) {
173+ r. score_kind = "rrf" . to_string ( ) ;
174+ }
175+ }
176+ }
131177 // limit 契约统一收口(fts 候选按 8× 放大取回——过滤后必须裁回;
132178 // 审查 H1:此前仅在 like 过滤分支内截断,fts-only 常态超发 8 倍)
133179 rows. truncate ( limit) ;
@@ -150,6 +196,26 @@ impl Db {
150196 }
151197}
152198
199+ /// 按 id 列表批量取命中行(语义独有候选补齐——HIT_COLUMNS 同口径)。
200+ fn rows_by_ids ( conn : & Connection , ids : & [ i64 ] ) -> Result < Vec < HitRow > > {
201+ if ids. is_empty ( ) {
202+ return Ok ( Vec :: new ( ) ) ;
203+ }
204+ let placeholders = ( 0 ..ids. len ( ) ) . map ( |_| "?" ) . collect :: < Vec < _ > > ( ) . join ( "," ) ;
205+ let sql = format ! (
206+ "SELECT {} FROM kb_chunks c {} WHERE c.id IN ({})" ,
207+ HIT_COLUMNS , HIT_JOINS , placeholders
208+ ) ;
209+ let ids_vec: Vec < & dyn rusqlite:: ToSql > = ids. iter ( ) . map ( |v| v as & dyn rusqlite:: ToSql ) . collect ( ) ;
210+ let mut stmt = conn. prepare ( & sql) ?;
211+ let hit_rows = stmt
212+ . query_map ( rusqlite:: params_from_iter ( ids_vec. iter ( ) . copied ( ) ) , map_hit_row) ?
213+ . collect :: < rusqlite:: Result < Vec < _ > > > ( ) ?;
214+ let mut rows = hit_rows;
215+ rows. iter_mut ( ) . for_each ( |r| r. score_kind = "rrf" . to_string ( ) ) ;
216+ Ok ( rows)
217+ }
218+
153219/// 命中列(三通道共用;c.id 恒有——fts 通道经影子表 join 原 chunk 行)。
154220const HIT_COLUMNS : & str = "c.id, c.source_kind, c.note_id, c.fragment_id,
155221 n.title, g.name, c.heading, c.text" ;
0 commit comments