Skip to content

Commit 81e552b

Browse files
committed
feat(kb): kb_search 混合检索 RRF 合流与三路引擎接线(REQ-259)
1 parent c57ce0c commit 81e552b

8 files changed

Lines changed: 187 additions & 21 deletions

File tree

‎app/src-tauri/src/commands_ai_chat_kb.rs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,17 @@ fn kb_run(
129129
flag: CancelFlag,
130130
settings: &AiSettings,
131131
) -> Result<(), String> {
132-
// ① 本地检索(零成本零上传——不受闸门约束)
133-
let hits = match state.db.kb_search(question, KB_QA_HITS_LIMIT) {
132+
// ① 本地检索(零成本零上传——不受闸门约束;REQ-259:引擎就绪自动语义合流;
133+
// 锁与 db 调用同作用域——引擎借用不逃逸锁生命周期)
134+
let hits = {
135+
let slot = state
136+
.embedding_slot
137+
.lock()
138+
.map_err(|e| format!("embedding 引擎锁中毒: {}", e))?;
139+
let engine = (slot.engine.dims().is_some()).then(|| slot.engine.as_ref());
140+
state.db.kb_search_hybrid(engine, question, KB_QA_HITS_LIMIT)
141+
};
142+
let hits = match hits {
134143
Ok(h) => h,
135144
Err(e) => {
136145
// 检索层故障:failed 占位 + 诚实错误(与断网同类降级路径)

‎app/src-tauri/src/commands_kb.rs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,18 @@ pub fn kb_search(
4545
if q.chars().count() > KB_QUERY_MAX_CHARS {
4646
return Err(format!("查询过长(≤{} 字符)", KB_QUERY_MAX_CHARS));
4747
}
48-
state
49-
.db
50-
.kb_search(q, limit.unwrap_or(KB_SEARCH_DEFAULT_LIMIT).min(KB_SEARCH_MAX_LIMIT))
51-
.map_err(|e| e.to_string())
48+
// REQ-259:引擎就绪时语义合流(锁与 db 调用同作用域——借用不逃逸)
49+
let result = {
50+
let slot = state
51+
.embedding_slot
52+
.lock()
53+
.map_err(|e| format!("embedding 引擎锁中毒: {}", e))?;
54+
let engine = (slot.engine.dims().is_some()).then(|| slot.engine.as_ref());
55+
state
56+
.db
57+
.kb_search_hybrid(engine, q, limit.unwrap_or(KB_SEARCH_DEFAULT_LIMIT).min(KB_SEARCH_MAX_LIMIT))
58+
};
59+
result.map_err(|e| e.to_string())
5260
}
5361

5462
/// 索引统计(设置页/角标——含脏源/失败计数,索引失败不静默)。

‎app/src-tauri/src/commands_kb_discovery.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ pub fn kb_discovery_suggest(
2828
return Err("相关素材建议未开启(设置 → 学习库 → 相关素材建议开关;默认关——本地检索本身不受影响)".to_string());
2929
}
3030
drop(flags);
31-
state
32-
.db
33-
.kb_discovery_suggest(concept_id)
31+
// REQ-259:证据候选走混合检索(锁与 db 调用同作用域——借用不逃逸)
32+
let result = {
33+
let slot = state
34+
.embedding_slot
35+
.lock()
36+
.map_err(|e| format!("embedding 引擎锁中毒: {}", e))?;
37+
let engine = (slot.engine.dims().is_some()).then(|| slot.engine.as_ref());
38+
state.db.kb_discovery_suggest_hybrid(engine, concept_id)
39+
};
40+
result
3441
.map_err(|e| e.to_string())?
3542
.ok_or_else(|| "概念不存在(可能已删除或不在当前体系)".to_string())
3643
}

‎app/src-tauri/src/kb_discovery.rs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,23 @@ pub struct DiscoveryResult {
5050
}
5151

5252
impl Db {
53-
/// 概念发现建议(只读编排;概念不存在 → None——命令层映射"概念不存在")。
53+
/// 概念发现建议(FTS-only 等价入口——仅测试保留;生产走 *_hybrid)。
54+
#[cfg(test)]
55+
pub fn kb_discovery_suggest(&self, concept_id: i64) -> Result<Option<DiscoveryResult>> {
56+
self.kb_discovery_suggest_hybrid(None, concept_id)
57+
}
58+
59+
/// 概念发现建议(REQ-259:engine=Some 时证据候选走混合检索 RRF 合流——
60+
/// 引擎不可用由检索层自决降级;相似概念提示仍按名称/词法重叠规则)。
5461
///
5562
/// @ai-context: 检索只读派生索引 kb_*;排除集 = 该概念在 knowledge_links
5663
/// 已引用的 note/fragment target(引用通道唯一入口且概念链
5764
/// 只落在自身体系内,无需 system 维度二次过滤)。
58-
pub fn kb_discovery_suggest(&self, concept_id: i64) -> Result<Option<DiscoveryResult>> {
65+
pub fn kb_discovery_suggest_hybrid(
66+
&self,
67+
engine: Option<&dyn crate::kb_embed::EmbeddingEngine>,
68+
concept_id: i64,
69+
) -> Result<Option<DiscoveryResult>> {
5970
// ① 概念取回(查询面 name/essence + 排除集归属体系)
6071
let concept = self.with_conn(|conn| {
6172
Ok(conn
@@ -93,7 +104,7 @@ impl Db {
93104
if query.chars().count() > DISCOVERY_QUERY_MAX_CHARS {
94105
query = query.chars().take(DISCOVERY_QUERY_MAX_CHARS).collect();
95106
}
96-
let hits = self.kb_search(&query, DISCOVERY_LIMIT)?;
107+
let hits = self.kb_search_hybrid(engine, &query, DISCOVERY_LIMIT)?;
97108
let evidence: Vec<KbHit> = hits
98109
.into_iter()
99110
.filter(|h| {

‎app/src-tauri/src/kb_embed.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
//! @ai-context: Onnx/Ollama 具体引擎后续模块实现(OnnxEmbedding 需 ort + 模型
99
//! 文件 + BERT 分词——模型分发复用 model_registry);本模块红线:
1010
//! 引擎产物只是派生索引材料,绝不写结构层(人工裁决闸门铁律)。
11-
//! 注意:向量编解码与 cosine top-K 的检索合流接线(kb_search 混合 RRF /
12-
//! reindex 回填)落地前尚未被生产路径引用——dead_code 临时豁免,混合检索
13-
//! 接线轮必须移除本属性(TODO REQ-259);引擎槽/契约/Noop 已被命令层引用。
11+
//! 注意:向量写入侧(encode_embedding + META_* 键)待 reindex 回填轮接线——
12+
//! 读侧(decode/cosine/META_DIM)已被 kb_search_semantic 引用;dead_code 临时
13+
//! 豁免缩小至写入侧,reindex 回填轮移除本属性(TODO REQ-259)。
1414
#![allow(dead_code)]
1515

1616
use std::error::Error;

‎app/src-tauri/src/kb_search.rs‎

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use rusqlite::Connection;
1515

1616
use crate::db::Db;
1717
use crate::error::Result;
18+
use crate::kb_embed::EmbeddingEngine;
1819
use crate::kb_fts::{build_snippet, like_pattern, plan_query};
20+
use crate::kb_search_semantic::semantic_merge;
1921

2022
/// 默认/上限命中数(命令层 clamp——防超大 payload)。
2123
pub const KB_SEARCH_DEFAULT_LIMIT: usize = 10;
@@ -46,6 +48,7 @@ pub struct KbHit {
4648
}
4749

4850
/// 查询行(snippet 后处理前置结构)。
51+
#[derive(Clone)]
4952
struct 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 行)。
154220
const HIT_COLUMNS: &str = "c.id, c.source_kind, c.note_id, c.fragment_id,
155221
n.title, g.name, c.heading, c.text";
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//! kb 检索的语义合流模块(REQ-259,v0.19.5;kb_search.rs 行数拆分)。
2+
//!
3+
//! @ai-context: 引擎不可达(Noop/推理失败/dim 与库内回填不符)时一律返回 None
4+
//! ——调用方 FTS 直通,检索可用性红线不因语义层被击穿;模型更换
5+
//! 未重建时降级并打日志提示(不产出维度错乱结果)。
6+
//! @ai-context: 全库向量线扫在内存解码后逐条余弦(数千 chunk × 512 维毫秒级)
7+
//! ——量级未到向量索引门槛(设计 §六 YAGNI 记录)。
8+
9+
use rusqlite::{params, Connection};
10+
11+
use crate::kb_embed::{cosine_top_k, decode_embedding, EmbeddingEngine, META_DIM};
12+
use crate::kb_fts::rrf_merge;
13+
14+
/// 语义合流:查询嵌入 → 全库已嵌向量余弦 top-K → 与词法候选 RRF 融合。
15+
///
16+
/// 返回 (merged_ids, used_semantic);语义不可用/不一致 → Ok(None)。
17+
pub(crate) fn semantic_merge(
18+
conn: &Connection,
19+
engine: &dyn EmbeddingEngine,
20+
query: &str,
21+
fts_ids: &[i64],
22+
limit: usize,
23+
) -> rusqlite::Result<Option<(Vec<i64>, bool)>> {
24+
let Some(dim) = engine.dims() else {
25+
return Ok(None);
26+
};
27+
// 引擎 dim 必须与库内回填 dim 一致(模型更换未重建 → 降级并提示重建)
28+
// 行缺失=从未回填(None);行存在但值 NULL 也读为 None——两种均按未回填
29+
let stored_dim: Option<i64> = {
30+
let mut stmt = conn.prepare("SELECT value FROM kb_meta WHERE key = ?1")?;
31+
let mut rows = stmt.query_map(params![META_DIM], |r| r.get::<_, Option<i64>>(0))?;
32+
match rows.next() {
33+
Some(Ok(v)) => v,
34+
Some(Err(e)) => return Err(e),
35+
None => None,
36+
}
37+
};
38+
if stored_dim.is_some_and(|d| d as usize != dim) {
39+
eprintln!("[kb-search] embedding dim 不匹配(库={stored_dim:?} 引擎={dim})——降级 FTS-only,请重建索引");
40+
return Ok(None);
41+
}
42+
let Ok(qvec) = engine.embed(&[query.to_string()]) else {
43+
return Ok(None); // 引擎推理失败 → 降级(检索可用性红线)
44+
};
45+
let Some(qvec) = qvec.into_iter().next() else {
46+
return Ok(None);
47+
};
48+
// 全库已嵌向量(只取非 NULL——重嵌中/未嵌行自然缺席)
49+
let decoded: Vec<(i64, Vec<u8>)> = {
50+
let mut stmt = conn.prepare("SELECT id, embedding FROM kb_chunks WHERE embedding IS NOT NULL")?;
51+
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))?;
52+
rows.collect::<rusqlite::Result<Vec<_>>>()?
53+
};
54+
let vec_rows = decoded
55+
.into_iter()
56+
.filter_map(|(id, blob)| decode_embedding(&blob, dim).map(|v| (id, v)));
57+
let vec_ids: Vec<i64> = cosine_top_k(&qvec, vec_rows, limit * 8)
58+
.into_iter()
59+
.map(|(id, _)| id)
60+
.collect();
61+
let merged = rrf_merge(&[fts_ids.to_vec(), vec_ids], limit);
62+
Ok(Some((merged, true)))
63+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ mod kb_fts;
256256
mod kb_index;
257257
mod kb_reindex;
258258
mod kb_search;
259+
// REQ-259(v0.19.5):kb 检索语义合流(RRF 融合独立模块——行数拆分)
260+
mod kb_search_semantic;
259261
mod commands_kb;
260262
// v0.19.1(REQ-260):学习库问答编排(检索分支分流——纯聊链路零改动)
261263
mod kb_prompt;

0 commit comments

Comments
 (0)