
本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.4.6线上体验https://ai.hwyblog.cloud/instant-mindAI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。一个 AI Chat 如果只能靠关键词匹配来找回用户的长期偏好那大概率会翻车——用户换个说法系统就认不出来了。举个例子。用户说过记住我不吃香菜后来问最近肠胃不舒服推荐点好消化的——“肠胃”“消化跟香菜”“忌口在词面上完全没有交集规则匹配直接抓瞎。再比如回答风格偏好用户说技术解释先用大白话再补充专业说法”下次问LangGraph Store 是什么别讲太抽象——大白话和别讲太抽象在中文里语义是通的但词面毫无重叠照样找不到。问题的本质很简单用户换一种说法系统就找不到之前存的记忆了。这一版做的事也很直接——给每条长期记忆加上向量索引让语义相近的文本能被自动召回。核心手段把每条 UserMemory 的text和tags向量化存到 pgvector用户提问时把问题也向量化用余弦距离找最相似的记忆。写入时 PostgresStore 自动调豆包 embedding 模型生成向量搜索时 pgvector 做 ANN 检索。整条链路配上 1500ms 超时和失败降级语义召回不会成为聊天的单点故障。接下来从向量写入、向量召回、失败降级三个环节把整条链路拆开看。几个关键概念先交代UserMemory当前 browser session 范围内、跨会话可复用的长期用户记忆比如用户不吃香菜“技术解释先用大白话再专业”PostgresStoreLangGraph 提供的 PostgreSQL 存储后端这一版用它内置的 pgvector 能力做向量索引embedding把文本转成浮点数数组向量的过程语义相近的文本向量也相近1. 整体架构向量化嵌在哪向量能力落在现有user-memory模块内部新增和改动集中在三个文件doubao-embeddings.ts新增 → 文本→向量的翻译器调豆包 embedding API provider.ts改动 → PostgresStore 创建时加上向量索引配置 retrieval.ts大幅改动 → 向量搜索 多级过滤的召回流水线整条链路是[写入] 模型提取候选记忆 → 程序校验 → 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.tsPostgresStore 配置与实例管理里创建 PostgresStore 时多了一个index配置PostgresStore.fromConnString(connectionString,{index:{dims:dimensions,// 向量维度由 doubao-embedding-vision 决定distanceMetric:cosine,// 余弦距离embed:doubaoEmbeddings,// embedding 模型实例fields:[text,tags],// 只对这两个字段建向量索引},schema:langgraph_user_memory,})四个参数各自有明确的职责dimspgvector 列的维度必须和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()的第三个参数控制是否建向量索引awaitstore.put(namespace,// [ai-mind, user-memory, v1, hash]stableKey,// 文档 keytoStoredValue(nextDocument),// 完整 UserMemoryDocument JSONnextDocument.statusactive// 第三个参数index 配置?[text,tags]// active → 建向量索引:false// inactive/suppressed → 不建索引)只有active状态的记忆才传[text, tags]。被 suppressed 或 inactive 的记忆传false——即使之前被索引过向量也不会再更新。这个设计保证了只有当前活跃、经过校验的长期记忆才会进入语义搜索的候选池。2.3 写入时附带 semantic 元数据除了向量索引每条 active 记忆的文档内部还会写入一份semantic元数据{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接口把文本数组变成浮点数数组// 请求体{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()方法里先判断// 只有 ordinary_chat 和 tool_assisted_ordinary_chat 才触发this.userMemoryContextMessagesisUserMemoryContextEligibleRequest(this.request)?awaitthis.resolveUserMemoryContextMessages(session.toolBoundModel?tool_assisted_ordinary_chat:ordinary_chat):[]被排除的路径Tasklist Agent、Delivery Chain、hydration前端页面加载时恢复会话状态、sidebar 会话列表加载、conversation 切换。这些路径不需要长期记忆补充触发语义召回纯属浪费 embedding API 调用。3.2 query 规范化裁剪不改写用户输入作为 retrieval query 之前只做确定性处理不做 LLM 改写functionnormalizeSemanticQuery(latestUserText:string,config):string{constnormalizednormalizeWhitespace(latestUserText)// trim 折叠多余空白if(normalized.length800){returnnormalized// 不超过 800 字符直接返回}// 超了保留前 400 字符 后 400 字符constheadnormalized.slice(0,400)consttailnormalized.slice(-400)return${head}${tail}.slice(0,800)}两个关键决策不做 LLM query rewrite。不用模型把别讲太抽象改写成用简单语言解释再搜索。多一次 LLM 调用就多一次延迟和成本而且改写可能引入歧义。直接拿用户原话搜让 embedding 模型自己处理语义泛化。超长截断取前 400 后 400。用户有可能把关键信息放在末尾——“对了我不吃香菜”——只取前 800 就丢了。前后各取一半是个折中策略。3.3 向量搜索一次调用top-8 候选规范化后的 query 交给 PostgresStore 做向量搜索constitemsawaitstore.search(namespace,{limit:8,// 只取 top-8mode:vector,// 只做向量搜索不用 hybrid/textquery:normalizedQuery,// 已裁剪到 800 字符的 query})PostgresStore 内部自动完成三件事调embed.embedQuery(query)把 query 转成向量 → 在 pgvector 里做 ANN近似最近邻搜索 → 返回最相似的 8 条每条带一个score余弦相似度分数。为什么不用 hybrid searchPostgresStore 的 hybrid 模式会同时对store.value的完整 JSON 做全文搜索——这意味着sourceConversationId、reason等不该参与搜索的字段也会被命中直接绕过了字段白名单。这一版只走 vector 模式。为什么 topK8这是个够用且不浪费的数字。browser-session 级的内存里通常只有几十条 UserMemory8 条候选足够覆盖大部分相关记忆。再多的候选不会提升召回质量反而增加后续过滤的计算量。3.4 召回后的过滤6 层安检向量搜索返回的 8 条候选不是直接注入。在retrieval.ts语义召回核心流水线里toVectorSemanticCandidates()对它们执行 6 层过滤层过滤条件滤掉什么1isUserMemorySemanticEligiblestatus≠active、confidence0.7、semantic 元数据不匹配2score 合法性score 缺失、NaN、负数、大于 13score 阈值score 0.32本版基于doubao-embedding-vision校准4冲突检测用户当前输入和记忆 polarity 冲突如不吃香菜vs想吃香菜5stableKey 去重同 key 只保留 score 更高或 updatedAt 更新的6conflict handlingtypesubjectfacet 相同的冲突记忆保留更新的score 阈值 0.32 是怎么来的不是拍脑袋定的。它基于doubao-embedding-vision在本版中文 UserMemory 场景下的真实分数分布校准得出。语义相关的记忆通常落在 0.4 到 0.7 之间不相关的在 0.1 到 0.2 之间。0.32 是一道保守的分界线——宁可少召回也不乱注入。第 4 层的冲突检测值得展开。假设用户记忆是喜欢吃桃子polarityprefer但当前输入里出现了不吃“不要”“别等否定词这条记忆就不注入。反过来记忆是不吃香菜”polarityavoid当前输入里出现了想吃“可以吃”同样不注入。当前用户输入永远优先于长期记忆。3.5 最终选择预算控制过滤完的候选按 score 降序排列进入selectFromSemanticCandidates()做最终截取functionselectFromSemanticCandidates(candidates,config):SelectedUserMemory[]{constselected[]lettotalChars0for(constcandidateofcandidates){if(selected.length3||totalChars900)break// 硬上限consttextclipUserMemoryText(candidate.document.text,300)// 每条 ≤300 字if(!text||totalCharstext.length900)continueselected.push({score,stableKey,tags,text,type})totalCharstext.length}returnselected}三条硬限制最多 3 条、单条 300 字符、总计 900 字符。这个限制不仅控制 token 消耗也防止过多长期记忆喧宾夺主——当前会话的短期上下文summary、pinnedDecisions、recent messages才是回答的主要依据。4. 失败降级流程语义召回不能成为单点故障这是 v0.4.6 的一条硬底线。retrieveRelevantUserMemories()的整个执行体被 try/catch 兜底try{constsearchItemsawaitwithSemanticTimeout(vectorSemanticSearch(store,namespace,normalizedQuery,input.limit),input.timeoutMs// 1500ms)constcandidatestoVectorSemanticCandidates(searchItems,input.latestUserText,config)returnselectFromSemanticCandidates(candidates,config)}catch(error){// 脱敏日志只记事件名、provider 类型、搜索模式、错误类别和耗时logUserMemoryRetrievalEvent(semantic-retrieval-degraded,{degradationKind:error.messageUSER_MEMORY_SEMANTIC_TIMEOUT?timeout:failure,providerKind:config.semanticEmbeddingProviderKind,searchMode:vector,errorName:errorinstanceofError?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、账号级记忆。但每一版都只往前推一步每一步都先保证不破坏已有的稳定性。项目地址 GitHubhttps://github.com/HWYD/ai-mind 线上体验https://ai.hwyblog.cloud/instant-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。