一个自研 memory graph 系统的 benchmark 结果最近被摆到了台面上:作者用自己实现的记忆图谱方案和 Memora 做同场评估,得到 0.831 vs 0.801。单看数字,领先只有 0.03,感觉不大,但这类系统的对比,真正的看点从来不在那一点分差,而在背后的整条链路——实体怎么抽、关系怎么存、记忆怎么召回、上下文怎么回填。分数只是链路质量的最终投影。
这篇文章不会替原作者公布他根本没有放出的源码,也不会把两个数字当成“谁强谁弱”的结论。更值得做的是把 memory graph 这类项目拆开,讲清楚它为什么会比普通向量记忆更耐测,以及如果你想在本地复现一套同类系统并跑出可信的 benchmark,应该准备什么、按什么流程验证、最容易在哪个环节翻车。看完之后,你至少能判断:0.831 和 0.801 这种对比,到底值不值得信。
先说明一个前提:本文所有代码和配置都是通用实现思路,不是某个具体开源仓库的原始内容。你可以把它当成一份“memory graph 复现与评测路线图”来用。
1. 先看 benchmark:0.831 vs 0.801 意味着什么
从标题可以提取三个关键事实:项目类型是 memory graph,对比对象是 Memora,评估结果是 0.831 对 0.801。如果按常见的记忆系统评估指标来理解,这个分数大概率是准确率、召回率或 F1 中的某一项,在 0 到 1 的区间内,0.80 以上已经属于“可用但仍有明显错误”的水平,0.83 则是略好一档。
先把领先幅度算清楚:
(0.831 - 0.801) / 0.801 ≈ 0.0375,即相对提升约 3.7%3.7% 的相对差距,在信息检索和对话记忆任务里算不上“代差”,但已经足够说明两个系统在同等条件下的输出质量存在可测差异。真正决定这个差异是否可信的,是下面这个清单:
| 检查项 | 说明 |
|---|---|
| 数据集是否一致 | 两个系统必须用同一批对话记录、同一批查询问题,否则没有可比性 |
| Prompt 是否一致 | 实体抽取和问答生成阶段,Prompt 差异会直接影响分数 |
| 评估脚本是否共用 | 自动评估的打分逻辑、指标口径、后处理方式必须完全一致 |
| 是否多次运行取均值 | LLM 推理有随机性,单次结果不能代表真实水平 |
| 是否排除数据泄漏 | 训练数据或示例数据不能出现在评估数据中 |
| 是否控制 token 上限 | 一个系统能看 8k 上下文,另一个只能看 2k,结果自然不同 |
0.831 和 0.801 并不可怕,可怕的是没有人能解释这个分数是怎么来的。所以本文后续会给出一个能够自证的 benchmark 流程,让每个模块的贡献都可以被复查。
2. 什么是 memory graph,比向量记忆强在哪
很多对话系统现在的记忆方案是“对话文本切块 -> 向量化 -> 存向量库 -> 检索相似片段”。这种方案实现简单,但有两个明显问题:第一,多个片段里出现同一个实体时,无法自动合并;第二,只支持相似度召回,不支持关系推理。
memory graph 的思路完全不同。它会把对话内容解析成一组结构化的三元组:实体、关系、实体。例如:
用户 提到 项目A 项目A 使用 技术B 用户 偏好 Python 项目A 开始于 2024-03这些三元组被写入图结构后,可以回答三类问题:
- 直接召回:用户偏好什么语言?-> Python
- 关系查询:项目A 用了哪些技术?-> 通过“项目A -> 使用 -> 技术”这条边展开
- 多跳推理:用户提到过哪些与技术B 相关的项目?-> 从技术B反查项目A
普通向量检索面对“多跳问题”时,通常需要同时召回多个片段再靠 LLM 拼凑,而图记忆天生就把关系存在边上,查询路径更短。
| 维度 | 向量记忆 | Memory Graph |
|---|---|---|
| 存储结构 | 向量 + 原始文本片段 | 实体节点 + 关系边 + 属性 |
| 检索方式 | 相似度 Top-K | 相似度召回 + 图遍历 + 重排 |
| 实体去重 | 依赖文本相似,效果一般 | 可显式合并同一实体 |
| 关系推理 | 弱,需要 LLM 二次加工 | 强,图结构直接支持 |
| 时间线维护 | 难,片段之间没有因果关系 | 可以在边上存储时间戳和事件 |
| 实现成本 | 低 | 中高,需要抽取和存储逻辑 |
从 0.831 这个分数来看,作者大概率不是简单把文本塞进图数据库,而是在抽取精度、去重策略、检索路径上做了优化。这类系统最适合的场景是:智能助手长期记忆、个人知识库、多轮对话中的用户画像维护、企业内部文档关系梳理。
3. 从标题反推项目架构:模块与数据流
标题里只有“memory graph”和“benchmark”两个关键词,但足以反推一个完整系统应该包含的模块。memory graph 项目的标准数据流如下:
输入对话/文档 -> 信息抽取(LLM 抽取实体、关系、属性) -> 图写入(去重、合并、建立索引) -> 检索触发(收到用户提问) -> 候选召回(图遍历 + 向量相似度) -> 重排筛选(相关性、时间权重、置信度) -> 生成回答(把记忆片段注入 Prompt) -> 输出答案并评估每个环节都可以单独测试,也可以联合评测。模块划分如下:
| 模块 | 核心职责 | 常用实现方式 |
|---|---|---|
| 信息抽取 | 从非结构化文本中识别实体和关系 | LLM + JSON Schema,或微调 NER 模型 |
| 图存储 | 保存实体和关系,支持查询 | Neo4j、NetworkX、EdgeDB |
| 实体合并 | 处理同义实体,避免节点膨胀 | 规则 + 向量相似度 + LLM 判断 |
| 记忆检索 | 根据查询找到相关记忆子图 | BFS/DFS 遍历 + 向量 Top-K |
| 上下文组装 | 把子图转成自然语言片段 | 模板序列化 |
| 评估模块 | 计算召回、准确率、F1 | 脚本 + 人工标注样本集 |
从工程角度看,抽取模块是整个系统的天花板。如果实体抽得不准,后面图存得再漂亮,检索结果也是垃圾。所以 benchmark 里 0.831 vs 0.801 的差距,最可能首先出在抽取和实体合并策略上。
4. 本地复现同类记忆系统的环境准备
如果你想自己搭一套 memory graph 并做对比测试,不需要一开始就上 Graph Database,先用内存图结构跑通流程,再考虑 Neo4j 这类重组件。下面是一份通用环境清单。
操作系统:Windows / macOS / Linux 均可。
语言版本:Python 3.10 或更高。
核心依赖:
pip install networkx langchain openai ollama pydantic pandas- networkx:内存图结构,适合原型验证
- langchain:封装 LLM 调用链,不强依赖
- openai 或 ollama:负责实体抽取和答案生成
- pydantic:校验 LLM 输出的结构化 JSON
- pandas:统计评估结果
如果选择 Neo4j 作为正式存储,需要额外安装:
pip install neo4j同时在本地启动 Neo4j 服务,或使用 Docker:
docker run -d --name neo4j \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/testpassword \ neo4j:5-community硬件要求:
- 纯 API 方案:无 GPU 要求,API 模型负责抽取和生成,内存 16G 足够。
- 本地小模型抽取:建议 8G 显存以上,可以运行 7B 级别的量化模型做实体抽取。
- 本地大模型推理:取决于模型规模,14B 以上建议 24G 显存,或者使用 CPU 推理但接受较低速度。
说明:具体显存占用会因模型量化方式、上下文长度和并发数而变化,实际以本机 nvidia-smi 或任务管理器为准,不建议照搬任何固定数字。
端口方面,LangChain 默认服务可能占用 8000,Neo4j 默认使用 7474/7687,如果你本地已经跑着其他服务,要提前检查冲突。
5. 最小 Memory Graph 原型搭建
为了理解 0.831 这种分数是怎么产生的,我们先动手搭一个最小原型。下面代码使用 NetworkX 存储图,用 LLM 完成实体抽取,然后实现基础写入、检索和序列化。这不是某开源项目的原始实现,而是一个可运行的简化版本。
import json import networkx as nx from pydantic import BaseModel, Field from typing import List, Optional class Relation(BaseModel): head: str = Field(description="头实体") relation: str = Field(description="关系名") tail: str = Field(description="尾实体") timestamp: Optional[str] = Field(default=None, description="事件时间,可为空") class ExtractionResult(BaseModel): relations: List[Relation] entities: List[str] = Field(description="出现的所有实体列表") class MemoryGraph: def __init__(self): self.graph = nx.MultiDiGraph() self._entity_aliases = {} def add_relations(self, result: ExtractionResult): for ent in result.entities: self._upsert_entity(ent) for rel in result.relations: self._upsert_entity(rel.head) self._upsert_entity(rel.tail) self.graph.add_edge( self._canonical(rel.head), self._canonical(rel.tail), relation=rel.relation, timestamp=rel.timestamp, ) def _upsert_entity(self, name: str): name = name.strip() if not name: return if name not in self.graph: self.graph.add_node(name) def _canonical(self, name: str) -> str: name = name.strip().lower() return self._entity_aliases.get(name, name) def recall(self, query_entities: List[str], max_depth: int = 2): """从查询实体出发,做有限深度遍历,返回相关子图节点和边""" nodes = set() edges = [] for q in query_entities: q = q.strip().lower() if q not in self.graph: continue visited = set() frontier = {q} for _ in range(max_depth): next_frontier = set() for node in frontier: if node in visited: continue visited.add(node) nodes.add(node) for _, target, data in self.graph.out_edges(node, data=True): edges.append((node, target, data["relation"])) next_frontier.add(target) for source, _, data in self.graph.in_edges(node, data=True): edges.append((source, node, data["relation"])) next_frontier.add(source) frontier = next_frontier return {"nodes": list(nodes), "edges": edges} def to_context_text(self, subgraph) -> str: """把子图序列化成 LLM 可读的自然语言片段""" lines = [] for h, t, rel in subgraph["edges"]: lines.append(f"{h} --[{rel}]--> {t}") return "\n".join(lines) if lines else "(无相关记忆)"这个原型足够跑通“写入记忆 -> 查询召回 -> 序列化上下文”的流程。真实项目会把实体抽取替换成更精细的 LLM 调用,并用 Neo4j 替代 NetworkX,但整体思路一致。
接入 LLM 做抽取时,可以让模型输出符合ExtractionResult结构的 JSON:
import openai client = openai.OpenAI( base_url="http://localhost:11434/v1", # 这里是 Ollama 的通用地址示例 api_key="ollama" ) EXTRACTION_PROMPT = """请从下面的对话记录中抽取实体和关系。 实体包括人名、项目名、技术名、地点、时间等名词。 关系需要是简洁的谓词,例如“使用”“偏好”“开始于”。只输出 JSON。""".format() def extract_with_llm(text: str): resp = client.chat.completions.create( model="qwen2.5:7b", messages=[ {"role": "system", "content": EXTRACTION_PROMPT}, {"role": "user", "content": text} ], response_format={"type": "json_object"}, temperature=0.2, ) data = json.loads(resp.choices[0].message.content) return ExtractionResult(**data)注意,这里的base_url和model只是示例,实际项目中需要替换成你本地可用的模型服务地址和模型名。更稳妥的做法是先不依赖本地模型,直接用兼容 OpenAI 格式的 API,把基座模型换成一个抽取能力强的大模型。
6. 搭一个能自证的 benchmark 流程
有了原型,接下来就是回答核心问题:如何得到像 0.831 vs 0.801 这样的分数,并且让这个分数经得起质疑。
benchmark 的第一步是固定数据集。建议准备三类数据:
- 多轮对话记录:模拟用户与助手的长期交流,包含偏好、项目进展、人物关系。
- 文档片段:描述技术选型、产品历史、团队结构。
- 测试查询集:覆盖直接事实问答、时间线查询、多跳关系推理三类问题。
每个测试查询必须带有标准答案,例如:
{ "query": "用户最常用的编程语言是什么?", "expected": "Python", "type": "direct_fact" }第二步是定义评估指标。推荐三个维度:
| 指标 | 计算方式 | 适合场景 |
|---|---|---|
| 准确率 | 回答正确的百分比 | 直接事实问答 |
| 召回率 | 正确答案条目中被系统找到的比例 | 关系推理、多候选问题 |
| 完整性 | 回答是否覆盖了标准答案的所有要点 | 复杂问题 |
第三步是控制变量。两个系统对比时,除了记忆实现不同,其余条件全部固定:同一个 LLM 生成答案、同一个 Prompt 模板、同一个评估脚本。如果自研 memory graph 用 gpt-4o,而 Memora 用大 context 模式,差别就不代表架构优劣。
下面是评估脚本的核心逻辑:
import json import statistics def evaluate_answer(predicted: str, expected: str) -> float: """简化评估:检查预期答案是否出现在预测结果中""" if not expected: return 0.0 return 1.0 if expected.lower() in predicted.lower() else 0.0 results = [] test_set = json.load(open("test_queries.json", "r", encoding="utf-8")) for item in test_set: # 每个系统都要跑一遍 predicted_graph = run_memory_graph(item["query"]) # 自定义函数 score_graph = evaluate_answer(predicted_graph, item["expected"]) predicted_memora = run_memora(item["query"]) # 被对比系统的调用接口 score_memora = evaluate_answer(predicted_memora, item["expected"]) results.append({ "query": item["query"], "graph_score": score_graph, "memora_score": score_memora, }) graph_avg = statistics.mean([r["graph_score"] for r in results]) memora_avg = statistics.mean([r["memora_score"] for r in results]) print(f"memory graph: {graph_avg:.3f}") print(f"memora: {memora_avg:.3f}")这里run_memory_graph和run_memora都需要你自己实现,分别封装两套记忆查询逻辑。如果需要更高可信度,每一条 query 重复 3 次,取平均值,同时把日志完整保存下来。
7. 解读一轮测试结果:分数是怎么算出来的
假设你跑了 100 条测试问题,memory graph 答对 83 条,Memora 答对 80 条,那么准确率就是 0.830 和 0.800,和标题里的 0.831 / 0.801 处于同一量级。注意,0.831 不是 83.1% 的“标准答案”,它只是某个指标在某个数据集上的均值,可能是 F1,可能是加权准确率,也可能是把多项指标合成后的结果。
在解读分数时,有四个原则:
- 0.03 的差距只在相同口径下有意义,跨数据集对比没有说服力。
- 要看失败样本分布。如果 memory graph 在 83 个正确回答里全部集中在简单问题上,而 Memora 在复杂多跳问题上更好,那么平均分高并不能说明系统更强。
- 要看错误类型。常见错误包括:实体抽错、关系方向反转、时间戳丢失、多跳路径断裂。
- 要看稳定性。跑 5 次,如果分数在 0.80 到 0.86 之间大幅波动,说明系统对随机性敏感,单次 benchmark 不可信。
建议在 benchmark 报告中至少包含错误样本分析表:
| 问题类型 | 错误数 | 典型错误 |
|---|---|---|
| 直接事实 | 7 | 实体名被合并错误 |
| 关系推理 | 9 | 关系方向反转 |
| 多跳查询 | 12 | 遍历深度不足 |
| 时间线 | 5 | 时间戳遗漏或错误 |
分数只是起点,错误样本才是改进系统的地图。
8. 资源占用与性能观察
memory graph 类项目在资源占用上,和普通 RAG 有较大差异。普通 RAG 的主要开销是向量索引和文本存储,memory graph 的主要开销则集中在三个方面:
实体抽取阶段。每段对话都要调用 LLM 抽取实体和关系,token 消耗比单纯向量化高很多。如果使用的是本地模型,抽取速度会直接成为瓶颈。观察指标包括:抽取一条对话的耗时、prompt token 用量、输出 token 用量。降低开销的方法是批量抽取:把多段对话一次性发给 LLM,用 JSON 数组返回,减少请求次数。
图存储阶段。NetworkX 原型的内存开销会随节点和边的数量线性增长。几万条边以内问题不大,超过百万条边建议切换到 Neo4j 或图数据库服务。观察指标:节点数、边数、重复实体的合并率。如果实体别名过多,说明抽取阶段的实体合并策略不完整。
检索阶段。图遍历深度、候选节点数量和向量召回 Top-K 决定了检索延迟。遍历深度从 2 提高到 3,效果可能变好,但耗时可能翻倍。观察指标:单次查询的 p50/p95 延迟、召回子图的大小。
一个稳妥的调优顺序是:
- 先固定遍历深度为 2,评估召回率。
- 再提升实体合并质量,减少冗余节点。
- 然后增加向量候选召回作为补充通道。
- 最后才考虑增加深度或并发。
不要一上来就堆资源。0.831 这个分数如果真的是靠大量实体合并规则和精确抽取刷出来的,那么复现它最重要的工作是在数据质量和抽取 Prompt 上,而不是在显存上。
9. 常见问题与排查方法
在复现或构建 memory graph 的过程中,下面几个问题最容易出现。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 抽取出的实体关系全是垃圾 | Prompt 没有给示例,或模型太小 | 打印抽取原始 JSON,抽查 10 条 | 在 Prompt 中加 few-shot 示例,或换更大模型 |
| 同一实体在图中出现多个重复节点 | 缺少实体合并逻辑 | 导出全量实体,检查命名分布 | 增加别名表,用 embedding 相似度做合并 |
| 查询时召回不到相关记忆 | 查询实体和图里实体无法对齐 | 打印查询实体列表和召回子图 | 增加查询扩展,用向量召回补充图遍历 |
| 多跳推理总是断链 | 关系方向抽取错误 | 检查典型错误样本中的边方向 | 在抽取 Prompt 中强化方向约束 |
| 回答质量不稳定 | 上下文序列化顺序无规则 | 固定随机种子,观察多次输出 | 对上下文片段按相关性和时间排序 |
| 本地模型推理很慢 | 并发低或使用了 CPU 推理 | 查看 nvidia-smi,确认 GPU 利用率 | 缩小模型量化等级,或改用 API |
| 评估分数忽高忽低 | 测试集太小或 LLM 随机性过大 | 增加测试集到 100 条以上,重复跑 3 次 | 温度调整为 0,多次运行取均值 |
| 服务启动后端口冲突 | 上一次进程未退出或端口被占用 | 查看端口监听状态 | 更换端口:如--port 7861 |
实体合并是 memory graph 里最容易被低估的环节。许多人一开始只做规则归一化,比如全小写、去空格。但对“Python”和“python”这种大小写差异,以及“OpenAI”和“Open AI”这类拼写变体,规则只能覆盖一部分。更稳妥的做法是维护一个别名映射表,再结合向量相似度判断是否属于同一实体。实体合并做不好,图里的节点会迅速膨胀,召回准确率降低,最终分数自然上不去。
另一个常见坑是时间戳丢失。对话记录里常常含有多个事件时间:“上个月开始”“昨天改成了”“2025-03 立项”。如果不把时间解析成标准格式并存储到关系边上,时间线查询基本无法完成。在信息抽取阶段,给 LLM 的 Prompt 里要明确要求:如果文本中出现时间表达,必须给出标准时间戳。没有时间属性的记忆图,只能回答静态关系,无法回答“项目从什么时候开始”这类问题。
10. 最佳实践与隐私合规
memory graph 存储的是用户偏好、对话内容、人物关系、项目内部信息,这类数据天然属于敏感数据。无论是一个本地测试项目,还是一个准备上线的记忆系统,都绑着隐私责任。
使用边界方面,建议遵守以下原则:
- 数据最小化:只抽取系统运行所需的信息,不要记录与功能无关的敏感字段。
- 明确授权:涉及真实用户数据时,必须获得授权,明确告知用户数据将被存储和被使用的方式。
- 可删除性:用户要求删除记忆时,要能定位到对应实体、关系并彻底删除。图结构的相关性删除比向量库删除更复杂,需要写递归删除逻辑。
- 审计与透明:记录记忆写入的来源和修改时间,便于追溯和纠错。
- 版权合规:如果输入数据来自书籍、论文、企业内部文档或他人内容,需要确认是否允许用于模型抽取和商用。
- 肖像与身份信息:如果记忆系统中包含人脸、声音、身份证号、银行卡号等身份信息,必须做脱敏处理,并遵循相关规定。
工程层面,建议从一开始就为每条记忆写入源头 ID。例如用一个source_id字段记录这条实体关系来自哪段对话、哪个文档。这样当某条记录需要修正或删除时,可以准确地找到所有相关节点。同时在写图之前,增加一层抽检逻辑:随机抽 5% 的抽取结果,由人工或规则检查实体、关系、时间戳是否正确。这个步骤会显著提高记忆图的质量。
对于 benchmark 本身,建议把测试集、评估脚本、两次评估的日志全部保存。如果未来有人引用 0.831 vs 0.801 这个结果,你至少要能说明:数据集是什么、评估指标怎么算、每个系统的调用参数是什么、跑了几次、方差多大。缺少这些信息的分数,只能当成宣传数据,不能当成工程结论。
11. 总结与建议
memory graph 能够在上一个 benchmark 对比中拿到 0.831,说明这条技术路线在对话记忆、关系推理、长期记忆这类场景里确实有竞争力。但 0.831 和 0.801 的分差,只有在完整复现了同样评估条件之后才有意义。真正值得学习的是它的架构思路:抽取不用求全,但求准;存储不用复杂,但要支持关系查询;检索不用贪深,但要能覆盖多跳路径。
如果你打算试水这个方向,第一步不是去复现原作者的全部代码,而是先跑通一个最小系统:用 NetworkX 存图,用一个本地 LLM 或 API 做抽取,准备 30 到 50 条测试问题,跑出第一版本分数。然后集中精力优化实体合并和 Prompt,观察指标变化是否合理。这个过程能让你快速判断 memory graph 是否适合你的实际场景,也能帮你理解那些 benchmark 数字背后到底有多少工程细节。
如果后续作者放出了完整的源码和测试集,建议按本文的验证流程重新跑一遍:先确认数据集口径,再检查评估脚本,最后复现分数。能复现的 benchmark 才算数,不能复现的 0.831,只能当个参考值。