Skip to content

Commit 244ea92

Browse files
committed
feat(kb): embedding 纯层——引擎契约/向量编解码/余弦 top-K(REQ-259 地基)
1 parent 6c428f0 commit 244ea92

4 files changed

Lines changed: 172 additions & 0 deletions

File tree

‎app/src-tauri/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/src-tauri/Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ serde_json = "1"
6767
thiserror = "2"
6868
rusqlite = { version = "0.32", features = ["bundled"] }
6969
sherpa-onnx = { version = "1.13", default-features = false, features = ["shared"] }
70+
# v0.19.5(REQ-259):本地 embedding(bge-small-zh-v1.5 int8)——ort 2.0.0-rc.13,
71+
# 与 oar-ocr 的 ort-sys 共享同一份 onnxruntime.dll(ORT_LIB_LOCATION 已在
72+
# .cargo/config.toml 注入);仅取 ndarray 输入通道,禁 download-binaries
73+
# (crate 本地缓存 + config 零网络下载;模型文件另走 model_registry 分发)
74+
ort = { version = "2.0.0-rc.13", default-features = false, features = ["ndarray"] }
7075
# v0.12.0 构建修复:sherpa-onnx 由 static(MT 静态库,内嵌 onnxruntime 符号)切 shared
7176
# (DLL 动态链接)——否则与 oar-ocr 的 ort-sys(MD 动态链接 onnxruntime.dll)链接时
7277
# 发生 LNK2038 RuntimeLibrary 冲突 + LNK2005 onnxruntime 符号重复;shared 包

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

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! kb 语义索引的 embedding 契约与纯函数(REQ-259,v0.19.5)。
2+
//!
3+
//! @ai-context: 设计(docs/archive/2026-09-03 v0.19 设计 §六/§三 DDL):kb_chunks
4+
//! 的 `embedding BLOB` 存 f32 小端向量(长度=kb_meta 的 dim 键);
5+
//! `NULL = 未嵌`(无引擎自动降级 FTS-only——不阻塞任何读路径)。
6+
//! 本模块=引擎无关的纯层:trait 契约 + Noop 兜底 + 向量编解码 +
7+
//! 余弦 top-K(读路径的本地语义检索无需任何外部依赖即可单测)。
8+
//! @ai-context: Onnx/Ollama 具体引擎后续模块实现(OnnxEmbedding 需 ort + 模型
9+
//! 文件 + BERT 分词——模型分发复用 model_registry);本模块红线:
10+
//! 引擎产物只是派生索引材料,绝不写结构层(人工裁决闸门铁律)。
11+
//! 注意:引擎接线(kb_search 混合 RRF / reindex 向量回填)落地前,公共 API
12+
//! 尚未被生产路径引用——dead_code 临时豁免,接线轮必须移除本属性(TODO REQ-259)。
13+
#![allow(dead_code)]
14+
15+
use std::error::Error;
16+
17+
/// kb_meta 键:当前 embedding 模型名(无引擎时无此键)
18+
pub const META_MODEL: &str = "embedding_model";
19+
/// kb_meta 键:向量维度(f32 数量)
20+
pub const META_DIM: &str = "embedding_dim";
21+
/// kb_meta 键:向量编码格式(本实现固定 "f32le")
22+
pub const META_FORMAT: &str = "embedding_format";
23+
24+
/// 本实现支持的编码格式(变更需迁移既有 BLOB——当前无存量,固定常量)
25+
pub const FORMAT_F32LE: &str = "f32le";
26+
27+
/// Embedding 引擎契约(无引擎=恒不可用,读路径跳过语义召回)。
28+
pub trait EmbeddingEngine: Send + Sync {
29+
/// 模型输出维度(不可用=None)
30+
fn dims(&self) -> Option<usize>;
31+
/// 批量编码文本(不可用/失败=Err——调用方按"未嵌"降级,不阻断主链路)
32+
fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, String>;
33+
}
34+
35+
/// Noop 引擎(默认态——无模型时注册,恒不可用,检索自动 FTS-only)。
36+
#[derive(Debug, Clone, Default)]
37+
pub struct NoopEmbedding;
38+
39+
impl EmbeddingEngine for NoopEmbedding {
40+
fn dims(&self) -> Option<usize> {
41+
None
42+
}
43+
fn embed(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
44+
Err("未配置本地 embedding 模型(降级 FTS-only 检索)".to_string())
45+
}
46+
}
47+
48+
/// 向量 → f32le BLOB(与 kb_chunks.embedding 列一一对应;长度=dim×4)
49+
pub fn encode_embedding(vec: &[f32]) -> Vec<u8> {
50+
vec.iter().flat_map(|v| v.to_le_bytes()).collect()
51+
}
52+
53+
/// f32le BLOB → 向量(长度非 dim×4 → None——版本/损坏防御,调用方降级未嵌)
54+
pub fn decode_embedding(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
55+
if blob.len() != dim * 4 {
56+
return None;
57+
}
58+
let mut out = Vec::with_capacity(dim);
59+
for chunk in blob.chunks_exact(4) {
60+
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
61+
}
62+
Some(out)
63+
}
64+
65+
/// 余弦相似度(零向量安全——任一零向量返回 0.0,不产生 NaN)
66+
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
67+
if a.len() != b.len() || a.is_empty() {
68+
return 0.0;
69+
}
70+
let mut dot = 0f32;
71+
let mut na = 0f32;
72+
let mut nb = 0f32;
73+
for (x, y) in a.iter().zip(b.iter()) {
74+
dot += x * y;
75+
na += x * x;
76+
nb += y * y;
77+
}
78+
if na <= 0.0 || nb <= 0.0 {
79+
return 0.0;
80+
}
81+
dot / (na.sqrt() * nb.sqrt())
82+
}
83+
84+
/// 余弦 top-K(语义召回纯函数:query 与全库 chunk 向量比对)。
85+
///
86+
/// @ai-context: 向量在内存解码后全量线扫——本地量级(数千 chunk × 512 维)
87+
/// 每次查询毫秒级可接受;向量索引(hnsw 等)不做(YAGNI,量级
88+
/// 未到;§六 无云端向量库红线)。k=0 返回空。零相似不返回。
89+
pub fn cosine_top_k(
90+
query: &[f32],
91+
rows: impl Iterator<Item = (i64, Vec<f32>)>,
92+
k: usize,
93+
) -> Vec<(i64, f32)> {
94+
if k == 0 || query.is_empty() {
95+
return Vec::new();
96+
}
97+
let mut scored: Vec<(i64, f32)> = rows
98+
.filter_map(|(chunk_id, vec)| {
99+
let s = cosine_similarity(query, &vec);
100+
(s > 0.0).then_some((chunk_id, s))
101+
})
102+
.collect();
103+
// 降序稳定排序(total_cmp:余弦受零向量守卫无 NaN;并列保序——输出确定利于 golden)
104+
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
105+
scored.truncate(k);
106+
scored
107+
}
108+
109+
/// 统一错误到 String(trait 便捷——引擎内部用)
110+
pub fn to_string_err<E: Error>(e: E) -> String {
111+
e.to_string()
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use super::*;
117+
118+
#[test]
119+
fn blob_roundtrip_preserves_vector() {
120+
let v = vec![0.1, -2.5, 3.75, 0.0, 1e-6];
121+
let blob = encode_embedding(&v);
122+
assert_eq!(blob.len(), v.len() * 4);
123+
assert_eq!(decode_embedding(&blob, v.len()), Some(v));
124+
}
125+
126+
#[test]
127+
fn decode_rejects_wrong_length() {
128+
assert_eq!(decode_embedding(&[0u8; 8], 3), None, "长度≠dim×4 → None");
129+
assert_eq!(decode_embedding(&[0u8; 0], 0), Some(vec![]), "空向量合法");
130+
}
131+
132+
#[test]
133+
fn cosine_zero_safe_and_ordered() {
134+
assert_eq!(cosine_similarity(&[], &[]), 0.0);
135+
assert_eq!(cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]), 0.0); // 正交
136+
assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), 0.0); // 零向量
137+
let near = cosine_similarity(&[1.0, 2.0], &[2.0, 4.0]);
138+
assert!((near - 1.0).abs() < 1e-5, "同向向量相似≈1: {near}");
139+
}
140+
141+
#[test]
142+
fn cosine_top_k_ranks_and_truncates() {
143+
let q = vec![1.0, 0.0];
144+
let rows = vec![
145+
(1, vec![1.0, 0.0]), // 相似 1.0
146+
(2, vec![0.9, 0.1]), // 相似 ~0.994
147+
(3, vec![0.0, 1.0]), // 正交 0 → 被滤
148+
(4, vec![0.8, 0.6]), // 相似 0.8
149+
];
150+
let top = cosine_top_k(&q, rows.into_iter(), 2);
151+
assert_eq!(top.len(), 2);
152+
assert_eq!(top[0].0, 1);
153+
assert_eq!(top[1].0, 2);
154+
assert_eq!(cosine_top_k(&q, std::iter::empty(), 5).len(), 0);
155+
assert_eq!(cosine_top_k(&[], vec![(1, vec![1.0])].into_iter(), 5).len(), 0);
156+
}
157+
158+
#[test]
159+
fn noop_engine_is_unavailable_but_usable_as_default() {
160+
let e = NoopEmbedding;
161+
assert_eq!(e.dims(), None);
162+
assert!(e.embed(&["hi".to_string()]).is_err());
163+
}
164+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ mod kb_prompt;
262262
mod commands_ai_chat_kb;
263263
// v0.19.3(REQ-261):检索建议(发现路径)——证据候选/跨体系相似提示
264264
mod kb_discovery;
265+
// REQ-259(v0.19.5):kb 语义索引 embedding 契约与纯函数(编解码/cosine top-K)
266+
mod kb_embed;
265267
mod commands_kb_discovery;
266268
mod concept_weakness;
267269
mod goal_interview;

0 commit comments

Comments
 (0)