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_URLhttp://localhost:11434;privatestaticfinalStringDEFAULT_MODELnomic-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]]}当前代码解析的是JsonNodeembeddingNoderoot.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余弦相似度计算逻辑scoredot(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改成了booleanpublicbooleanstore(Stringcontent,Stringscope)返回值含义true - 成功保存了一条新的长期记忆 false - 内容为空、精确重复、或向量相似度判断为重复完整流程LongTermMemory.store(content, scope) - 空内容直接返回 false - scope 规范化为 project 或 global - 获取 currentProjectPath - 先做精确去重 - 如果 embeddingClient 存在生成 content embedding - 根据 scope/projectPath 找同区域候选长期记忆 - 调 JsonMemoryVector.search(..., topK1, minScore0.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_SCORE0.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. 长期记忆检索流程读取相关长期记忆的入口是publicListScoredMemorysearchRelevant(Stringquery)默认参数来自ConstantspublicstaticfinalintDEFAULT_MEMORY_TOP_K5;publicstaticfinaldoubleDEFAULT_MEMORY_MIN_SCORE0.75;检索流程LongTermMemory.searchRelevant(query) - query 为空则返回空列表 - embeddingClient 为空则返回空列表 - 用 embeddingClient.embed(query.trim()) 生成 queryVector - 过滤出当前项目可见的长期记忆 candidates - 调 JsonMemoryVector.search(queryVector, candidates, topK, minScore) - 返回 ListScoredMemory当前项目可见规则global 记忆 - 所有项目可见 project 记忆 - 只有 memory.projectPath currentProjectPath 时可见6. Agent 注入长期记忆的变化位置src/main/java/edu/sdu/smilecli/agent/Agent.javaAgent 初始化时现在创建的是this.longTermMemorynewLongTermMemory(newOllamaEmbeddingClient(),newJsonMemoryVector());也就是说长期记忆内容 - 由 LongTermMemory 管理 embedding 生成 - 由 OllamaEmbeddingClient 完成 向量索引 - 由 JsonMemoryVector 管理原来的长期记忆注入是全量注入ListMemoryEntrymemorieslongTermMemory.list();现在改成了根据当前用户输入检索ListScoredMemorymemorieslongTermMemory.searchRelevant(userInput);然后只把检索到的相关记忆拼进 system message以下是可参考的长期记忆 - ... - ...实际发送给 LLM 的 messages 仍然是临时构造的longTermHistorysystem prompt 长期记忆 system message conversationHistory 中除 system 外的消息这点延续了之前的设计长期记忆不会永久写回 conversationHistory。 它只是在本次请求里作为额外上下文临时注入。7. CLI 保存反馈变化位置src/main/java/edu/sdu/smilecli/cli/Main.java/save命令现在会读取agent.saveLongTermMemory(toSave, scope)的 boolean 返回值booleanresultagent.saveLongTermMemory(toSave,scope);如果返回true已保存到长期记忆(scope): content如果返回false未保存到长期记忆(scope): content 已有相关长期记忆这比原来的void store()更清楚因为现在可以区分确实保存成功 因为重复或空内容没有保存8. 自动提取长期记忆的连接点位置src/main/java/edu/sdu/smilecli/memory/ConversationHistoryCompactor.java短期历史压缩时仍然会让 LLM 从旧对话里提取适合长期保存的记忆ListMemoryEntrymemoriesextractLongTermMemories(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 仍然可能被传 nullLongTermMemory(EmbeddingClient embeddingClient, JsonMemoryVector memoryVector)目前直接赋值this.memoryVectormemoryVector;如果外部传入了非 null 的embeddingClient但传入 null 的memoryVector调用store()或searchRelevant()时可能出现空指针。当前Agent里传的是newLongTermMemory(newOllamaEmbeddingClient(),newJsonMemoryVector())所以主链路没有问题。后续可以在构造器里兜底this.memoryVectormemoryVectornull?newJsonMemoryVector():memoryVector;5. Ollama 配置目前写死为默认值OllamaEmbeddingClient目前默认使用http://localhost:11434 nomic-embed-text如果后续希望通过.env配置OLLAMA_BASE_URLhttp://localhost:11434 OLLAMA_EMBEDDING_MODELnomic-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 应该加入关键词检索、文件路径、符号名、行号等结构化信息不能只靠向量。