|
| 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 | +} |
0 commit comments