SmileCli05 Memory长期记忆存储实现向量检索向量检查是否重复存储

📅 2026/7/8 5:40:40 👁️ 阅读次数 📝 编程学习
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关联MemoryEntryMemoryVectorRecord

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.java
publicstaticfinaldoubleCOS_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.java

Agent 初始化时现在创建的是:

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 unrelated

12. 本阶段完成度

目前长期记忆向量化已经完成了第一版核心闭环:

Ollama embedding client -> 可以把文本变成向量 JsonMemoryVector -> 可以把向量写入本地 JSON 文件 LongTermMemory.store() -> 可以在保存时做精确去重和向量相似去重 LongTermMemory.searchRelevant() -> 可以根据当前用户输入检索相关长期记忆 Agent.buildLongTermMemoryContext() -> 可以把检索出的长期记忆注入本次 LLM 请求 CLI /save -> 可以根据 boolean 返回值提示保存成功或已有相关记忆

从架构角度看,本阶段把长期记忆从:

长期记忆 JSON 存储 全量注入 字符串精确去重

推进到了:

长期记忆 JSON 存储 本地向量索引 JSON 存储 Ollama 本地 embedding 语义相似去重 TopK 相关记忆检索注入

这已经是一个可继续演进的本地 RAG 长期记忆雏形。

13. 建议下一步

建议后续按这个顺序推进:

  1. OllamaEmbeddingClient接入.env配置,避免 baseUrl 和 model 写死。
  2. LongTermMemory构造器补memoryVector兜底,降低空指针风险。
  3. JsonMemoryVector.upsert()增加 boolean 返回值,让LongTermMemory.store()能知道向量是否真正保存成功。
  4. 增加/memory rebuild-index,用于给已有long_term_memory.json补齐向量。
  5. MemoryVectorRecord增加contentHash,为后续编辑、迁移、重建索引做准备。
  6. LongTermMemory.store()JsonMemoryVector.search()OllamaEmbeddingClient.embed()补单元测试。
  7. 根据真实使用结果调整COS_SIMILARITY_SCOREDEFAULT_MEMORY_MIN_SCORE
  8. 后续代码阅读 RAG 可以复用这一套思想,但代码 RAG 应该加入关键词检索、文件路径、符号名、行号等结构化信息,不能只靠向量。