diff --git a/docs/6-memory-system.md b/docs/6-memory-system.md index 6e26d0d..2b9c494 100644 --- a/docs/6-memory-system.md +++ b/docs/6-memory-system.md @@ -99,6 +99,9 @@ src/ ```rust #[async_trait] pub trait MemoryStore: Send + Sync { + /// 保存/覆盖一个 MemoryItem(upsert 语义)。 + /// - 如果 id 不存在,则插入新条目 + /// - 如果 id 已存在,则覆盖旧条目 async fn save(&self, item: MemoryItem) -> Result<(), MemoryError>; async fn get(&self, id: &str) -> Result, MemoryError>; async fn delete(&self, id: &str) -> Result<(), MemoryError>; @@ -123,19 +126,23 @@ pub struct ConversationMemory { store: Arc, session_id: String, config: ConversationMemoryConfig, + messages: Vec, // 热缓存,供 compact 直接操作 + compact_state: CompactState, // 断路器状态 } pub struct ConversationMemoryConfig { - pub strategy: MemoryStrategy, // sliding_window | full - pub max_turns: usize, // sliding window 的最大轮数 - pub compact_config: Option, // 复用现有压缩配置 + pub strategy: MemoryStrategy, // sliding_window | full + pub max_turns: usize, // sliding window 的最大轮数 + pub compact_config: Option, // 复用现有压缩配置 } ``` -- 基于 `MemoryStore` 持久化消息 -- `add_message()` 写入,`get_history()` 读取 -- sliding window 模式:超出 `max_turns` 后,调用 `llm::compact::microcompact()` 裁剪 -- 复用现有 `CompactConfig`(context_window, reserved_tokens, keep_recent) +- `add_message(msg)` 写入热缓存 `self.messages`,同时通过 `store.save()` 持久化到后端 +- `get_history()` 优先从热缓存返回,缓存未命中时从 store 恢复 +- `compact` 直接在 `self.messages` 上调用 `should_compact()` 和 `microcompact()` +- 压缩后同步回 `store` +- 使用了 `llm::types::OpenaiChatMessage` 作为内部消息类型 +- 复用现有 `CompactConfig`(context_window, reserved_tokens, keep_recent)和 `CompactState` #### KnowledgeStore — 具体 struct @@ -156,8 +163,39 @@ impl KnowledgeStore { } ``` -- `search` 使用简单的字符串包含匹配标题/摘要/标签 -- index 在 add/update/delete 时自动维护 +- `search()` 优先搜索 index(标题/摘要/标签),全文 content 搜索走 MemoryStore +- index 在 add/update/delete 时自动维护,也支持通过 `rebuild_index()` 手动重建 +- `KnowledgeStore` 以 `"knowledge_{page_id}"` 格式作为 `MemoryItem.id` 前缀,前缀字符串提取为常量 `const KNOWLEDGE_PREFIX: &str = "knowledge_"` + +提供 `rebuild_index()` 方法修复 index 与 store 的不同步问题: + +```rust +impl KnowledgeStore { + /// 从 MemoryStore 重建 index(修复 index 与 store 的不同步问题) + pub async fn rebuild_index(&self) -> Result<(), MemoryError> { + let items = self.store.list(&MemoryFilter { + prefix: Some("knowledge_".into()), + since: None, + offset: None, + limit: None, + }).await?; + let mut index = self.index.lock(); + index.clear(); + for item in items { + let page: KnowledgePage = serde_json::from_str(&item.content) + .map_err(|e| MemoryError::Serialization(e.to_string()))?; + index.push(PageIndexEntry { + id: page.id.clone(), + title: page.title, + summary: page.summary, + tags: page.tags, + updated_at: page.updated_at, + }); + } + Ok(()) + } +} +``` #### MemoryRetriever — 简化版检索器 @@ -205,6 +243,19 @@ flowchart TD 关键词提取在 MemoryRetriever 内部简单实现:按空格/标点分割 → 过滤单字符和停用词 → 返回关键词列表。TextOverlap 计算 query 与页面标题/摘要/内容的 n-gram 重叠度(基于 Dice 系数)。 +TextOverlap 评分基于 Dice 系数(字符 bigram): + + dice(query, text) = 2 × |bigrams(query) ∩ bigrams(text)| / (|bigrams(query)| + |bigrams(text)|) + +多字段加权: + score = title_dice × 0.5 + summary_dice × 0.3 + content_dice × 0.2 + +中文场景退化:当前版本按字符级 bigram 处理中文,不依赖分词器。 + +> **已知限制**:关键词提取基于空格/标点分割,对中文不做语义分词。 +> 中文场景按字符 bigram 参与 TextOverlap 计算,精度低于专业分词方案。 +> 如有更高精度需求,可替换 MemoryRetriever 的关键词提取逻辑。 + ### 3.4 核心数据类型 ```rust @@ -212,12 +263,13 @@ pub struct MemoryItem { pub id: String, pub content: String, pub metadata: serde_json::Value, - pub created_at: chrono::DateTime, + pub created_at: time::OffsetDateTime, } pub struct MemoryFilter { pub prefix: Option, - pub since: Option>, + pub since: Option, + pub offset: Option, // 跳过前 N 条 pub limit: Option, } @@ -228,8 +280,8 @@ pub struct KnowledgePage { pub content: String, pub tags: Vec, pub references: Vec, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, + pub created_at: time::OffsetDateTime, + pub updated_at: time::OffsetDateTime, } pub struct PageIndexEntry { @@ -237,7 +289,7 @@ pub struct PageIndexEntry { pub title: String, pub summary: String, pub tags: Vec, - pub updated_at: chrono::DateTime, + pub updated_at: time::OffsetDateTime, } pub enum MemoryStrategy { @@ -254,9 +306,6 @@ pub enum MemoryError { #[error("Item not found: {0}")] NotFound(String), - #[error("Item already exists: {0}")] - AlreadyExists(String), - #[error("Storage error: {0}")] Storage(String), @@ -281,29 +330,31 @@ impl MemoryError { ```mermaid flowchart TD - subgraph CM["ConversationMemory (src/memory/conversation.rs)"] - A["add_message()"] - A1["store.save(message) ← 写到底层存储"] - C{"sliding_window && over_limit?"} - D["messages = store.list(session)"] - E["compact_config.should_compact() ← 复用 CompactConfig"] - F["microcompact(&mut messages) ← 复用 microcompact()"] - G["store.save(pruned) ← 写回"] - H["return"] - I["get_history()"] - J["store.list(session)"] + subgraph CM["ConversationMemory"] + A["add_message(msg)"] + A1["self.messages.push(msg) ← 热缓存"] + A2["store.save(to_item(msg)) ← 冷持久化"] + B{"len(messages) > max_turns?"} + C["should_compact(&messages, &config, &state) ← 直接在热缓存上操作"] + D["microcompact(&mut messages, keep_recent) ← 复用 microcompact()"] + E["sync_to_store() ← 压缩后同步回 store"] + F["return"] + G["get_history()"] + H["从 self.messages 返回"] + I["从 store 恢复 → 重建热缓存"] A --> A1 - A1 --> C - C -->|是| D + A1 --> A2 + A2 --> B + B -->|是| C + C --> D D --> E E --> F - F --> G + B -->|否| F G --> H - C -->|否| H - I --> J + H -->|缓存未命中| I end - K["imports: llm::compact::{CompactConfig, microcompact, should_compact}"] + J["依赖: llm::compact::{CompactConfig, CompactState, should_compact, microcompact}"] ``` --- @@ -516,7 +567,7 @@ async fn maybe_evict(&self) { - 单元测试:TTL 过期淘汰、容量上限淘汰、不淘汰(None) - 验收:`cargo build` + `cargo test` 通过 -**依赖**:chrono(日期时间)、serde(序列化) +**依赖**:time(日期时间,启用 `serde` feature)、serde(序列化) ### Step 2:ConversationMemory(含消息淘汰) @@ -551,7 +602,9 @@ async fn maybe_evict(&self) { - 内部关键词提取:split + 过滤停用词 - TextOverlap 评分:基于 Dice 系数计算 query 与页面的文本重叠度 - 阈值过滤 → 排序 → 截取 top-N -- 在 `memory.rs` 中用 `pub use` 重导出所有公共类型 +- 在 `memory.rs` 中用 `pub use` 分层重导出: + - 高频类型(大多数下游需要):`MemoryStore`、`InMemoryStore`、`ConversationMemory`、`KnowledgeStore`、`MemoryRetriever`、`MemoryError` + - 低频类型(配置/高级使用):`MemoryItem`、`MemoryFilter`、`MemoryStrategy`、`KnowledgePage`、`PageIndexEntry`、`EvictionConfig`、`EvictionPolicy`、`ConversationMemoryConfig`、`RetrieverConfig`、`RetrievalResult`、`ScoredItem` - 在 `src/lib.rs` 中声明 `pub mod memory` - 单元测试:关键词提取、TextOverlap 评分正确性、阈值过滤、排序正确性 - 集成测试:端到端检索流程 @@ -565,7 +618,7 @@ async fn maybe_evict(&self) { |------|------|------|---------| | KnowledgeStore 的 keyword 检索在大规模下效率低 | 中 | 中 | MemoryStore 实现可替换——下游可使用 SQLite FTS 等更高效的后端 | | ConversationMemory 与 compact 耦合引入循环依赖 | 低 | 高 | 仅引用 `CompactConfig`(纯数据结构)和 `microcompact()`(纯函数),不引用 cycle.rs | -| chrono 增加依赖体积 | 低 | 低 | chrono 已是 Rust 生态标准,且仅用于时间戳 | +| time 增加依赖体积 | 低 | 低 | time 是 Rust 官方维护的时间库,体积小于 chrono | | Phase 4 集成时发现 Memory 设计不合理 | 低 | 高 | 按最小可行接口设计,预留扩展空间 | ---