Skip to content

Commit 6a10ba0

Browse files
committed
feat(kb): reindex 后置向量回填与统计就绪态(REQ-259)
1 parent 81e552b commit 6a10ba0

5 files changed

Lines changed: 190 additions & 17 deletions

File tree

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ pub fn kb_embedding_load(state: State<'_, AppState>) -> Result<EmbeddingStatusVi
117117
.map_err(|_| "embedding 引擎锁中毒".to_string())?;
118118
let dim = engine.dims();
119119
*slot = crate::kb_embed::EmbeddingSlot {
120-
engine: Box::new(engine),
120+
engine: std::sync::Arc::new(engine),
121121
kind: "onnx",
122122
};
123123
Ok(EmbeddingStatusView {
@@ -136,12 +136,29 @@ pub async fn kb_reindex_all(
136136
channel: Channel<KbReindexEvent>,
137137
) -> Result<(), String> {
138138
let db = state.db.clone();
139+
// REQ-259:引擎快照(Arc 克隆——spawn_blocking 跨线程共享;锁即放)
140+
let engine: Option<std::sync::Arc<dyn crate::kb_embed::EmbeddingEngine>> = {
141+
let slot = state
142+
.embedding_slot
143+
.lock()
144+
.map_err(|e| format!("embedding 引擎锁中毒: {}", e))?;
145+
(slot.engine.dims().is_some()).then(|| slot.engine.clone())
146+
};
139147
tauri::async_runtime::spawn_blocking(move || {
140148
let mut progress = |done: u64, total: u64| {
141149
let _ = channel.send(KbReindexEvent::Progress { done, total });
142150
};
143151
match db.kb_reindex_all(&mut progress) {
144152
Ok(report) => {
153+
// REQ-259:重建后置向量回填(引擎就绪时——失败如实记录,Done 照发)
154+
if let Some(eng) = &engine {
155+
match db.kb_fill_embeddings(eng.as_ref()) {
156+
Ok(n) => eprintln!("[kb-index] embedding 回填完成:{n} 块"),
157+
Err(e) => eprintln!(
158+
"[kb-index] embedding 回填失败(检索保持 FTS-only,可重试): {e}"
159+
),
160+
}
161+
}
145162
let _ = channel.send(KbReindexEvent::Done { report });
146163
}
147164
Err(e) => {

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@
88
//! @ai-context: Onnx/Ollama 具体引擎后续模块实现(OnnxEmbedding 需 ort + 模型
99
//! 文件 + BERT 分词——模型分发复用 model_registry);本模块红线:
1010
//! 引擎产物只是派生索引材料,绝不写结构层(人工裁决闸门铁律)。
11-
//! 注意:向量写入侧(encode_embedding + META_* 键)待 reindex 回填轮接线——
12-
//! 读侧(decode/cosine/META_DIM)已被 kb_search_semantic 引用;dead_code 临时
13-
//! 豁免缩小至写入侧,reindex 回填轮移除本属性(TODO REQ-259)。
14-
#![allow(dead_code)]
15-
16-
use std::error::Error;
11+
//! 注意:本模块公共 API 已全部接线(槽/契约/编解码/余弦/元数据键——
12+
//! 读侧 kb_search_semantic,写侧 kb_embed_store)。
1713
1814
/// kb_meta 键:当前 embedding 模型名(无引擎时无此键)
1915
pub const META_MODEL: &str = "embedding_model";
@@ -47,16 +43,17 @@ impl EmbeddingEngine for NoopEmbedding {
4743
}
4844

4945
/// 引擎槽(AppState 持有;状态命令读、加载命令换入 Onnx——锁内
50-
/// read-modify-write,与词表/开关同模式防 TOCTOU)。
46+
/// read-modify-write,与词表/开关同模式防 TOCTOU;Arc 使引擎可廉价快照给
47+
/// spawn_blocking 重建任务跨线程使用)。
5148
pub struct EmbeddingSlot {
52-
pub engine: Box<dyn EmbeddingEngine>,
49+
pub engine: std::sync::Arc<dyn EmbeddingEngine>,
5350
/// 引擎标识(noop | onnx——状态命令如实上报)
5451
pub kind: &'static str,
5552
}
5653

5754
impl Default for EmbeddingSlot {
5855
fn default() -> Self {
59-
Self { engine: Box::new(NoopEmbedding), kind: "noop" }
56+
Self { engine: std::sync::Arc::new(NoopEmbedding), kind: "noop" }
6057
}
6158
}
6259

@@ -121,11 +118,6 @@ pub fn cosine_top_k(
121118
scored
122119
}
123120

124-
/// 统一错误到 String(trait 便捷——引擎内部用)
125-
pub fn to_string_err<E: Error>(e: E) -> String {
126-
e.to_string()
127-
}
128-
129121
#[cfg(test)]
130122
mod tests {
131123
use super::*;
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! kb 向量回填存储(REQ-259,v0.19.5)。
2+
//!
3+
//! @ai-context: 派生索引铁律:kb_* 全部可由 reindex_all 重建——向量列亦然。
4+
//! 全量重建(kb_reindex_all 命令)成功后由本模块做后置回填:
5+
//! 拉取全部 chunk 文本 → 引擎批嵌入 → 单事务写回 embedding 列 +
6+
//! kb_meta 元数据(model/dim/format,检索合流的 dim 校验数据源)。
7+
//! @ai-context: 软重建钩子(保存/删除)不增量补向量——语义召回一致性依赖
8+
//! 全量重建(REQ-262 UI 按钮);未回填行 embedding=NULL,语义
9+
//! 合流自然缺席、FTS 精度不受影响(诚实降级)。
10+
11+
use crate::db::Db;
12+
use crate::error::Result;
13+
use crate::kb_embed::{
14+
EmbeddingEngine, FORMAT_F32LE, META_DIM, META_FORMAT, META_MODEL, encode_embedding,
15+
};
16+
use crate::kb_index::meta_set;
17+
18+
impl Db {
19+
/// 全量向量回填:全部已切块文本 → 引擎嵌入 → 写列 + 元数据(幂等——
20+
/// reindex 后 embedding 全 NULL,重跑即覆盖;引擎失败 → Err 保持可诊断)。
21+
pub fn kb_fill_embeddings(&self, engine: &dyn EmbeddingEngine) -> Result<usize> {
22+
let dim = engine
23+
.dims()
24+
.ok_or_else(|| crate::error::AppError::Db("引擎不可用(无 dim)".to_string()))?;
25+
let chunks: Vec<(i64, String)> = self.with_conn(|conn| {
26+
let mut stmt = conn.prepare("SELECT id, text FROM kb_chunks ORDER BY id")?;
27+
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
28+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
29+
})?;
30+
if chunks.is_empty() {
31+
// 无块也写元数据(重建后空库——状态如实:引擎就绪但无向量)
32+
self.with_conn(|conn| {
33+
meta_set(conn, META_MODEL, "onnx:bge-small-zh-v1.5")?;
34+
meta_set(conn, META_DIM, &dim.to_string())?;
35+
meta_set(conn, META_FORMAT, FORMAT_F32LE)?;
36+
Ok(())
37+
})?;
38+
return Ok(0);
39+
}
40+
let texts: Vec<String> = chunks.iter().map(|(_, t)| t.clone()).collect();
41+
let vectors = engine
42+
.embed(&texts)
43+
.map_err(|e| crate::error::AppError::Db(format!("嵌入失败: {e}")))?;
44+
let paired: Vec<(i64, Vec<f32>)> = chunks
45+
.into_iter()
46+
.zip(vectors)
47+
.map(|((id, _), v)| (id, v))
48+
.collect();
49+
self.with_conn(|conn| {
50+
// 回填幂等可重跑(失败可整轮重试)——逐条自动提交,无需事务;
51+
// 单条失败中断并报错(kb_meta 最后写——元数据即"完成标记",
52+
// 未写完=未完成,检索合流按缺 dim 自动 FTS-only)
53+
{
54+
let mut stmt =
55+
conn.prepare("UPDATE kb_chunks SET embedding = ?1 WHERE id = ?2")?;
56+
for (id, vec) in &paired {
57+
stmt.execute(rusqlite::params![encode_embedding(vec), id])?;
58+
}
59+
}
60+
meta_set(conn, META_MODEL, "onnx:bge-small-zh-v1.5")?;
61+
meta_set(conn, META_DIM, &dim.to_string())?;
62+
meta_set(conn, META_FORMAT, FORMAT_F32LE)?;
63+
Ok(())
64+
})?;
65+
Ok(paired.len())
66+
}
67+
}
68+
69+
#[cfg(test)]
70+
mod tests {
71+
use crate::db::Db;
72+
use crate::kb_embed::EmbeddingEngine;
73+
74+
/// 假引擎:dim=2,每文本返回确定性向量([字符数×0.1, 1.0])
75+
struct FakeEngine;
76+
impl EmbeddingEngine for FakeEngine {
77+
fn dims(&self) -> Option<usize> {
78+
Some(2)
79+
}
80+
fn embed(&self, texts: &[String]) -> std::result::Result<Vec<Vec<f32>>, String> {
81+
Ok(texts
82+
.iter()
83+
.map(|t| vec![t.chars().count() as f32 * 0.1, 1.0])
84+
.collect())
85+
}
86+
}
87+
88+
fn seed_chunk(db: &Db, id: i64, text: &str) {
89+
db.with_conn(|c| {
90+
// 先建 notes 事实行(kb_chunks.note_id 有 FK——外键约束先满足)
91+
c.execute(
92+
"INSERT INTO notes (id, title, content, source, created_at, updated_at)
93+
VALUES (?1, ?2, ?3, 'manual', 1, 1)",
94+
rusqlite::params![id, format!("测试笔记 {id}"), text],
95+
)?;
96+
c.execute(
97+
"INSERT INTO kb_chunks (id, source_kind, note_id, ord, char_start, char_end, text)
98+
VALUES (?1, 'note', ?1, 1, 0, ?2, ?3)",
99+
rusqlite::params![id, text.chars().count() as i64, text],
100+
)?;
101+
Ok(())
102+
})
103+
.unwrap();
104+
}
105+
106+
#[test]
107+
fn fill_writes_blobs_and_meta_idempotently() {
108+
let db = Db::open(":memory:").unwrap();
109+
seed_chunk(&db, 1, "学习");
110+
seed_chunk(&db, 2, "配色与晕染");
111+
let n = db.kb_fill_embeddings(&FakeEngine).unwrap();
112+
assert_eq!(n, 2);
113+
// 列内 BLOB = dim×4;元数据三键齐备
114+
db.with_conn(|c| {
115+
let bytes: i64 = c.query_row(
116+
"SELECT SUM(length(embedding)) FROM kb_chunks",
117+
[],
118+
|r| r.get(0),
119+
).unwrap();
120+
assert_eq!(bytes, 2 * 2 * 4);
121+
let dim: String = c.query_row(
122+
"SELECT value FROM kb_meta WHERE key='embedding_dim'",
123+
[],
124+
|r| r.get(0),
125+
).unwrap();
126+
assert_eq!(dim, "2");
127+
Ok(())
128+
})
129+
.unwrap();
130+
// 幂等重跑:全量覆盖不报错
131+
let again = db.kb_fill_embeddings(&FakeEngine).unwrap();
132+
assert_eq!(again, 2);
133+
}
134+
135+
#[test]
136+
fn fill_empty_library_still_records_meta() {
137+
let db = Db::open(":memory:").unwrap();
138+
assert_eq!(db.kb_fill_embeddings(&FakeEngine).unwrap(), 0);
139+
db.with_conn(|c| {
140+
let format: String = c.query_row(
141+
"SELECT value FROM kb_meta WHERE key='embedding_format'",
142+
[],
143+
|r| r.get(0),
144+
).unwrap();
145+
assert_eq!(format, "f32le");
146+
Ok(())
147+
})
148+
.unwrap();
149+
}
150+
151+
#[test]
152+
fn fill_requires_engine_with_dim() {
153+
let db = Db::open(":memory:").unwrap();
154+
seed_chunk(&db, 1, "x");
155+
assert!(db.kb_fill_embeddings(&crate::kb_embed::NoopEmbedding).is_err());
156+
}
157+
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,15 @@ impl Db {
195195
.unwrap_or(0);
196196
let last_error =
197197
meta_get(conn, META_LAST_ERROR).ok().flatten().filter(|v| !v.is_empty());
198+
// REQ-259:embedding 就绪 = kb_meta 已写 dim(回填完成后的诚实状态)
199+
let embedding_ready = meta_get(conn, crate::kb_embed::META_DIM)
200+
.ok()
201+
.flatten()
202+
.is_some();
198203
Ok(KbIndexStats {
199204
fts_ready: true,
200-
embedding_ready: false,
201-
engine: "fts5".to_string(),
205+
embedding_ready,
206+
engine: if embedding_ready { "fts5+onnx" } else { "fts5" }.to_string(),
202207
chunks_total: note_chunks + fragment_chunks,
203208
note_chunks,
204209
fragment_chunks,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ mod kb_embed;
270270
mod kb_embed_tokenizer;
271271
// REQ-259(v0.19.5):bge-small-zh ONNX 推理引擎(ort 封装 + CLS/L2)
272272
mod kb_embed_onnx;
273+
// REQ-259(v0.19.5):kb 向量回填存储(全量重建后置嵌入 + kb_meta 元数据)
274+
mod kb_embed_store;
273275
mod commands_kb_discovery;
274276
mod concept_weakness;
275277
mod goal_interview;

0 commit comments

Comments
 (0)