AI Agent 的记忆怎么查才准?从 Graphiti 讲透 BM25、Embedding、Graph Traversal 与混合检索
上周我在做一个客服类 AI Agent 时遇到一个很典型的翻车场景:用户在两天前问过“云存储套餐的涨价时间”,隔天换了个问法说“那个套餐啥时候调价来着”,结果 Agent 翻了半天记忆库,给了一句“我没有找到相关记录”。
数据明明存了,为什么查不到?这其实是几乎所有 AI Agent 在接入长期记忆后都会撞上的墙——你存了不等于你能查得到,能查得到不等于查得准。我后来把检索链路整体重做了一遍,核心方案是基于 Graphiti 的知识图谱结构,配合 BM25、Embedding 和 Graph Traversal 三类信号做混合召回。这篇文章就把这套“怎么查得准”的思路从头到尾讲透,包括每种检索方式的原理、它们的天然短板、以及最终融合排序时的关键细节,适合正在做 RAG、Agent 记忆系统、知识库问答的工程师参考。
1. 记忆的形态决定检索的下限:为什么文档切片不是全部
先想一个问题:AI Agent 的“记忆”到底应该长什么样?很多人第一反应是放进向量数据库,每次对话来了做相似度检索。这个思路本身没错,但它隐含了一个前提:记忆是一堆“能互相独立检索的文本块”。这个前提在很多真实业务里并不成立。
1.1 文档切片丢掉的恰恰是最重要的关系
假设你的 Agent 要记住一个客户的完整档案:客户姓张,做跨境电商,上个月投诉过海外仓配送延迟,前天下单了一个智能货架项目,备注里写“预算 50 万以内,希望下周五之前收到方案”。
如果你把这些信息简单切片成几段文字丢进向量库,检索时会发生什么?用户问“张先生那个智能货架方案做到哪一步了”,向量检索确实有可能把“下单了智能货架项目”这段捞出来,但如果用户问“张先生最近一次投诉跟物流有关系吗”,那就麻烦了——因为“投诉”和“物流”这两段信息在文本层面可能相距很远,向量距离也更远,它根本拉不上关系。
但这事儿对一个人来说太简单了:我们脑子里存的是一个“张先生”节点,下面挂着物流投诉、智能货架、预算 50 万这些事件节点,彼此之间还能再关联。这就是记忆形态的问题:文本切片是一种扁平结构,而真实的记忆是图结构。
1.2 Graphiti 的做法:把记忆建成一张会生长的图
Graphiti 的核心思路就是放弃“切片文本”这个基本单位,改用以实体为中心的知识图谱。它的构建过程大致是:Agent 每收到一段新信息,就用 LLM 抽取其中的实体(人物、公司、产品、项目、事件)和关系(投诉过、下单了、预算是多少),然后把这些实体作为节点、关系作为边,增量写入图里。
关键是这个“增量”两个字。Graphiti 不是一次性把整个知识库建好,而是跟着对话不断地把新实体挂到已有节点上。如果用户说“那个智能货架项目,张总说预算涨到 60 万了”,系统会找到已有的“智能货架项目”节点,更新它的“预算”关系值,而不是新建一个孤立的文本块。
在这个形态下,记忆检索就不止是“这段文本跟那段文本像不像”,而是“这个实体跟那个实体有没有关系、路径通不通”。这是后面所有检索策略的地基。
1.3 为什么说“检索准不准”本质上是结构问题
我的观点是,Agent 记忆检索的准确率天花板是由数据形态决定的。在扁平文本结构下,你不管用什么花哨的检索算法,能解决的无非是“文本相似度”的问题;但记忆场景里大量查询问的是实体关系、事件脉络、时间先后,这些在扁平结构里根本没有显式表达,自然也就无法被精确检索。
Graphiti 把记忆建成知识图谱之后,检索就变成三个子问题:关键词能不能精确命中某个实体(BM25)、语义上是不是相近(Embedding)、图上能不能走通关系路径(Graph Traversal)。三个子问题的答案都拿到了,再把它们融合成一个最终排序。下面逐个讲。
2. BM25:精确匹配是检索质量的底线
先说 BM25。很多人一听 BM25 就觉得这是老古董,是 Elasticsearch 时代的玩意,在“AI Agent 记忆”这种听起来很新的场景里好像不太登得上台面。但实际上,在我测过的所有检索链路里,BM25 是最不起眼但最不能省的一环。
2.1 BM25 到底在算什么:词频和稀有度的平衡
BM25 本质上是一个给“文档—查询”算相关度分数的公式。核心是两个因素:一是查询里的词在文档里出现了多少次,二是这个词在整个语料里有多稀有。
公式长这样(我简化了):
score(D,Q) = Σ IDF(qi) · (f(qi,D) · (k1+1)) / (f(qi,D) + k1 · (1 − b + b · |D| / avgdl))
解释一下:f(qi,D) 是查询词在当前文档里的词频;IDF(qi) 是逆文档频率,出现的文档数越少,这个词越稀有,权重越高;分母里 |D|/avgdl 是文档相对长度的惩罚项,文档越长,单个词频的价值就越低。
生活化类比:你在图书馆找一本书,脑子里记得书名里有个生僻词,比如“鲸落”。如果一本书的标题里出现了“鲸落”,那基本就是它了。但如果你只记得一个常见词“海洋”,那光靠词频就不够,需要配合文档长度和整体语料分布来判断。
2.2 在 Agent 记忆场景里,哪类查询必须靠 BM25
我在实际使用中总结出一个很痛的规律:凡是涉及人名的、编号的、品牌名的、具体金额的查询,纯向量检索几乎必然翻车,而 BM25 往往一发命中。
还是拿开头的例子来说。用户问“张先生那个云存储涨价的事”,你猜用户真正想匹配的是什么?“张先生”三个字,还有可能带个“涨价”。这两个词在向量空间里很难被精确表达——Embedding 会把“张先生”和“the customer”“这位客户”“Mr. Zhang”混在一起,得到一个模糊的语义点。但 BM25 不管这些,它就是拿“张先生”去全库扫,凡是文本中出现过这三个字的记录都会被拉出来。
更典型的场景是工单号、订单号、SKU 编码。比如用户说“我之前问过 ORD-20241105 那个订单”,这种查询里 ORD-20241105 是一个绝对唯一的标识符,没有任何语义扩展空间。向量检索对这种字符串型 token 的处理非常无力,我见过太多因为 “ORD-20241105” 和 “ORD-20241106” 的 embedding 向量过于接近而抓到错误记录的案例。
2.3 BM25 的短板:它不理解“同一个意思换个说法”
BM25 最大的问题在于同义词和意译。用户记忆里存的是“云存储套餐要涨价”,下次用户问的是“那个调价公告什么时候生效”,BM25 直接傻眼,因为它只做字面匹配,“调价”和“涨价”是两个完全不同的 token。
还有一类更隐蔽的问题:BM25 是按词条独立打分的,它不理解否定和反转。比如记忆里说“张先生明确表示不需要物流服务”,用户问“张先生需要物流吗”,BM25 看“需要”和“物流”都命中了,会给出一个很高的分数,但它根本不知道原句其实是否定句。这类问题是词典式检索的共同天花板。
所以我的结论很明确:BM25 是你检索精度的底线和保险丝,它负责兜住那些“必须一字不差”的查询;但它扛不起整个检索链路,接下来需要语义信号补位。
3. Embedding:语义相似不等于事实正确
Embedding 检索解决的核心问题是“换了个说法依然能找到”。这套东西过去一两年被讲得非常多,但真正落地时大家都忽略了一点:语义相似它负责,事实正确它不负责。
3.1 Embedding 检索的直觉:把语义压缩进向量空间
Embedding 干的事情可以这样理解:把一个句子映射成高维空间里的一个点,让语义相近的句子点与点之间的距离更近。“涨价了”和“调价了”在向量空间里的距离是近的,所以即使字面完全不同,向量检索也能把它们拉在一起。这也是为什么 Embedding 能补 BM25 的短板。
实际工程里常用的方案是用 HNSW 这类近似最近邻索引,在千万级的向量里也能做到毫秒级召回。对 Agent 记忆这种规模(通常撑死几百万条事实),向量检索的延迟完全不是问题。
3.2 我踩过的坑:Embedding 会把“语义相近”当成“事实相同”
这里想重点展开一个反直觉现象。我曾经在项目里遇到一个问题:用户的记忆库里有两条记录,一条是“客户 A 说预算 30 万”,另一条是“客户 B 说预算 300 万”。用户问“客户 A 预算多少”,向量检索同时召回了这两条,而且给出的相似度分数差不多。原因不复杂——这两句话的结构、语义、关键词几乎完全一样,唯一的区别是数字大小,而 embedding 模型对数字的敏感度非常有限。
更夸张一点的例子:客户投诉说“配送太慢,三天才到”,另一条记录是“确认收货,三天到货,很满意”。这两条在语义上也是高度接近的,因为核心词都是“配送”“三天”“到货”,但事实方向完全相反。向量检索根本分不清。
这不是 embedding 模型的 bug,而是它的固有特性:它建模的是“语义相似度”,不是“事实一致性”。当你只靠 embedding 做召回时,检索结果里会出现大量“看着很像但事实上不对”的内容。这在知识库问答里非常危险,因为模型很容易把语义相似的内容当成事实依据,然后一本正经地瞎说。
3.3 长文本被平均稀释:切片的另一重伤害
还有一个细节值得展开:embedding 对长文本的表示质量会急剧下降。一段包含五六个事实的记忆片段,embedding 会把整段语义压缩成一个点,最后这个点的位置往往被“平均”了,每个具体事实都变得模糊。
我习惯用“鸡尾酒”类比:向量模型把一杯包含橙汁、伏特加和薄荷叶的鸡尾酒打成泥,告诉你“这是鸡尾酒的味道”,但你想找的是“杯子里有一片薄荷叶”。而 Graphiti 把知识图谱拆成以实体为中心的小型事实节点之后,每条事实相对短小,embedding 的表达精度就高很多。这其实是知识图谱对 embedding 检索的一个隐性增益——它不是换了一种检索方式,而是让“被检索的单元”变得更干净了。
所以我的经验是:embedding 是不可或缺的语义召回手段,但它给出的召回结果只能算是“候选集”,绝对不能直接当作最终答案。它的定位是把你带到“大概正确的地方”,最终准不准,还要靠其他信号来确定。
4. Graph Traversal:顺着关系把零散记忆串成网
前面 BM25 和 Embedding 都是“文本匹配”的思路,Graph Traversal 是完全不同的维度——它不做相似度,它走图。这也是 Graphiti 和其他记忆框架最明显的区别。
4.1 图遍历解决的问题:跨实体把信息串起来
很多记忆查询本质上不是相似度查询,而是“路径查询”。举个例子:
- 记忆库里有:实体“智能货架项目”,关系“负责人是张先生”,关系“预算 50 万”
- 还有一条:实体“张先生”,关系“上个月投诉了物流配送延迟”
- 用户问:张先生那个项目的预算和最近的投诉有联系吗?
这个问题如果你用 BM25 或 Embedding,你搜“预算”“投诉”“物流”,得到的结果是几个孤立的文本片段,它们不会告诉你“项目”和“投诉”之间的路径是:智能货架项目 —(负责人)—> 张先生 —(投诉过)—> 物流配送。
Graph Traversal 做的就是这件事:从“智能货架项目”节点出发,沿着边走到“张先生”,再沿着边走到“物流配送延迟”这个事件节点,一条路径走下来,整个关系的脉络就全出来了。这种检索能力和文本匹配是互补的,它擅长回答“谁和谁有关系”“这件事牵涉到哪些人”“这个客户的完整历史是什么”这类结构性很强的问题。
4.2 Graphiti 图结构里的两类边,决定了检索的边界
Graphiti 的知识图谱里,边的类型大致分两种。一种是时间性边,连接的是某个实体和一个具体发生事件,比如“张先生—投诉过—物流延迟(10月12日)”,这种边会带上时间信息,检索时可以用时间衰减来控制陈旧信息的权重。另一种是永久性关系边,连接两个实体之间的固定关系,比如“智能货架项目—负责人—张先生”,这种边不随时间消失。
这两种边的分野有个很实际的工程意义:用户在问“张先生最近怎么样”的时候,系统应该优先走时间性边,把最近的事件捞出来,而不是翻出三年前的旧记录;用户在问“张先生负责哪些项目”的时候,系统应该走关系边,把项目列表拉全。
如果知识库里只有文本切片,你是没办法做这种“按时间衰减优先”和“按实体关系列举”的区分检索的。这又回到了第一节的结论:存储结构决定检索能力上限。
4.3 图遍历的实际操作流程:扩展一跳到再跳
Graphiti 的检索不是简单地在图里跑一次 BFS 然后拿所有节点当结果,而是有一套比较工程化的流程,大致四步:
- 先用 embedding 做一次语义检索,把种子实体找出来。比如用户问“张先生的预算调整”,embedding 首先命中“张先生”和“智能货架项目”这两个节点,作为图遍历的起点。
- 从种子节点开始做图扩展,沿着一跳到二跳的邻居方向展开。这个扩展过程会带回一批新的事实节点和关系路径。
- 对扩展回来的节点做去重和评分,常用手段是余弦相似度 + 节点权重组合。
- 把最终扩展得到的一组事实节点跟初始语义检索的结果合并,作为总候选集。
这套流程好在哪里?它把“文本相似”和“结构关联”融合到了一个召回路里——既保留了 embedding 的语义捕捉能力,又通过图扩展把 embedding 搜不到的相关实体一起带了回来。
需要特别提醒:图扩展深度不要拍脑袋设太大。深度为 1 的扩展会把种子节点直接相邻的事实全部捞出来,信息量已经很大了;深度到 2 就很容易捞到“朋友的朋友”这种弱相关;深度 3 以后,几乎整个连通分量都会被拉出来,结果里全是噪声。
5. 混合检索:三类信号如何融合成同一个排序
前面讲了三种检索信号各自能干什么、不能干什么。真正让 Graphiti 的“记忆查询”变准的,是它怎么把 BM25、Embedding、Graph Traversal 的信号融合成一个最终排序。
5.1 候选集合并,再统一打分
混合检索的第一件事不是“融合分数”,而是先把各部门的召回结果合并成一个候选集。BM25 召回一批,Embedding 召回一批,Graph Traversal 再扩展一批,三批结果放到同一个池子里,去重之后统一打分。
这时有一个工程细节:每个来源的分数尺度完全不一样。BM25 的分数可能是 0 到十几的绝对数值,Embedding 的余弦相似度是 -1 到 1,Graph 扩展的得分又可能是基于路径长度和节点权重的自定义指标。你不能直接把这三个分数加在一起,那样等于让数值大的那一路信号完全霸榜。
我见过不少团队的初版融合方案都是直接把分数归一化到 0-1 然后加权求和,效果还行,但有个隐藏问题:归一化假设了每个来源的分数分布是相对均匀的,实际并不一定如此。BM25 的分数长尾很严重,少数文档分数突出;Embedding 的分数则集中在 0.7-0.95 这个小窗口里。这时候直接用线性归一化加权,反而会放大 BM25 的波动、压抑 embedding 的区分度。
5.2 RRF 和加权求和,哪个更靠谱
业界常用的一个稳健方案是 Reciprocal Rank Fusion(RRF),简单说就是不看绝对分数,只看每条结果在各自来源中的排名,然后把排名的倒数相加:
score_final = Σ 1 / (k + rank_i)
这个方法的好处是不需要管每个来源的分数分布长什么样,只关心“它在自己的召回列表里排第几”,天然免疫量纲差异。我团队里线上跑的效果是,RRF 比直接归一化加权在大多数查询上都要稳,尤其是 BM25 和 Embedding 分数分布差异很大的场景。
但 RRF 也有它的盲区:排名信息丢掉了来源的置信度。比如某条结果在 BM25 里排名第一,但 BM25 整体对这个查询的置信度本来就不高;另一条结果在 embedding 里排名第五,但 embedding 对这个查询的把握很强。RRF 只看排名,两者会近似的看待。所以更精细的做法是加权 RRF,给不同来源不同的权重系数。
Graphiti 在实际设计中,对最终排序还加入了另外两个因素:时间衰减和实体/事实的固有权威度。时间衰减就是前面提到的时间性边,越久远的事实分数越低;固有权威度则来自长期反复出现的实体——如果一个客户反复出现在对话历史里,那跟这个客户相关的事实整体权重会更高。这个思路很像人脑里“经常被想起的事记得更牢”的机制。
5.3 我建议的最终融合策略
根据我自己的实验,最终融合策略可以做成这样一段伪代码逻辑:
candidates = {} candidates.update(bm25_search(query)) candidates.update(embedding_search(query)) candidates.update(graph_expansion(seed_entities)) for fact in candidates: score = ( w_bm25 * rrf_bm25_score(fact) + w_embedding * rrf_embedding_score(fact) + w_graph * graph_path_score(fact) ) score *= time_decay_factor(fact['created_at']) score *= entity_authority_score(fact['entities']) sorted_facts = sort(candidates, key=lambda f: f['score'], reverse=True)写这段代码是为了展示一个三层结构:第一层是三类信号的 RRF 排名融合,第二层是时间衰减,第三层是实体权威度。你可以把这三层拆开来单独调参,而不是搞成一个端到端的黑盒。哪一层出问题了也容易定位,这是工程可维护性的关键。
6. Graphiti 落地配置与我的踩坑经验
讲完了原理,聊点实操。Graphiti 的实际部署过程其实有不少细节,文档未必会提醒你,但踩过一次就印象深刻。
6.1 环境准备和核心配置项
Graphiti 走的是“知识图谱存储 + LLM 抽取 + 检索融合”的组合架构,所以环境准备的主要工作包括:图数据库(Graphiti 可以适配 neo4j 或者其他图存储),实体抽取和关系构建用的 LLM 客户端,以及一个 embedding 模型服务。
核心配置项大致如下:
- entity_extraction_model:用哪个模型做实体和关系的抽取,通常需要一个推理能力强的模型,因为对话中的隐式关系(比如“他后来又说”指向某人)对抽取模型要求很高。
- embedding_model:语义检索用的向量模型,这里维度要和图索引配置对齐,用 OpenAI 的 1536 维或者开源的 768/1024 维都可以。
- time_decay:时间衰减强度,控制“多久之前的信息算过时”,取值范围 0-1,越大衰减越快。
- max_search_depth:图遍历最大深度,我建议从 2 开始跑,实际调优时再决定是否加深。
- cross_encoder_rerank:最终排序是否接一个交叉编码器重排模型。接了之后效果通常好一截,但延迟也会上去。
6.2 一个可参考的最小接入流程
Graphiti 的接入大致可以抽象成三步“建档—投喂—检索”。建档是指先完成图索引和检索索引的初始化,投喂是把历史对话或文档逐条 add_episode 进去,检索就是调用 search 接口拿到排序结果。
from graphiti import Graphiti graphiti = Graphiti( kg_client=kg_client, config=GraphitiConfig( entity_extraction_model="gpt-4o", embedding_dim=1536, time_decay=0.3, ) ) # 1. 初始化索引 graphiti.build_indices() # 2. 投喂历史对话 graphiti.add_episode( "10月12日,张先生投诉海外仓配送延迟,要求48小时内解决。" ) graphiti.add_episode( "10月14日,张先生下单智能货架项目,预算50万,下周五前要方案。" ) # 3. 检索 results = graphiti.search( "张先生那个货架项目的预算以及他最近的物流问题", max_results=20, ) for r in results: print(r.fact, r.score)这里想强调一下 add_episode 的名字本身就很有意义——Graphiti 把一个对话回合、一段消息当作一个“Episode”投进图里,它会自动提取实体、建立时间性边、和已有节点做合并或去重。所以在接入历史数据时,你不用自己事先清洗成结构化的三元组,直接把原始文本丢进去就行。
6.3 我踩过的三个真实的坑
第一个坑:实体抽取模型不够强,把同一个实体拆成了两个节点。比如模型在第一条消息里识别出“张先生”,在第二条消息里识别出“张某”,如果它没有做共指消解,图里就会出现两个节点,导致图遍历时关系路径断掉,明明是一个人,查“张先生”却捞不到“张某”的信息。解决方案是在配置里用推理能力更强的模型做抽取,并且在构建图的过程中定期检查实体合并规则。
第二个坑:time_decay 配得太狠,把长期有价值的事实给“遗忘了”。我做客服 Agent 的时候一开始把 time_decay 调到 0.7,结果三个月前客户确认过的重要合同条款被压到了很低的分值,用户问起来的时候检索不到,Agent 直接说“没有相关信息”。后来把 time_decay 调到 0.2 左右,让长期事实相对稳定地保留在候选集里,短期事件通过时间边的新鲜度来排先后。建议从低往高调,不要一开始就设置一个很大的值。
第三个坑:图遍历深度设置为 3,结果每次检索的候选集都巨大,排序耗时严重超标,而且召回了一堆“朋友的朋友”级别的无关事实。后来我把默认深度改为 2,并且只对种子实体做有条件的三跳扩展。这里也可以加一条经验规则:三跳扩展只允许沿着“永久性关系边”走,不要沿着“时间性边”继续漫游,这样能大幅控制噪声。
6.4 一次真实对比测试的结果
我把自己项目里的客服 Agent 检索链路做过一次 A/B 对比,测试数据是一年内 2000 多轮客服对话和工单记录,查询集是 80 个真实用户问题,覆盖姓名、订单号、事件型、关系型四种查询。
单独只用 embedding 做召回,准确率大约 62%——主要翻车在姓名和订单号这类精确匹配查询上。加上 BM25 后,精确查询的命中率大幅改善,整体准确率升到 78%。在此基础上引入 Graph Traversal 扩展和相关事实的重排,准确率到了 88% 左右。这个数据不算惊艳,但它说明一个事情:混合检索每一步提升都是真实可测的,而且提升的象限不重叠。
最后:关于“记忆检索准不准”的两点真心话
第一点,所有检索算法都是在跟“表达的多义性”和“事实的唯一性”做斗争,没有哪种单一信号能同时赢下这两场仗。BM25 守住事实唯一性的底线,Embedding 扛起多义表达的召回,Graph Traversal 补上关系路径的结构性缺口,三者缺一不可。如果你现在做的 Agent 记忆检索只有 embedding,我建议第一步先加 BM25,成本最低、收益最直接。
第二点,混合检索的最终效果高度依赖图构建质量。检索做得好不好,很大程度取决于实体抽取、共指消解和关系建模有没有做好。我见过太多团队把精力全花在调检索参数上,结果瓶颈其实在实体被拆散、关系建错边这样的上游问题上。先用一批固定查询做回归集,每次调完图构建或检索配置都跑一遍对比,比凭感觉调参要靠谱得多。这也是 Graphiti 这套方案最值得借鉴的地方:它逼着你在存储结构、抽取质量和检索融合三个层面同时思考,而不是把“记忆”当成一个单纯的向量库问题来解决。