AI 聊天长期记忆的向量化处理:从"关键词匹配"到"换种说法也能找到"

本文基于 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 的 texttags 向量化存到 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 文档里还有 sourceConversationIdreasonconfidence 等不该参与语义搜索的字段。显式声明 ['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 做全文搜索------这意味着 sourceConversationIdreason 等不该参与搜索的字段也会被命中,直接绕过了字段白名单。这一版只走 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 条,聊天继续
}

三层降级保护

  1. 超时保护withSemanticTimeout() 用 Promise 竞速实现 1500ms 超时。超时后 reject,不会无限等待。
  2. 异常兜底:embedding API 挂了、pgvector 查询出错、Store 连接断开------任何异常都被 catch,返回空数组。
  3. 上层二次兜底chat-orchestrator.tsresolveUserMemoryContextMessages() 也有独立的 try/catch,同样返回空数组。

日志脱敏 :失败日志只记录事件名、provider 类型、搜索模式、错误类别和耗时。绝不记录 raw query 文本、raw UserMemory 文本、embedding 向量或 provider 原始响应。这些数据一旦进入日志,就可能被持久化、被监控系统采集、被错误地展示给用户。

降级后的效果:0 条 UserMemory 注入 → 普通聊天继续 → 流式输出不受影响 → ThreadState 不受影响。对用户来说,就是"这次没有用到长期记忆",和没开这个功能一样。


5. 总结

以上拆了三条链路:向量写入向量召回失败降级。三条链路拼在一起,就是这套语义召回能力的完整画像。

写入链路的核心决策是"只索引该索引的"------texttags 进向量索引,其余字段只做过滤元信息。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⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。

相关推荐
dong_junshuai11 小时前
每天一个开源项目#99 OpenResearch:2.2K星的本地研究Agent工作台
开源·github·agent
花椒技术12 小时前
把 AI 视频接进直播:提前生成、按次生成、持续生成怎么选?
agent·音视频开发
用户80825986668712 小时前
用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管
agent
能不能静下心来看12 小时前
从 RAG 到 Agent:跑通两个 Demo 后,我终于分清了这两个词
agent
ZGi.ai12 小时前
知识库权限变了,怎样避免答错?
agent·权限管理·知识库·工作流·zgi·客服运营
大模型真好玩12 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
掰头战士12 小时前
MCP、Skill、Plugin,都是给agent拓展能力,到底有何区别?
typescript·llm·agent
嘟嘟嘟952714 小时前
AI Agent 的边缘困境
人工智能·架构·agent
HYDtomako14 小时前
Agent checkpoint设计
ai·agent·checkpoint·claude code
梦在远山后14 小时前
从需求到架构:我的企业研发 Agent 整体设计
langchain·agent