SmileCli05 Memory长期记忆存储实现向量检索向量检查是否重复存储
LongTermMemory2Vector 阶段总结
1. 本阶段目标
这一阶段是在原有长期记忆系统上增加向量化能力,让 SmileCli 的长期记忆从“全部注入”升级为“按当前问题检索相关记忆后注入”。
原来的长期记忆链路是:
/save 或压缩时自动提取 -> LongTermMemory.store() -> 保存 MemoryEntry 到 long_term_memory.json -> Agent 每次请求时读取全部长期记忆 -> 全量注入 LLM 请求这一版改造后的目标是:
保存长期记忆时 -> 生成 embedding 向量 -> 写入本地向量文件 -> 用向量相似度判断是否重复 读取长期记忆时 -> 当前用户输入生成 query 向量 -> 从长期记忆向量索引中检索相关记忆 -> 只注入 TopK 相关记忆这一步本质上是把长期记忆引入 RAG 的两个核心阶段:
Indexing -> 记忆内容向量化 -> 记忆向量落盘 Retrieval -> 当前 query 向量化 -> 余弦相似度检索 -> 相关记忆注入上下文2. 新增和改动的核心类
EmbeddingClient
位置:
src/main/java/edu/sdu/smilecli/memory/EmbeddingClient.java它是长期记忆向量化的抽象接口:
publicinterfaceEmbeddingClient{double[]embed(Stringtext);Stringmodel();}设计意义:
LongTermMemory 不直接关心 embedding 来自哪里。 可以先接 FakeEmbeddingClient 测试链路, 也可以接 OllamaEmbeddingClient 使用本地模型, 以后还可以接 OpenAI、DashScope、Jina 或其他 embedding provider。OllamaEmbeddingClient
位置:
src/main/java/edu/sdu/smilecli/memory/OllamaEmbeddingClient.java它实现了EmbeddingClient,通过本机 Ollama 服务生成向量。
当前默认配置是:
privatestaticfinalStringDEFAULT_BASE_URL="http://localhost:11434";privatestaticfinalStringDEFAULT_MODEL="nomic-embed-text";请求接口:
POST http://localhost:11434/api/embed请求体大致是:
{"model":"nomic-embed-text","input":"这个项目使用 Java 实现长期记忆向量化"}响应体中会返回:
{"model":"nomic-embed-text","embeddings":[[0.010071029,-0.0017594862,0.05007221]]}当前代码解析的是:
JsonNodeembeddingNode=root.path("embeddings").path(0);也就是只处理单条文本输入,取embeddings[0]作为当前文本的向量。
FakeEmbeddingClient
位置:
src/main/java/edu/sdu/smilecli/memory/FakeEmbeddingClient.java这是一个测试用 embedding 实现。它不具备真实语义能力,只是根据字符分布生成稳定的本地向量。
它适合用于:
验证 store 流程 验证向量文件是否写入 验证 searchRelevant 是否能跑通 验证 JsonMemoryVector 的 cosine search正式使用时,Agent已经切换为OllamaEmbeddingClient。
MemoryVectorRecord
位置:
src/main/java/edu/sdu/smilecli/memory/MemoryVectorRecord.java它表示一条长期记忆对应的向量索引记录:
publicrecordMemoryVectorRecord(StringmemoryId,StringembeddingModel,intdimension,double[]vector,longupdatedTime){}字段含义:
memoryId -> 对应 MemoryEntry.id embeddingModel -> 生成这个向量时使用的 embedding 模型 dimension -> 向量维度 vector -> 实际 embedding 向量 updatedTime -> 向量记录更新时间当前没有保存contentHash。因为本阶段主要是只新增长期记忆,不做编辑、导入、迁移和重建索引,所以暂时可以只依赖memoryId关联MemoryEntry和MemoryVectorRecord。
JsonMemoryVector
位置:
src/main/java/edu/sdu/smilecli/memory/JsonMemoryVector.java它负责向量索引的本地 JSON 持久化和余弦相似度检索。
向量文件路径:
${user.home}/.smilecli/memory/long_term_memory_vectors.json核心能力:
loadFromDisk() -> 启动时读取 long_term_memory_vectors.json saveToDisk() -> upsert 后保存向量文件 upsert(memoryId, embeddingModel, vector) -> 保存或覆盖某条 memoryId 对应的向量记录 findByMemoryId(memoryId) -> 根据 MemoryEntry.id 找对应向量 search(queryVector, candidates, topK, minScore) -> 在候选 MemoryEntry 里做向量检索检索流程是:
遍历 candidates -> 根据 memory.id 找 MemoryVectorRecord -> 如果没有向量则跳过 -> 如果维度和 queryVector 不一致则跳过 -> 计算 cosine similarity -> score >= minScore 才加入结果 -> 按 score 降序排序 -> 返回 topK余弦相似度计算逻辑:
score=dot(a,b)/(sqrt(normA)*sqrt(normB))ScoredMemory
位置:
src/main/java/edu/sdu/smilecli/memory/ScoredMemory.java它表示一次长期记忆检索结果:
publicrecordScoredMemory(MemoryEntrymemory,doublescore){}设计意义:
不仅返回 MemoryEntry, 还保留它和当前 query 的相关度分数, 后续可以用于调试、排序、日志或更复杂的 rerank。3. 长期记忆保存流程
核心入口:
src/main/java/edu/sdu/smilecli/memory/LongTermMemory.java当前store已经从void改成了boolean:
publicbooleanstore(Stringcontent,Stringscope)返回值含义:
true -> 成功保存了一条新的长期记忆 false -> 内容为空、精确重复、或向量相似度判断为重复完整流程:
LongTermMemory.store(content, scope) -> 空内容直接返回 false -> scope 规范化为 project 或 global -> 获取 currentProjectPath -> 先做精确去重 -> 如果 embeddingClient 存在,生成 content embedding -> 根据 scope/projectPath 找同区域候选长期记忆 -> 调 JsonMemoryVector.search(..., topK=1, minScore=0.95) -> 如果找到相似记忆,认为重复,返回 false -> 创建新的 MemoryEntry -> upsert 向量记录到 long_term_memory_vectors.json -> add MemoryEntry 到 memories -> 保存 long_term_memory.json -> 返回 true这一版同时保留了两层去重:
精确去重 -> content 完全相同 -> scope 相同 -> project 记忆还要求 projectPath 相同 -> global 记忆不比较 projectPath 语义去重 -> 先生成新 content 的向量 -> 在同一可见范围内检索已有记忆向量 -> 相似度 >= COS_SIMILARITY_SCORE 时认为重复当前重复阈值来自:
src/main/java/edu/sdu/smilecli/util/Constants.javapublicstaticfinaldoubleCOS_SIMILARITY_SCORE=0.95;也就是:
score >= 0.95 -> 认为这条长期记忆已经存在,不再重复保存4. scope 和 projectPath 的作用
长期记忆仍然保留原来的两级作用域:
project -> 只对当前项目可见 global -> 对所有项目可见MemoryEntry的结构是:
publicrecordMemoryEntry(Stringid,Stringcontent,Stringscope,StringprojectPath,longcreatedTime){}向量查重时,代码使用:
sameVisibleRangeOfMemory(memory,normalizedScope,projectPath)当前逻辑是:
如果要保存 project 记忆: -> 只和当前 projectPath 下的 project 记忆比较 如果要保存 global 记忆: -> 只和 global 记忆比较这样可以避免不同项目之间的 project 级记忆互相影响,也避免 global 记忆和 project 记忆混在一起做重复判断。
5. 长期记忆检索流程
读取相关长期记忆的入口是:
publicList<ScoredMemory>searchRelevant(Stringquery)默认参数来自Constants:
publicstaticfinalintDEFAULT_MEMORY_TOP_K=5;publicstaticfinaldoubleDEFAULT_MEMORY_MIN_SCORE=0.75;检索流程:
LongTermMemory.searchRelevant(query) -> query 为空则返回空列表 -> embeddingClient 为空则返回空列表 -> 用 embeddingClient.embed(query.trim()) 生成 queryVector -> 过滤出当前项目可见的长期记忆 candidates -> 调 JsonMemoryVector.search(queryVector, candidates, topK, minScore) -> 返回 List<ScoredMemory>当前项目可见规则:
global 记忆: -> 所有项目可见 project 记忆: -> 只有 memory.projectPath == currentProjectPath 时可见6. Agent 注入长期记忆的变化
位置:
src/main/java/edu/sdu/smilecli/agent/Agent.javaAgent 初始化时现在创建的是:
this.longTermMemory=newLongTermMemory(newOllamaEmbeddingClient(),newJsonMemoryVector());也就是说:
长期记忆内容 -> 由 LongTermMemory 管理 embedding 生成 -> 由 OllamaEmbeddingClient 完成 向量索引 -> 由 JsonMemoryVector 管理原来的长期记忆注入是全量注入:
List<MemoryEntry>memories=longTermMemory.list();现在改成了根据当前用户输入检索:
List<ScoredMemory>memories=longTermMemory.searchRelevant(userInput);然后只把检索到的相关记忆拼进 system message:
以下是可参考的长期记忆: - ... - ...实际发送给 LLM 的 messages 仍然是临时构造的longTermHistory:
system prompt 长期记忆 system message conversationHistory 中除 system 外的消息这点延续了之前的设计:
长期记忆不会永久写回 conversationHistory。 它只是在本次请求里作为额外上下文临时注入。7. CLI 保存反馈变化
位置:
src/main/java/edu/sdu/smilecli/cli/Main.java/save命令现在会读取agent.saveLongTermMemory(toSave, scope)的 boolean 返回值:
booleanresult=agent.saveLongTermMemory(toSave,scope);如果返回true:
已保存到长期记忆(scope): content如果返回false:
未保存到长期记忆(scope): content 已有相关长期记忆这比原来的void store()更清楚,因为现在可以区分:
确实保存成功 因为重复或空内容没有保存8. 自动提取长期记忆的连接点
位置:
src/main/java/edu/sdu/smilecli/memory/ConversationHistoryCompactor.java短期历史压缩时,仍然会让 LLM 从旧对话里提取适合长期保存的记忆:
List<MemoryEntry>memories=extractLongTermMemories(oldMsgs);for(MemoryEntrymemory:memories){longTermMemory.store(memory.content(),"project");}因为现在longTermMemory.store()内部已经包含:
精确去重 向量生成 向量相似查重 MemoryEntry 保存 MemoryVectorRecord 保存所以自动提取出来的长期记忆,也会走同一套向量化存储和重复判断流程。
9. 本地文件结构
长期记忆的人类可读数据仍然保存在:
${user.home}/.smilecli/memory/long_term_memory.json示意结构:
[{"id":"mem-12345678","content":"SmileCli 是一个 Java CLI Agent 项目。","scope":"project","projectPath":"D:\\SmileCli","createdTime":1780000000000}]长期记忆的向量索引保存在:
${user.home}/.smilecli/memory/long_term_memory_vectors.json示意结构:
[{"memoryId":"mem-12345678","embeddingModel":"nomic-embed-text","dimension":768,"vector":[0.01,-0.02,0.03],"updatedTime":1780000000000}]两个文件通过:
MemoryEntry.id == MemoryVectorRecord.memoryId建立关联。
10. 当前设计的优点
仍然保持简单
没有引入 Qdrant、Milvus、pgvector 这类外部向量数据库。向量索引先用 JSON 文件持久化,适合当前长期记忆数量较少的阶段。
检索逻辑可控
当前检索逻辑完全在本地:
遍历候选记忆 计算 cosine similarity 按分数排序 取 topK这让调试和理解都很直接。
embedding provider 可替换
EmbeddingClient把向量生成抽象了出来。后面如果不想用 Ollama,可以替换为:
OpenAIEmbeddingClient DashScopeEmbeddingClient JinaEmbeddingClient LocalOnnxEmbeddingClient而LongTermMemory不需要大改。
查重和检索共用一套向量索引
存储时:
新记忆向量 vs 已有记忆向量 -> 判断是否重复读取时:
当前用户问题向量 vs 已有记忆向量 -> 检索相关长期记忆这一点让长期记忆的“存”和“读”进入了同一套语义空间。
11. 当前仍需注意的边界
1. contentHash 暂时没有加入
当前MemoryVectorRecord只通过memoryId关联MemoryEntry。
在只新增、不编辑、不导入、不迁移长期记忆的前提下,这样可以工作。
但如果以后支持:
/memory edit 手动修改 long_term_memory.json 导入历史记忆 重建索引 切换 embedding 模型就建议给MemoryVectorRecord增加:
contentHash用来判断:
当前 MemoryEntry.content 是否仍然和向量生成时的文本一致2. 旧长期记忆没有自动补向量
如果long_term_memory.json里已经有旧记忆,但long_term_memory_vectors.json没有对应向量,当前检索时会跳过这些记忆。
当前你的处理思路是:
开发阶段可以先删除旧 long_term_memory.json, 让新记忆从一开始就同时写入 MemoryEntry 和 MemoryVectorRecord。后续更完整的做法是加:
/memory rebuild-index或在启动时自动检查缺失向量并补齐。
3. 向量和记忆写入还不是事务
当前保存顺序是:
memoryVector.upsert(...) memories.add(entry) saveToDisk()这种顺序比先写long_term_memory.json再写向量更合理,因为:
如果 vector 多出孤儿记录,检索时不会用到 如果 JSON 多出无向量记忆,用户能看到但 RAG 检索不到,体验更困惑不过它仍然不是严格事务。后续可以考虑:
upsert 返回 boolean saveToDisk 返回 boolean JSON 保存失败时删除刚写入的 vector 增加 orphan vector 清理4. memoryVector 仍然可能被传 null
LongTermMemory(EmbeddingClient embeddingClient, JsonMemoryVector memoryVector)目前直接赋值:
this.memoryVector=memoryVector;如果外部传入了非 null 的embeddingClient,但传入 null 的memoryVector,调用store()或searchRelevant()时可能出现空指针。
当前Agent里传的是:
newLongTermMemory(newOllamaEmbeddingClient(),newJsonMemoryVector())所以主链路没有问题。
后续可以在构造器里兜底:
this.memoryVector=memoryVector==null?newJsonMemoryVector():memoryVector;5. Ollama 配置目前写死为默认值
OllamaEmbeddingClient目前默认使用:
http://localhost:11434 nomic-embed-text如果后续希望通过.env配置:
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_EMBEDDING_MODEL=nomic-embed-text可以把Main.loadEnvValue()抽成公共工具类,或者在Main中创建OllamaEmbeddingClient(baseUrl, model)再传给Agent。
6. 查重阈值需要根据实际效果调整
当前:
COS_SIMILARITY_SCORE = 0.95含义是非常相似才认为重复。
这个值偏保守,适合避免误删不同记忆。但也可能导致一些语义重复的记忆没有被拦截。
后续可以根据真实数据观察:
0.95 -> 高精度,少误判,可能漏掉重复 0.90 -> 更积极去重,但可能误判相近但不同的事实对于容易冲突的记忆,例如:
项目使用 Java 16 项目使用 Java 17向量相似度可能很高,但它们不是重复,而是冲突或更新。后续可以在高相似区间加一层 LLM 判定:
same_fact update_old conflict unrelated12. 本阶段完成度
目前长期记忆向量化已经完成了第一版核心闭环:
Ollama embedding client -> 可以把文本变成向量 JsonMemoryVector -> 可以把向量写入本地 JSON 文件 LongTermMemory.store() -> 可以在保存时做精确去重和向量相似去重 LongTermMemory.searchRelevant() -> 可以根据当前用户输入检索相关长期记忆 Agent.buildLongTermMemoryContext() -> 可以把检索出的长期记忆注入本次 LLM 请求 CLI /save -> 可以根据 boolean 返回值提示保存成功或已有相关记忆从架构角度看,本阶段把长期记忆从:
长期记忆 JSON 存储 全量注入 字符串精确去重推进到了:
长期记忆 JSON 存储 本地向量索引 JSON 存储 Ollama 本地 embedding 语义相似去重 TopK 相关记忆检索注入这已经是一个可继续演进的本地 RAG 长期记忆雏形。
13. 建议下一步
建议后续按这个顺序推进:
- 给
OllamaEmbeddingClient接入.env配置,避免 baseUrl 和 model 写死。 - 给
LongTermMemory构造器补memoryVector兜底,降低空指针风险。 - 给
JsonMemoryVector.upsert()增加 boolean 返回值,让LongTermMemory.store()能知道向量是否真正保存成功。 - 增加
/memory rebuild-index,用于给已有long_term_memory.json补齐向量。 - 在
MemoryVectorRecord增加contentHash,为后续编辑、迁移、重建索引做准备。 - 给
LongTermMemory.store()、JsonMemoryVector.search()、OllamaEmbeddingClient.embed()补单元测试。 - 根据真实使用结果调整
COS_SIMILARITY_SCORE和DEFAULT_MEMORY_MIN_SCORE。 - 后续代码阅读 RAG 可以复用这一套思想,但代码 RAG 应该加入关键词检索、文件路径、符号名、行号等结构化信息,不能只靠向量。
