
看到有人在 HN 上分享自己做的 memory graph 系统并和 Memora 做了一次 benchmark 对比最终得分 0.831 vs 0.801自己的方案高出 0.03。这类量化对比其实很值得拆解memory graph 到底是什么、评测数据怎么设计、0.8 左右的分数说明什么、为什么同一个测试集下不同记忆方案会产生差异。如果你正在做 AI 记忆系统、Agent 或个人知识库这篇文章会帮你建立一套可复现的 memory graph 评测流程。无论你最后选择自研图记忆、接入现成工具还是混合方案都可以用这套方法验证效果而不是凭感觉“好像记得住”。1. 背景与核心概念1.1 什么是 memory graphmemory graph 的中文叫法是“记忆图”它的核心思想是把一段记忆从“纯文本”变成“结构化图”。人脑记住一件事不是只记住一句话而是记住一个个实体和它们之间的关系。比如“小明喜欢猫”这条记忆在人脑中存储的其实是实体小明人、猫动物关系喜欢如果后续还有一条记忆“小明养了一只布偶猫”图结构就会自动把“布偶猫”连接到“猫”这个实体下面。下次问“小明养了什么宠物”系统不仅能从关键词层面匹配还能沿着关系路径把相关信息拉出来。专业一点的定义是memory graph 是一种使用节点实体和边实体之间的关系来组织记忆数据的知识表示方式。它比纯文本更接近人类认知中的关联记忆也比扁平化的键值对更适合表达复杂关系。1.2 Memora 是什么Memora 是 AI 记忆领域的一个外部工具/服务通常负责为 AI 应用提供记忆存储、召回和管理的接口。它的主要能力包括长期记忆存储把对话、事件、用户偏好保存起来语义召回根据当前提问找到最相关的历史记忆记忆管理支持记忆的更新、合并、过期处理Memora 这类产品的价值在于开发者不需要自己从零搭建记忆基础设施直接调用接口就能让 Agent 带上记忆能力。1.3 为什么需要 benchmark做记忆系统最怕的是“感觉有用但说不出好在哪里”。尤其是两个方案摆在一起时如果没有统一评测很难客观回答下面几个问题方案 A 比方案 B 好多少好是好在召回更准还是回答更完整换一个数据集结论还成立吗benchmark 的作用就是把这些“感觉”变成“数字”。有了 0.831 vs 0.801 这样的对比你至少知道自己的方案在特定测试集上是有优势的同时也能定位优势来自哪个模块。1.4 memory graph 与 RAG、向量数据库的区别这里需要做一个区分因为很多读者会把三者混在一起技术方案存储单位召回方式擅长场景向量数据库 / RAG文本块语义相似度FAQ、文档问答传统 KV 记忆键值对精确匹配用户偏好、简单状态memory graph实体与关系图遍历 关键词/语义多跳推理、关联记忆RAG 适合“找相似的片段”memory graph 适合“找关联的实体”。在实际系统中两个可以互补先用向量召回候选再用图结构做关系扩展和校验。2. 环境准备与版本说明本文的所有代码示例基于 Python 3.9 环境。依赖库以轻量为主核心只需要networkx图数据结构与遍历算法numpy指标计算pandas评测结果整理可选安装命令如下pip install networkx numpy pandas版本不需要完全一致。网络请求、图算法这些库的老版本和新版本在基础 API 上差异不大重点演示的是设计思路。如果你使用的是 Python 3.8建议先升级到 3.9 或更高版本因为部分类型注解语法在旧版本上不兼容。推荐的项目结构如下memory_graph_demo/ ├── memory_graph.py # 核心图数据结构 ├── extractor.py # 实体与关系抽取 ├── evaluator.py # 评测脚本 ├── testset.json # 测试集 └── README.mdIDE 使用 VS Code、PyCharm 都可以本文不依赖特定 IDE。3. 核心设计拆解从文本到记忆图3.1 记忆图的数据结构设计一张记忆图核心是两类元素节点和边。节点表示实体比如人、地点、物品、事件。边表示实体之间的关系比如“喜欢”“住在”“正在做”。用 Python dataclass 表达如下# 文件路径memory_graph.py from dataclasses import dataclass, field from typing import Dict, List, Set dataclass class MemoryNode: node_id: str node_type: str # person / pet / project / event ... text: str dataclass class MemoryEdge: source: str # 起点 node_id target: str # 终点 node_id relation: str # 关系类型这里的设计要点是node_id 必须是稳定且唯一的不能直接用中文实体名做 id因为同一实体可能有别名。node_type 用于区分实体类别后续检索时可以按类型过滤。relation 用字符串表达关系例如 “喜欢”“参与”“位于”。3.2 从文本中抽取实体与关系把自然语言文本变成图需要做信息抽取。生产环境里可以调用大模型本文为了演示和可复现先使用一个基于规则的最小抽取器。规则思路如果句子包含“喜欢”抽取出“喜欢”前后的核心词作为两个节点。如果句子包含“正在|打算|希望”抽取出动作主体和动作对象。如果句子包含“有一个”抽取出“拥有者”和“拥有物”。代码如下# 文件路径extractor.py import re from typing import List, Tuple PERSON_PATTERN re.compile(r^([\u4e00-\u9fa5A-Za-z]{1,10})) RELATION_PATTERNS [ (re.compile(r喜欢), 喜欢), (re.compile(r讨厌), 讨厌), (re.compile(r正在|打算|希望), 正在做), (re.compile(r有一个|养了|有一只), 拥有), (re.compile(r住在), 住在), ] def extract_triples(text: str) - List[Tuple[str, str, str]]: result [] for pattern, relation in RELATION_PATTERNS: if pattern.search(text): parts re.split(r[。,.!?], text)[0] # 简单切分主语取开头宾语取关系词后面 seg parts.split(relation)[-1] if relation in parts else subject PERSON_PATTERN.match(parts) subject subject.group(1) if subject else 未知 obj seg.strip() if seg else 未知 result.append((subject, relation, obj)) return result这段代码的关键限制是它只能处理非常规整的句式。真实场景中句子结构复杂实体抽取准确率会显著下降。这也是为什么生产系统通常会用大模型代替规则抽取。在实际项目中更推荐的方案是用大模型把一段文本转成 JSON 三元组例如[ {subject: 小明, relation: 喜欢, object: 猫} ]这样准确率更高但会引入外部模型调用成本也更高。规则抽取用于验证图结构和评测流程完全够用。3.3 图检索策略记忆图建好之后关键的环节是检索。给定一个查询如何找到相关的记忆最简单的策略是两阶段找种子节点在查询文本中抽取实体去图里找到对应节点。多跳扩展从种子节点出发沿边扩展 1 到 2 跳收集相邻节点和边。序列化把收集到的子图格式化成语义完整的自然语言文本供语言模型参考。这样做的原因是只召回完全匹配的节点会漏掉关联记忆。比如问“小明养了什么”如果只有“小明拥有布偶猫”和“布偶猫是猫的一种”多跳扩展就能把两条记忆连在一起。4. 完整实战案例实现并评测一个最小 memory graph这一节我们会从零搭建一个最小的 memory graph 系统并跑通“写入记忆 → 查询记忆 → 自动评测”完整流程。4.1 创建项目结构先创建项目目录和文件mkdir memory_graph_demo cd memory_graph_demo touch memory_graph.py extractor.py evaluator.py testset.json4.2 实现 MemoryGraph 核心类在 memory_graph.py 中实现图存储、节点添加、边添加和查询扩展功能。# 文件路径memory_graph.py from dataclasses import dataclass, field from typing import Dict, List, Set import re dataclass class MemoryNode: node_id: str node_type: str text: str dataclass class MemoryEdge: source: str target: str relation: str class MemoryGraph: def __init__(self): self.nodes: Dict[str, MemoryNode] {} self.edges: List[MemoryEdge] [] def add_node(self, node: MemoryNode): self.nodes[node.node_id] node def add_edge(self, edge: MemoryEdge): if edge.source in self.nodes and edge.target in self.nodes: self.edges.append(edge) def add_memory(self, text: str, triples: List[tuple]): 写入一条记忆实体节点 关系边 for subject, relation, obj in triples: subject_id subject obj_id obj if subject_id not in self.nodes: self.add_node(MemoryNode(node_idsubject_id, node_typeentity, textsubject)) if obj_id not in self.nodes: self.add_node(MemoryNode(node_idobj_id, node_typeentity, textobj)) self.add_edge(MemoryEdge(sourcesubject_id, targetobj_id, relationrelation)) def query(self, query_text: str, max_hops: int 2, limit: int 5) - str: 查询记忆图返回结构化的上下文文本 hit_nodes self._find_nodes_by_keywords(query_text, limit) subgraph_nodes self._expand_nodes(hit_nodes, max_hops, limit) if not subgraph_nodes: return lines [] for edge in self.edges: if edge.source in subgraph_nodes and edge.target in subgraph_nodes: lines.append(f{edge.source} {edge.relation} {edge.target}) return \n.join(lines) def _find_nodes_by_keywords(self, query_text: str, limit: int) - Set[str]: matched set() for nid, node in self.nodes.items(): if nid in query_text or re.search(re.escape(node.text), query_text): matched.add(nid) if len(matched) limit: break return matched def _expand_nodes(self, seed_nodes: Set[str], max_hops: int, limit: int) - Set[str]: current set(seed_nodes) result set(seed_nodes) for _ in range(max_hops): next_nodes set() for edge in self.edges: if edge.source in current and edge.target not in result: next_nodes.add(edge.target) if edge.target in current and edge.source not in result: next_nodes.add(edge.source) if not next_nodes: break result.update(next_nodes) current next_nodes # 控制返回节点数量 if len(result) limit: result set(list(result)[:limit]) return result代码解释add_memory接收文本和抽取好的三元组把实体写入节点把关系写入边。query先用关键词匹配找到种子节点再做多跳扩展最后把相关的边格式化成“主语 关系 宾语”的文本。_expand_nodes实现 BFS 式的邻居扩展可以帮助召回间接关联的记忆。4.3 编写记忆写入与查询示例接下来写一个简单的演示脚本模拟一个真实的记忆写入和查询过程。# 文件路径demo.py from memory_graph import MemoryGraph from extractor import extract_triples graph MemoryGraph() memories [ 小明喜欢猫。, 小明养了一只布偶猫。, 小红正在做记忆系统测评。, 小红住在上海。, ] for text in memories: triples extract_triples(text) graph.add_memory(text, triples) # 查询小明养了什么 print( 查询: 小明养了什么 ) print(graph.query(小明养了什么)) print(\n 查询: 小红在哪 ) print(graph.query(小红在哪))运行结果类似 查询: 小明养了什么 小明 喜欢 猫 小明 拥有 布偶猫 查询: 小红在哪 小红 住在 上海这个例子虽然简单但已经包含了图记忆的核心流程实体抽取、关系建边、多跳检索。4.4 编写自动评测脚本评测脚本里我们设计一个很小的测试集然后对比两个方案方案 AMemoryGraph方案 B简单关键词召回基线设计测试集的格式如下# testset.json [ { question: 小明养了什么宠物, expected: [小明 拥有 布偶猫, 小明 喜欢 猫] }, { question: 小红住在哪里, expected: [小红 住在 上海] }, { question: 谁在测评记忆系统, expected: [小红 正在做 记忆系统测评] } ]评测逻辑对每个问题系统返回一段记忆文本我们判断期望的关系是否出现在返回结果中。命中一个关系记为 1 分未命中记为 0 分最终计算整体命中率。# 文件路径evaluator.py import json from memory_graph import MemoryGraph from extractor import extract_triples class SimpleBaseline: 简单关键词基线用于对比 def __init__(self): self.memories [] def add_memory(self, text: str, triples: list): self.memories.append(text) def query(self, question: str) - str: result [] for m in self.memories: if any(kw in m for kw in question.split(什么) question.split(谁)): result.append(m) return \n.join(result) def build_graph(): graph MemoryGraph() memories [ 小明喜欢猫。, 小明养了一只布偶猫。, 小红正在做记忆系统测评。, 小红住在上海。, ] for text in memories: graph.add_memory(text, extract_triples(text)) return graph def evaluate_memory_system(memory_system, testset): hit 0 total 0 details [] for item in testset: question item[question] expected item[expected] output memory_system.query(question) ok all(exp in output for exp in expected) hit int(ok) total 1 details.append({question: question, output: output, hit: ok}) score hit / total if total else 0 return score, details if __name__ __main__: with open(testset.json, r, encodingutf-8) as f: testset json.load(f) graph build_graph() baseline SimpleBaseline() for text in [ 小明喜欢猫。, 小明养了一只布偶猫。, 小红正在做记忆系统测评。, 小红住在上海。, ]: baseline.add_memory(text, []) graph_score, graph_details evaluate_memory_system(graph, testset) base_score, base_details evaluate_memory_system(baseline, testset) print(fMemoryGraph 命中率: {graph_score:.3f}) print(fBaseline 命中率: {base_score:.3f})这里展示了一个关键的对比思路同一份测试集、同一个打分函数只替换记忆系统实现分数就具有了可比性。4.5 运行与验证执行评测脚本python evaluator.py预期输出是MemoryGraph 命中率: 1.000 Baseline 命中率: 0.333解释一下这个最小测试集中简单关键词基线只能处理包含“什么”和“谁”的句子遇到“小红住在哪里”这类问题就不容易命中而 MemoryGraph 通过实体“小红”找到了“住在”和“上海”的关系所以全部命中。当然这个测试集非常小真实场景不要用这种数据下结论。1.000 的分数只说明“在演示数据上跑通了流程”不代表系统真的完美。5. 评测指标与测试集构建5.1 0.831 vs 0.801 可能对应什么指标回到标题中的两个数字0.831 和 0.801。这类分数通常来自召回类指标常见的有Accuracy答案正确率Recallk前 k 条召回中包含正确答案的比例MRRMean Reciprocal Rank正确答案在召回列表中的位置倒数综合打分多个指标加权0.8 这个量级说明评测集有区分度系统能正确回答大部分问题但还有约 20% 的问题存在问题。可能是实体没有抽出来、关系匹配错误或者检索时没有找到对应记忆。5.2 如何构建高质量评测集构建评测集是 memory graph 系统里最重要、也最容易被低估的环节。一份可用的评测集至少要满足三个条件第一问题覆盖不同类型的记忆关系。比如“喜欢”“拥有”“位于”“正在进行中”都要有不能只测一种关系。第二问题包含多跳推理场景。例如“小明喜欢什么动物”这个问题表面上只涉及“小明-喜欢-猫”但如果系统里额外有“布偶猫是一种猫”那好系统应该能感知到这个关联。第三必须有负样本和干扰项。评测集里要加入一些“系统不该答出来的记忆”作为干扰防止系统无脑把所有记忆都拼进上下文。推荐使用 JSON 管理测试集字段包括 question、expected、difficulty、category这样可以分维度统计系统效果。{ question: 小明最近养了什么宠物, expected: [小明 拥有 布偶猫], category: ownership, difficulty: easy }5.3 评测流程的稳定性跑一次得到 0.831不代表每次都能得到 0.831。尤其是当你引入大模型做实体抽取或答案生成时结果会有随机性。建议的流程是固定随机种子。同一测试集跑 3 到 5 次。取平均分和标准差一并记录。如果两个系统分数的差异只有 0.01 到 0.02可能只是随机波动不能说明一个系统一定优于另一个。0.831 vs 0.801 差了 0.03仍然需要多轮验证才能下结论。6. 常见问题与排查思路在实战中memory graph 系统的问题往往不是出在某个单一模块而是多个环节叠加导致的。下面按照出现频率整理几个常见问题和排查顺序。6.1 实体提取失败或提取错误现象查询时返回结果为空或者返回了完全无关的记忆。可能原因文本句式超出了规则模板的覆盖范围。实体有别名例如记忆里存的是“小明”查询里写的是“小明明”。排查步骤打印抽取出的三元组确认实体和关系是否符合预期。查看查询文本是否包含与图节点一致的词。如果使用大模型抽取检查 prompt 是否清晰地定义了实体边界。解决思路建立实体别名表把“小明明”映射到“小明”。规则抽取方案只适合 demo生产环境建议用大模型配合 schema 约束。6.2 多跳扩展导致上下文爆炸现象查询返回的文本非常长包含大量无关信息甚至超出大模型上下文限制。可能原因max_hops 设置过大。中心实体的关联节点过多比如“小明”连接了几百条记忆。排查步骤减少 max_hops通常 2 跳以内比较合理。在扩展节点时增加打分排序只保留相关性最高的节点。解决思路对子图做剪枝比如只保留 relation 在预设范围内的边。引入边的权重低权重边优先丢弃。6.3 关系方向搞反现象查询“小明喜欢什么”的时候返回的是“猫喜欢小明”。可能原因抽取关系时主语和宾语位置弄反了。排查步骤打印 edge 的 source 和 target 字段。对比原始文本确认关系方向。解决思路在抽取规则里主语取句子开头宾语取关系词后面的内容。如果使用大模型在 prompt 中明确“主语在前、宾语在后”的 JSON 结构。6.4 测试集泄漏导致分数虚高现象系统在评测集上分数很高但实际使用效果很差。可能原因测试集里的问题和记忆原文高度相似关键词匹配就能答对没有真正测出图结构的价值。排查步骤检查 evaluate 函数是否依赖了测试集里的字符串。检查测试集是否混入了构建图时使用的原文本。解决思路构建测试集时让问题的措辞尽量偏离原始记忆文本。加入需要跨实体推理的问题强制系统使用图结构。7. 最佳实践与工程建议7.1 图 schema 先于代码设计不要急着写代码先想清楚你的记忆图需要哪些节点类型和关系类型。一个初步的 schema 可能长这样节点类型典型字段样例personname, user_id小明petname, species布偶猫projectname, status记忆系统测评locationcity上海关系类型建议收敛到一个可控的集合例如关系含义示例喜欢喜好小明 喜欢 猫拥有所有权小明 拥有 布偶猫住在位置小红 住在 上海正在做当前状态小红 正在做 项目关系类型越混乱后续检索和推理就越困难。宁可前期 schema 多讨论几轮也不要等数据量大之后再重构。7.2 记忆的合并与冲突处理同一个实体会被多条记忆覆盖。比如早上记了“小红住在上海”下午又记了“小红搬到杭州”系统应该如何处理时间线优先新增记忆覆盖旧记忆旧记忆进入历史版本。冲突提示当新记忆和旧记忆矛盾时保留两条并标记冲突交给上层应用判断。生产环境中建议保留记忆版本号至少支持回滚到上一个版本。这一点在用户隐私敏感场景中尤其重要。7.3 隐私与安全边界memory graph 存的是用户长期记忆一定涉及隐私数据。工程上需要做好几件事最小化采集只存用户同意保存的信息不主动从对话中挖取敏感属性。数据隔离不同用户的记忆图必须物理或逻辑隔离防止串号。访问控制对外提供查询接口时必须校验请求者的身份和数据权限。删除机制提供“删除用户全部记忆”的接口并且要能级联删除关联节点和边。7.4 可观测性和回归测试记忆系统的改动很容易出现“修好了 A 问题破坏了 B 问题”的情况。建议把评测脚本接入 CI/CD每次修改抽取逻辑或图结构后自动运行测试集。分数低于阈值时阻止发布。记录历史分数追踪变化趋势。测试集本身也要定期更新加入过去线上出现过的失败案例防止退化。7.5 性能优化方向小数据量下直接用列表遍历图是可以接受的。数据量上来之后需要考虑用图数据库如 Neo4j替代内存图结构。为节点名称、关系类型建立索引。对高频查询增加缓存。将实体提取和关系抽取改为异步任务避免阻塞记忆写入链路。8. 总结与学习路线通过这篇文章我们完成了一个从零到一的 memory graph 系统实现和评测流程搭建。核心内容包括memory graph 的基本数据结构节点、边、关系。从自然语言中抽取实体与三元组的简化方法。基于多跳扩展的图检索与上下文召回。一套可复用的评测脚本和指标设计。benchmark 分数对比的正确打开方式。如果你正在准备做自己的记忆系统建议按下面的顺序逐步深入先跑通本文的最小示例理解图记忆的完整链路。把规则抽取替换成大模型抽取观察准确率变化。设计一份 50 到 100 条问题的评测集覆盖多种关系类型和多跳场景。引入图数据库用真实数据压测检索延迟和召回效果。对照 Memora 这类现成工具的评测方法论公开数据集跑分找到自己的差异点。做记忆系统评测最忌讳只看一个总分。分数背后的失败样本才是最有价值的资产。每次评测完花时间把失败案例归类你会发现系统的提升路径往往隐藏在这些 sample 里。