Skip to main content

echo_state/memory/
embedding_store.rs

1//! 向量增强 Store(EmbeddingStore)
2//!
3//! 在任意 [`Store`] 实现外包装一层向量索引,透传所有 KV 操作,
4//! 并通过 [`Store::search_with`] 提供语义/混合检索能力。
5//!
6//! ## 存储分层
7//!
8//! | 层 | 负责方 | 内容 |
9//! |----|--------|------|
10//! | 内容层 | `inner: Arc<dyn Store>` | 条目键值(KV 语义)|
11//! | 向量层 | 内存 `RwLock<VecIndex>` + 可选 JSON 文件 | 嵌入向量 |
12//!
13//! ## 快速上手
14//!
15//! ```rust,no_run
16//! use echo_core::error::Result;
17//! use echo_state::memory::{EmbeddingStore, FileStore, HttpEmbedder};
18//! use std::sync::Arc;
19//!
20//! # async fn example() -> Result<()> {
21//! let inner = Arc::new(FileStore::new("~/.echo-agent/store.json")?);
22//! let embedder = Arc::new(HttpEmbedder::from_env());
23//! let store = Arc::new(
24//!     EmbeddingStore::with_persistence(inner, embedder, "~/.echo-agent/store.vecs.json")?
25//! );
26//!
27//! // 将 `store` 注入你自己的 runtime 层,或通过 `echo_agent` façade 使用。
28//! let _ = store;
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::util::expand_tilde;
34use echo_core::error::{MemoryError, Result};
35pub use echo_core::memory::embedder::Embedder;
36pub use echo_core::memory::store::{SearchMode, SearchQuery, Store, StoreItem};
37use futures::future::BoxFuture;
38use serde_json::Value;
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::Arc;
42use tokio::sync::RwLock;
43use tracing::{debug, info, warn};
44
45// ── VecIndex ─────────────────────────────────────────────────────────────────
46
47/// 内存向量索引:namespace_key → key → 嵌入向量
48#[derive(Default)]
49struct VecIndex {
50    /// namespace_key(如 "alice/memories")→ key(UUID)→ 向量
51    data: HashMap<String, HashMap<String, Vec<f32>>>,
52}
53
54impl VecIndex {
55    fn insert(&mut self, ns_key: &str, key: &str, vec: Vec<f32>) {
56        self.data
57            .entry(ns_key.to_string())
58            .or_default()
59            .insert(key.to_string(), vec);
60    }
61
62    fn remove(&mut self, ns_key: &str, key: &str) {
63        if let Some(ns) = self.data.get_mut(ns_key) {
64            ns.remove(key);
65        }
66    }
67
68    fn get_namespace(&self, ns_key: &str) -> Option<&HashMap<String, Vec<f32>>> {
69        self.data.get(ns_key)
70    }
71}
72
73// ── EmbeddingStore ────────────────────────────────────────────────────────────
74
75/// 向量增强 Store:在任意 `Store` 外包装余弦相似度语义检索
76///
77/// > **注意**:此实现适用于**小规模**场景(数千条目以内)。向量索引
78/// > 全部加载到内存中,对于大规模检索场景,推荐使用专门的向量数据库
79/// > (如 Milvus、Qdrant、pgvector 等)。
80pub struct EmbeddingStore {
81    inner: Arc<dyn Store>,
82    embedder: Arc<dyn Embedder>,
83    index: RwLock<VecIndex>,
84    vec_path: Option<PathBuf>,
85    /// 语义检索时最多加载到内存进行比对的向量数量。
86    /// 超出此数量的向量会被跳过,避免 OOM。
87    /// 默认 10000。
88    max_candidates: usize,
89}
90
91impl EmbeddingStore {
92    /// 创建 EmbeddingStore,向量索引仅保存在内存中(进程重启后需重新写入条目以重建索引)
93    pub fn new(inner: Arc<dyn Store>, embedder: Arc<dyn Embedder>) -> Self {
94        info!("🧠 EmbeddingStore 初始化(内存索引)");
95        Self {
96            inner,
97            embedder,
98            index: RwLock::new(VecIndex::default()),
99            vec_path: None,
100            max_candidates: 10_000,
101        }
102    }
103
104    /// 创建 EmbeddingStore,向量索引持久化到指定 JSON 文件
105    ///
106    /// 若文件已存在则加载已有向量索引;不存在则从空索引开始(新条目写入时实时更新)。
107    pub fn with_persistence(
108        inner: Arc<dyn Store>,
109        embedder: Arc<dyn Embedder>,
110        vec_path: impl AsRef<Path>,
111    ) -> Result<Self> {
112        let path = expand_tilde(vec_path.as_ref());
113        if let Some(parent) = path.parent() {
114            std::fs::create_dir_all(parent).map_err(|e| MemoryError::IoError(e.to_string()))?;
115        }
116        let index = if path.exists() {
117            let raw =
118                std::fs::read_to_string(&path).map_err(|e| MemoryError::IoError(e.to_string()))?;
119            let data: HashMap<String, HashMap<String, Vec<f32>>> = serde_json::from_str(&raw)
120                .unwrap_or_else(|e| {
121                    warn!("向量索引文件解析失败,从空索引开始: {e}");
122                    HashMap::new()
123                });
124            let entry_count: usize = data.values().map(|m| m.len()).sum();
125            info!(
126                path = %path.display(),
127                entries = entry_count,
128                "🧠 EmbeddingStore 初始化(持久化索引,已加载 {} 条向量)",
129                entry_count,
130            );
131            VecIndex { data }
132        } else {
133            info!(path = %path.display(), "🧠 EmbeddingStore 初始化(空索引)");
134            VecIndex::default()
135        };
136        Ok(Self {
137            inner,
138            embedder,
139            index: RwLock::new(index),
140            vec_path: Some(path),
141            max_candidates: 10_000,
142        })
143    }
144
145    /// 设置语义检索时最多加载到内存进行比对的向量数量(默认 10000)。
146    ///
147    /// 超出此数量的向量会被跳过,避免大规模场景下的 OOM 问题。
148    pub fn with_max_candidates(mut self, max: usize) -> Self {
149        self.max_candidates = max;
150        self
151    }
152
153    /// 从 JSON Value 提取可嵌入文本
154    ///
155    /// 优先使用 `content` 字段(`remember` 工具写入格式),追加 `tags` 以丰富语义;
156    /// 否则将整个 Value 序列化为文本。
157    fn extract_text(value: &Value) -> String {
158        if let Value::Object(map) = value
159            && let Some(content) = map.get("content").and_then(|v| v.as_str())
160        {
161            let tags: String = map
162                .get("tags")
163                .and_then(|v| v.as_array())
164                .map(|arr| {
165                    arr.iter()
166                        .filter_map(|t| t.as_str())
167                        .collect::<Vec<_>>()
168                        .join(" ")
169                })
170                .unwrap_or_default();
171            return if tags.is_empty() {
172                content.to_string()
173            } else {
174                format!("{content} {tags}")
175            };
176        }
177
178        value_to_text(value)
179    }
180
181    async fn flush_index(&self) -> Result<()> {
182        let Some(ref path) = self.vec_path else {
183            return Ok(());
184        };
185        let index = self.index.read().await;
186        let json = serde_json::to_string(&index.data)
187            .map_err(|e| MemoryError::SerializationError(format!("向量索引序列化失败: {e}")))?;
188        tokio::fs::write(path, json)
189            .await
190            .map_err(|e| MemoryError::IoError(e.to_string()))?;
191        debug!(path = %path.display(), "💾 向量索引已持久化");
192        Ok(())
193    }
194
195    /// 将向量索引刷盘(仅对有持久化路径的实例有效)。
196    ///
197    /// 建议在批量写入后调用,以将内存中的向量变更持久化。
198    pub async fn flush_vector_index(&self) -> Result<()> {
199        self.flush_index().await
200    }
201
202    async fn semantic_search_impl(
203        &self,
204        namespace: &[&str],
205        query: &str,
206        limit: usize,
207    ) -> Result<Vec<StoreItem>> {
208        let ns_key = namespace.join("/");
209
210        let query_vec = match self.embedder.embed(query).await {
211            Ok(v) => v,
212            Err(e) => {
213                warn!(error = %e, "⚠️ 查询嵌入计算失败,回退到关键词检索");
214                return self.inner.search(namespace, query, limit).await;
215            }
216        };
217
218        let scored: Vec<(f32, String)> = {
219            let index = self.index.read().await;
220            let Some(ns_vecs) = index.get_namespace(&ns_key) else {
221                debug!(ns = %ns_key, "向量索引为空,回退到关键词检索");
222                drop(index);
223                return self.inner.search(namespace, query, limit).await;
224            };
225            let mut scored: Vec<(f32, String)> = ns_vecs
226                .iter()
227                .take(self.max_candidates)
228                .map(|(key, vec)| (cosine_similarity(&query_vec, vec), key.clone()))
229                .collect();
230            scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
231            scored.truncate(limit);
232            scored
233        };
234
235        if scored.is_empty() {
236            return Ok(vec![]);
237        }
238
239        debug!(ns = %ns_key, query = %query, hits = scored.len(), "🔍 语义检索完成");
240
241        let mut results = Vec::with_capacity(scored.len());
242        for (score, key) in scored {
243            if let Ok(Some(mut item)) = self.inner.get(namespace, &key).await {
244                item.score = Some(score);
245                results.push(item);
246            }
247        }
248        Ok(results)
249    }
250}
251
252impl Store for EmbeddingStore {
253    fn put<'a>(
254        &'a self,
255        namespace: &'a [&'a str],
256        key: &'a str,
257        value: Value,
258    ) -> BoxFuture<'a, Result<()>> {
259        Box::pin(async move {
260            self.inner.put(namespace, key, value.clone()).await?;
261
262            // 嵌入计算失败只打 warn,不影响写入
263            let text = Self::extract_text(&value);
264            match self.embedder.embed(&text).await {
265                Ok(vec) => {
266                    let ns_key = namespace.join("/");
267                    debug!(ns = %ns_key, key = %key, dims = vec.len(), "📌 向量索引已更新");
268                    self.index.write().await.insert(&ns_key, key, vec);
269                    // 批量刷盘:不在每次 put 时立即写盘,减少 IO 开销。
270                    // 调用者应定期调用 `flush_vector_index()` 或使用 `with_persistence`
271                    // 配合 `put_batch()` 来持久化向量索引。
272                }
273                Err(e) => {
274                    warn!(key = %key, error = %e, "⚠️ 嵌入计算失败,该条目不加入向量索引");
275                }
276            }
277            Ok(())
278        })
279    }
280
281    fn get<'a>(
282        &'a self,
283        namespace: &'a [&'a str],
284        key: &'a str,
285    ) -> BoxFuture<'a, Result<Option<StoreItem>>> {
286        Box::pin(async move { self.inner.get(namespace, key).await })
287    }
288
289    fn search<'a>(
290        &'a self,
291        namespace: &'a [&'a str],
292        query: &'a str,
293        limit: usize,
294    ) -> BoxFuture<'a, Result<Vec<StoreItem>>> {
295        Box::pin(async move { self.inner.search(namespace, query, limit).await })
296    }
297
298    fn delete<'a>(&'a self, namespace: &'a [&'a str], key: &'a str) -> BoxFuture<'a, Result<bool>> {
299        Box::pin(async move {
300            let found = self.inner.delete(namespace, key).await?;
301            if found {
302                let ns_key = namespace.join("/");
303                self.index.write().await.remove(&ns_key, key);
304                // 批量刷盘:调用者应定期调用 `flush_vector_index()` 持久化。
305            }
306            Ok(found)
307        })
308    }
309
310    fn list_namespaces<'a>(
311        &'a self,
312        prefix: Option<&'a [&'a str]>,
313    ) -> BoxFuture<'a, Result<Vec<Vec<String>>>> {
314        Box::pin(async move { self.inner.list_namespaces(prefix).await })
315    }
316
317    fn list<'a>(&'a self, namespace: &'a [&'a str]) -> BoxFuture<'a, Result<Vec<StoreItem>>> {
318        Box::pin(async move { self.inner.list(namespace).await })
319    }
320
321    fn search_with<'a>(
322        &'a self,
323        namespace: &'a [&'a str],
324        query: SearchQuery<'a>,
325    ) -> BoxFuture<'a, Result<Vec<StoreItem>>> {
326        Box::pin(async move {
327            match query.mode {
328                SearchMode::Keyword => self.inner.search(namespace, query.text, query.limit).await,
329                SearchMode::Semantic => {
330                    self.semantic_search_impl(namespace, query.text, query.limit)
331                        .await
332                }
333                SearchMode::Hybrid { vector_weight } => {
334                    // Get both result lists independently
335                    let semantic_items = self
336                        .semantic_search_impl(namespace, query.text, query.limit)
337                        .await?;
338                    let keyword_items = self
339                        .inner
340                        .search(namespace, query.text, query.limit)
341                        .await?;
342
343                    // Build rank maps: key -> 0-based position
344                    let semantic_ranks: HashMap<&str, usize> = semantic_items
345                        .iter()
346                        .enumerate()
347                        .map(|(i, item)| (item.key.as_str(), i))
348                        .collect();
349                    let keyword_ranks: HashMap<&str, usize> = keyword_items
350                        .iter()
351                        .enumerate()
352                        .map(|(i, item)| (item.key.as_str(), i))
353                        .collect();
354
355                    // Collect all unique keys
356                    let all_keys: Vec<&str> = semantic_ranks
357                        .keys()
358                        .chain(keyword_ranks.keys())
359                        .copied()
360                        .collect::<std::collections::HashSet<_>>()
361                        .into_iter()
362                        .collect();
363
364                    // Score each key via RRF, pick best item from either list
365                    let mut items: Vec<StoreItem> = all_keys
366                        .iter()
367                        .filter_map(|&key| {
368                            let sr = semantic_ranks.get(key).copied().unwrap_or(usize::MAX);
369                            let kr = keyword_ranks.get(key).copied().unwrap_or(usize::MAX);
370                            let rrf = echo_core::memory::store::rrf_score(sr, kr, vector_weight);
371
372                            // Prefer the semantic item if available, otherwise keyword
373                            let mut item = semantic_items
374                                .iter()
375                                .find(|i| i.key == key)
376                                .or_else(|| keyword_items.iter().find(|i| i.key == key))?
377                                .clone();
378                            item.score = Some(rrf);
379                            Some(item)
380                        })
381                        .collect();
382
383                    items.sort_by(|a, b| {
384                        b.score
385                            .unwrap_or_default()
386                            .partial_cmp(&a.score.unwrap_or_default())
387                            .unwrap_or(std::cmp::Ordering::Equal)
388                    });
389                    items.truncate(query.limit);
390                    Ok(items)
391                }
392            }
393        })
394    }
395}
396
397// ── 工具函数 ──────────────────────────────────────────────────────────────────
398
399/// 余弦相似度:两向量维度不匹配或为零向量时返回 0.0
400fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
401    if a.len() != b.len() || a.is_empty() {
402        return 0.0;
403    }
404    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
405    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
406    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
407    if norm_a == 0.0 || norm_b == 0.0 {
408        0.0
409    } else {
410        dot / (norm_a * norm_b)
411    }
412}
413
414fn value_to_text(value: &Value) -> String {
415    match value {
416        Value::String(s) => s.clone(),
417        Value::Array(arr) => arr.iter().map(value_to_text).collect::<Vec<_>>().join(" "),
418        Value::Object(map) => map
419            .values()
420            .map(value_to_text)
421            .collect::<Vec<_>>()
422            .join(" "),
423        Value::Number(n) => n.to_string(),
424        Value::Bool(b) => b.to_string(),
425        Value::Null => String::new(),
426    }
427}
428
429// ── 单元测试 ──────────────────────────────────────────────────────────────────
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::memory::MockEmbedder;
435    use crate::memory::store::InMemoryStore;
436    use serde_json::json;
437
438    async fn make_store() -> EmbeddingStore {
439        let inner = Arc::new(InMemoryStore::new());
440        let embedder = Arc::new(MockEmbedder::new(4));
441        EmbeddingStore::new(inner, embedder)
442    }
443
444    #[tokio::test]
445    async fn test_put_and_semantic_search() {
446        let store = make_store().await;
447        let ns = &["test", "ns"];
448
449        store
450            .put(ns, "k1", json!({"content": "Rust programming"}))
451            .await
452            .unwrap();
453        store
454            .put(ns, "k2", json!({"content": "Python machine learning"}))
455            .await
456            .unwrap();
457
458        let results = store
459            .search_with(ns, SearchQuery::semantic("Rust", 5))
460            .await
461            .unwrap();
462        assert!(!results.is_empty());
463        assert!(results[0].score.is_some());
464    }
465
466    #[tokio::test]
467    async fn test_delete_removes_from_index() {
468        let store = make_store().await;
469        let ns = &["test", "del"];
470
471        store
472            .put(ns, "k1", json!({"content": "hello world"}))
473            .await
474            .unwrap();
475        store.delete(ns, "k1").await.unwrap();
476
477        // 向量索引中已无该条目
478        let index = store.index.read().await;
479        let ns_vecs = index.get_namespace("test/del");
480        assert!(ns_vecs.map(|m| m.is_empty()).unwrap_or(true));
481    }
482
483    #[tokio::test]
484    async fn test_cosine_similarity() {
485        let a = vec![1.0f32, 0.0, 0.0];
486        let b = vec![1.0f32, 0.0, 0.0];
487        let sim = cosine_similarity(&a, &b);
488        assert!((sim - 1.0).abs() < 1e-5);
489
490        let c = vec![0.0f32, 1.0, 0.0];
491        let sim2 = cosine_similarity(&a, &c);
492        assert!((sim2 - 0.0).abs() < 1e-5);
493    }
494}