本文基于
AI Mind项目的真实实现整理。GitHub:github.com/HWYD/ai-min...
对应代码版本:
v0.4.6线上链接:ai.hwyblog.cloud/instant-min...
AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。
如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。


一个 AI Chat 如果只能靠关键词匹配来找回用户的长期偏好,那大概率会翻车------用户换个说法,系统就认不出来了。
举个例子。用户说过"记住我不吃香菜",后来问"最近肠胃不舒服,推荐点好消化的"------"肠胃""消化"跟"香菜""忌口"在词面上完全没有交集,规则匹配直接抓瞎。再比如回答风格偏好:用户说"技术解释先用大白话,再补充专业说法",下次问"LangGraph Store 是什么?别讲太抽象"------"大白话"和"别讲太抽象"在中文里语义是通的,但词面毫无重叠,照样找不到。
问题的本质很简单:用户换一种说法,系统就找不到之前存的记忆了。
这一版做的事也很直接------给每条长期记忆加上向量索引,让语义相近的文本能被自动召回 。核心手段:把每条 UserMemory 的 text 和 tags 向量化存到 pgvector,用户提问时把问题也向量化,用余弦距离找最相似的记忆。写入时 PostgresStore 自动调豆包 embedding 模型生成向量,搜索时 pgvector 做 ANN 检索。整条链路配上 1500ms 超时和失败降级,语义召回不会成为聊天的单点故障。
接下来从向量写入、向量召回、失败降级三个环节,把整条链路拆开看。
几个关键概念先交代:
- UserMemory:当前 browser session 范围内、跨会话可复用的长期用户记忆,比如"用户不吃香菜""技术解释先用大白话再专业"
- PostgresStore:LangGraph 提供的 PostgreSQL 存储后端,这一版用它内置的 pgvector 能力做向量索引
- embedding:把文本转成浮点数数组(向量)的过程,语义相近的文本向量也相近
1. 整体架构:向量化嵌在哪
向量能力落在现有 user-memory 模块内部,新增和改动集中在三个文件:
doubao-embeddings.ts(新增) → 文本→向量的翻译器,调豆包 embedding API
provider.ts(改动) → PostgresStore 创建时加上向量索引配置
retrieval.ts(大幅改动) → 向量搜索 + 多级过滤的召回流水线
整条链路是:
scss
[写入] 模型提取候选记忆 → 程序校验 → store.put(value, ['text','tags'])
↓
PostgresStore 内部调 embedDocuments() 生成向量
↓
存入 pgvector
[召回] 用户输入 → 裁剪到 800 字符 → store.search(mode:'vector', query)
↓
PostgresStore 内部调 embedQuery() 生成向量
↓
pgvector ANN 搜索 → top-8 候选
↓
6 层过滤 → 最多 3 条注入模型
2. 向量写入流程:记忆如何进入向量存储
2.1 先建向量索引
写入的前提是 Store 本身具备向量能力。在 provider.ts(PostgresStore 配置与实例管理)里,创建 PostgresStore 时多了一个 index 配置:
typescript
PostgresStore.fromConnString(connectionString, {
index: {
dims: dimensions, // 向量维度,由 doubao-embedding-vision 决定
distanceMetric: 'cosine', // 余弦距离
embed: doubaoEmbeddings, // embedding 模型实例
fields: ['text', 'tags'], // 只对这两个字段建向量索引
},
schema: 'langgraph_user_memory',
})
四个参数各自有明确的职责:
dims:pgvector 列的维度,必须和doubao-embedding-vision的输出一致。这个值通过环境变量AI_MIND_USER_MEMORY_EMBEDDING_DIMENSIONS传入,找不到直接启动失败------维度不对,向量索引建了也白建。distanceMetric: 'cosine':余弦距离。两个向量方向越接近,分数越高。对中文语义相似度来说,余弦距离是社区验证过的默认选择。embed:一个DoubaoEmbeddings实例。PostgresStore 在写入和搜索时,内部自动调它的embedDocuments()和embedQuery()方法,不需要手动管理向量化过程。fields: ['text', 'tags']:这是最关键的安全边界。PostgresStore 默认可以索引整个 JSON 文档(fields: ['$']),但 UserMemory 文档里还有sourceConversationId、reason、confidence等不该参与语义搜索的字段。显式声明['text', 'tags']意味着只有这两个字段的内容会被向量化,其余字段只作为过滤和排序元信息。
2.2 写入时决定是否建索引
store.put() 的第三个参数控制是否建向量索引:
typescript
await store.put(
namespace, // ['ai-mind', 'user-memory', 'v1', '<hash>']
stableKey, // 文档 key
toStoredValue(nextDocument), // 完整 UserMemoryDocument JSON
nextDocument.status === 'active' // 第三个参数:index 配置
? ['text', 'tags'] // active → 建向量索引
: false // inactive/suppressed → 不建索引
)
只有 active 状态的记忆才传 ['text', 'tags']。被 suppressed 或 inactive 的记忆传 false------即使之前被索引过,向量也不会再更新。这个设计保证了:只有当前活跃、经过校验的长期记忆才会进入语义搜索的候选池。
2.3 写入时附带 semantic 元数据
除了向量索引,每条 active 记忆的文档内部还会写入一份 semantic 元数据:
json
{
"semantic": {
"embeddingModelId": "doubao-embedding-vision",
"embeddingProviderKind": "volcengine-ark-doubao-openai-compatible",
"semanticIndexFields": ["text", "tags"],
"semanticIndexedAt": "2026-07-10T12:00:00.000Z",
"semanticIndexVersion": "user-memory-semantic.v3"
}
}
这份元数据是持久化在文档里的,不是内存中的临时状态。它的作用是:召回时检查这条记忆是用哪个 embedding 模型建的索引、索引版本是否匹配。如果模型升级或索引版本变了,旧的向量索引就不再被信任------直接过滤掉,避免跨版本语义漂移。
2.4 embedding 模型:复用的 API Key,独立的模型
向量化引擎是 doubao-embeddings.ts(豆包 Embedding 客户端),继承 LangChain 的 Embeddings 基类。它做的事极其简单------POST 到火山引擎的 /embeddings 接口,把文本数组变成浮点数数组:
typescript
// 请求体
{
model: 'doubao-embedding-vision',
input: texts, // 待向量化的文本
dimensions: 1024, // 向量维度
encoding_format: 'float' // 返回 float32
}
返回之后逐条校验:是不是数组、是不是数字、维度对不对。任何一项不满足就直接抛错,由上层 catch 做降级处理。
API Key 和 baseURL 复用了项目已有的 Doubao 聊天模型配置,但 model id 固定为 doubao-embedding-vision。这意味着:**用户在聊天界面切换模型,不会影响 embedding 模型的选择。**语义召回的质量是独立且稳定的。
3. 向量召回流程:用户提问时如何找回相关记忆
3.1 触发时机
语义召回不是每次聊天都触发。chat-orchestrator.ts(聊天主链的阶段编排和策略判断)在 run() 方法里先判断:
typescript
// 只有 ordinary_chat 和 tool_assisted_ordinary_chat 才触发
this.userMemoryContextMessages = isUserMemoryContextEligibleRequest(this.request)
? await this.resolveUserMemoryContextMessages(
session.toolBoundModel ? 'tool_assisted_ordinary_chat' : 'ordinary_chat'
)
: []
被排除的路径:Tasklist Agent、Delivery Chain、hydration(前端页面加载时恢复会话状态)、sidebar 会话列表加载、conversation 切换。这些路径不需要长期记忆补充,触发语义召回纯属浪费 embedding API 调用。
3.2 query 规范化:裁剪,不改写
用户输入作为 retrieval query 之前,只做确定性处理,不做 LLM 改写:
typescript
function normalizeSemanticQuery(latestUserText: string, config): string {
const normalized = normalizeWhitespace(latestUserText) // trim + 折叠多余空白
if (normalized.length <= 800) {
return normalized // 不超过 800 字符,直接返回
}
// 超了:保留前 400 字符 + 后 400 字符
const head = normalized.slice(0, 400)
const tail = normalized.slice(-400)
return `${head}${tail}`.slice(0, 800)
}
两个关键决策:
- 不做 LLM query rewrite。不用模型把"别讲太抽象"改写成"用简单语言解释"再搜索。多一次 LLM 调用就多一次延迟和成本,而且改写可能引入歧义。直接拿用户原话搜,让 embedding 模型自己处理语义泛化。
- 超长截断取前 400 + 后 400。用户有可能把关键信息放在末尾------"对了,我不吃香菜"------只取前 800 就丢了。前后各取一半是个折中策略。
3.3 向量搜索:一次调用,top-8 候选
规范化后的 query 交给 PostgresStore 做向量搜索:
typescript
const items = await store.search(namespace, {
limit: 8, // 只取 top-8
mode: 'vector', // 只做向量搜索,不用 hybrid/text
query: normalizedQuery, // 已裁剪到 800 字符的 query
})
PostgresStore 内部自动完成三件事:调 embed.embedQuery(query) 把 query 转成向量 → 在 pgvector 里做 ANN(近似最近邻)搜索 → 返回最相似的 8 条,每条带一个 score(余弦相似度分数)。
为什么不用 hybrid search? PostgresStore 的 hybrid 模式会同时对 store.value 的完整 JSON 做全文搜索------这意味着 sourceConversationId、reason 等不该参与搜索的字段也会被命中,直接绕过了字段白名单。这一版只走 vector 模式。
为什么 topK=8? 这是个"够用且不浪费"的数字。browser-session 级的内存里通常只有几十条 UserMemory,8 条候选足够覆盖大部分相关记忆。再多的候选不会提升召回质量,反而增加后续过滤的计算量。
3.4 召回后的过滤:6 层安检
向量搜索返回的 8 条候选不是直接注入。在 retrieval.ts(语义召回核心流水线)里,toVectorSemanticCandidates() 对它们执行 6 层过滤:
| 层 | 过滤条件 | 滤掉什么 |
|---|---|---|
| 1 | isUserMemorySemanticEligible |
status≠active、confidence<0.7、semantic 元数据不匹配 |
| 2 | score 合法性 | score 缺失、NaN、负数、大于 1 |
| 3 | score 阈值 | score < 0.32(本版基于 doubao-embedding-vision 校准) |
| 4 | 冲突检测 | 用户当前输入和记忆 polarity 冲突(如"不吃香菜"vs"想吃香菜") |
| 5 | stableKey 去重 | 同 key 只保留 score 更高或 updatedAt 更新的 |
| 6 | conflict handling | type+subject+facet 相同的冲突记忆,保留更新的 |
score 阈值 0.32 是怎么来的? 不是拍脑袋定的。它基于 doubao-embedding-vision 在本版中文 UserMemory 场景下的真实分数分布校准得出。语义相关的记忆通常落在 0.4 到 0.7 之间,不相关的在 0.1 到 0.2 之间。0.32 是一道保守的分界线------宁可少召回,也不乱注入。
第 4 层的冲突检测值得展开。假设用户记忆是"喜欢吃桃子"(polarity=prefer),但当前输入里出现了"不吃""不要""别"等否定词,这条记忆就不注入。反过来,记忆是"不吃香菜"(polarity=avoid),当前输入里出现了"想吃""可以吃",同样不注入。当前用户输入永远优先于长期记忆。
3.5 最终选择:预算控制
过滤完的候选按 score 降序排列,进入 selectFromSemanticCandidates() 做最终截取:
typescript
function selectFromSemanticCandidates(candidates, config): SelectedUserMemory[] {
const selected = []
let totalChars = 0
for (const candidate of candidates) {
if (selected.length >= 3 || totalChars >= 900) break // 硬上限
const text = clipUserMemoryText(candidate.document.text, 300) // 每条 ≤300 字
if (!text || totalChars + text.length > 900) continue
selected.push({ score, stableKey, tags, text, type })
totalChars += text.length
}
return selected
}
三条硬限制:最多 3 条、单条 300 字符、总计 900 字符。这个限制不仅控制 token 消耗,也防止过多长期记忆喧宾夺主------当前会话的短期上下文(summary、pinnedDecisions、recent messages)才是回答的主要依据。
4. 失败降级流程:语义召回不能成为单点故障
这是 v0.4.6 的一条硬底线。retrieveRelevantUserMemories() 的整个执行体被 try/catch 兜底:
typescript
try {
const searchItems = await withSemanticTimeout(
vectorSemanticSearch(store, namespace, normalizedQuery, input.limit),
input.timeoutMs // 1500ms
)
const candidates = toVectorSemanticCandidates(searchItems, input.latestUserText, config)
return selectFromSemanticCandidates(candidates, config)
} catch (error) {
// 脱敏日志:只记事件名、provider 类型、搜索模式、错误类别和耗时
logUserMemoryRetrievalEvent('semantic-retrieval-degraded', {
degradationKind: error.message === 'USER_MEMORY_SEMANTIC_TIMEOUT' ? 'timeout' : 'failure',
providerKind: config.semanticEmbeddingProviderKind,
searchMode: 'vector',
errorName: error instanceof Error ? error.name : 'UnknownError',
})
return [] // 降级为 0 条,聊天继续
}
三层降级保护:
- 超时保护 :
withSemanticTimeout()用 Promise 竞速实现 1500ms 超时。超时后 reject,不会无限等待。 - 异常兜底:embedding API 挂了、pgvector 查询出错、Store 连接断开------任何异常都被 catch,返回空数组。
- 上层二次兜底 :
chat-orchestrator.ts里resolveUserMemoryContextMessages()也有独立的 try/catch,同样返回空数组。
日志脱敏 :失败日志只记录事件名、provider 类型、搜索模式、错误类别和耗时。绝不记录 raw query 文本、raw UserMemory 文本、embedding 向量或 provider 原始响应。这些数据一旦进入日志,就可能被持久化、被监控系统采集、被错误地展示给用户。
降级后的效果:0 条 UserMemory 注入 → 普通聊天继续 → 流式输出不受影响 → ThreadState 不受影响。对用户来说,就是"这次没有用到长期记忆",和没开这个功能一样。
5. 总结
以上拆了三条链路:向量写入 、向量召回 、失败降级。三条链路拼在一起,就是这套语义召回能力的完整画像。
写入链路的核心决策是"只索引该索引的"------text 和 tags 进向量索引,其余字段只做过滤元信息。store.put() 的第三个参数控制是否建索引,active 记忆传 ['text', 'tags'],suppressed 记忆传 false。PostgresStore 内部自动调 embedding 模型生成向量,不用手动管理 pgvector 表。
召回链路的核心决策是"宁可少召回,也不乱注入"------800 字符裁剪、top-8 候选、0.32 的 score 阈值、6 层过滤链,每一步都在做减法。最终注入模型的记忆最多 3 条、总计 900 字符,作为补充上下文而不是权威答案。
降级链路的核心决策是"语义召回不能成为单点故障"------1500ms 超时、异常兜底、日志脱敏、两层 try/catch 保护。任何环节出错,聊天照常继续,只是这次没有长期记忆补充。
给长期记忆加向量索引,本质上就是让系统在用户换一种说法时也能找到之前存的偏好。技术手段不复杂,复杂的是把边界守好------哪些字段进索引、哪些路径触发召回、失败了怎么降级。正是这些边界,决定了这套能力是"稳定可用"还是"时不时出问题"。
下一步自然的方向是 hybrid 召回(语义 + 关键词互补)、Memory Inspector UI、账号级记忆。但每一版都只往前推一步,每一步都先保证不破坏已有的稳定性。
项目地址
👉 GitHub:github.com/HWYD/ai-min...
👉 线上体验:ai.hwyblog.cloud/instant-min...
如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。