当写《RAG 没有死》那篇、写到 GraphRAG 那一节的时候,我心里是有点发虚的。多跳推理、知识图谱、社区发现,这些词都是从论文和官方文档里搬来的,我自己并没有真的建过一张图、跑过一条查询。引用式写作的账迟早要还,所以最近两个周末我把 Neo4j 和 Cypher 认真补了一遍,顺手把 Agent 场景下图的选型边界也想清楚了。
这篇是补课笔记的上篇,聊三件事,图数据库到底解决什么问题,Cypher 怎么写,什么时候值得为你的 Agent 引入一张图。先澄清一个容易混的点,之前写《从 Loop 到 Graph》时说过,Agent 编排里的那张「执行图」不是知识图谱,一个组织的是系统怎么运转,一个组织的是系统知道什么,这篇讲的是后者。至于让 LLM 自己写 Cypher 会发生什么,下篇再引爆,友情提示,场面比想象中惨烈。
01 关系是一等公民,不是 JOIN 的产物
从一个具体查询说起。社交数据里要回答「Alice 的朋友的朋友看过哪些电影」,用 MySQL 大概长这样。
1SELECT DISTINCT w.movie_title 2FROM friend f1 3JOIN friend f2 ON f2.user_id = f1.friend_id 4JOIN watch w ON w.user_id = f2.friend_id 5WHERE f1.user_id = 'alice'; 6
两层 JOIN 不算痛。真正的痛在需求的变化。产品说要「朋友的朋友的朋友」,你加一层 JOIN。说要排除 Alice 自己看过的,再加一个 NOT IN 子查询。哪天产品经理说了句「咱做个六度人脉功能吧」,你的 SQL 就变成了一张自连接的千层饼,执行计划里全是表扫描。
索引一个都救不了。
图数据库对这个问题的回答很直接。关系不是每次查询时 JOIN 出来的,是写入时就存下来的。 Neo4j 的存储模型有个说法叫 index-free adjacency,每个节点直接持有指向邻居的引用。查 Alice 的朋友,顺着指针跳一步就到了。查朋友的朋友,再跳一步。遍历的代价只和「这一跳有多少个邻居」有关,和整张图里是几百万还是几亿个节点无关。
这句话反过来也成立。如果你的查询模式是全表聚合、跑报表,关系本身不参与过滤,那图的这个优势一文不值,老老实实用 MySQL 或者 ClickHouse,别为了时髦上图。
02 五分钟跑起来一个 Neo4j
工具链从简,一条 Docker 命令的事。
1docker run -d --name neo4j \ 2 -p 7474:7474 -p 7687:7687 \ 3 -e NEO4J_AUTH=neo4j/neo4j12345 \ 4 neo4j:5 5
跑起来之后浏览器打开 http://localhost:7474,就是 Neo4j Browser,一个网页版的查询台。7474 是 HTTP 端口,7687 是 Bolt 协议端口,后面 Python 驱动连的是后者。Python 侧装个官方驱动就能干活。
1pip install neo4j 2
1from neo4j import GraphDatabase 2 3driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "neo4j12345")) 4with driver.session() as session: 5 result = session.run("RETURN 1 AS one") # 图数据库版的 hello world 6 print(result.single()["one"]) 7driver.close() 8
03 建模,把世界拆成节点和关系
属性图模型只有三个角色。节点是实体,一个人、一部电影。关系是实体之间的有向连接,谁看过谁。属性是挂在节点或关系上的键值对,电影的上映年份、看片的评分。
把这句话翻译成建模口诀就是,名词做节点,动词做关系。
1CREATE (alice:Person {name: 'Alice', city: '北京'}), 2 (bob:Person {name: 'Bob', city: '上海'}), 3 (carol:Person {name: 'Carol', city: '北京'}), 4 (dave:Person {name: 'Dave', city: '深圳'}), 5 (inception:Movie {title: '盗梦空间', released: 2010}), 6 (matrix:Movie {title: '黑客帝国', released: 1999}), 7 (truman:Movie {title: '楚门的世界', released: 1998}), 8 (alice)-[:FRIEND_OF {since: 2021}]->(bob), 9 (bob)-[:FRIEND_OF {since: 2020}]->(carol), 10 (carol)-[:FRIEND_OF {since: 2022}]->(dave), 11 (alice)-[:WATCHED {score: 5}]->(inception), 12 (bob)-[:WATCHED {score: 5}]->(inception), 13 (bob)-[:WATCHED {score: 4}]->(matrix), 14 (carol)-[:WATCHED {score: 5}]->(truman), 15 (dave)-[:WATCHED {score: 4}]->(inception) 16
把这段贴进 Browser,回车,demo 图就建好了。Person 和 Movie 是标签(label),用来圈定一类节点,扮演类似「表」的角色。FRIEND_OF 这种全大写的是关系类型。注意关系是有方向的,(alice)-[:WATCHED]->(inception) 是 Alice 看了盗梦空间,反过来就不成立。方向这个细节先记一下,下篇讲 LLM 生成 Cypher 时它是重灾区。
FRIEND_OF
FRIEND_OF
FRIEND_OF
WATCHED 5
WATCHED 5
WATCHED 4
WATCHED 5
WATCHED 4
Alice
Bob
Carol
Dave
盗梦空间
黑客帝国
楚门的世界
口诀是入口不是终点。评分现在只是 WATCHED 关系上的一个属性,哪天产品要给评分挂评论、挂时间线,这个属性就该升级成一个独立的 Rating 节点。属性图建模里没有唯一正确答案,只有当前需求下的合理答案,这是它和关系型第三范式最大的思维差异,范式怕冗余,图怕的是把该成为实体的东西埋进属性里。
04 Cypher,用 ASCII 画出来的查询语言
Cypher 的设计哲学是「你画的图就是你写的查询」。模式用一对圆括号表示节点,方括号加箭头表示关系,(a)-[:KNOWS]->(b) 本身就是一张示意图。上手比 SQL 容易,坑也真不少,这里只讲后面会用到的核心子集。
最基础的模式匹配。
1-- 找出所有在北京的人 2MATCH (p:Person {city: '北京'}) 3RETURN p.name 4
加条件用 WHERE,语义和 SQL 基本一致。真正体现图特色的是变长路径,方括号里的 *1..2 表示关系深度区间。
1-- Alice 的朋友,和朋友的朋友 2MATCH (a:Person {name: 'Alice'})-[:FRIEND_OF*1..2]->(f:Person) 3RETURN DISTINCT f.name 4
回头看第 01 节那个两层 JOIN,这里一行模式写完。扩展性更是两个物种,六度人脉无非是把 *1..2 改成 *1..6,SQL 那边要再叠四层自连接。图的护城河就在这,遍历深度的边际成本约等于零。
顺着 demo 图再走一步,把「Alice 的朋友看过哪些电影」查出来,顺带感受一下结果长什么样。
1MATCH (a:Person {name: 'Alice'})-[:FRIEND_OF*1..2]->(f:Person)-[:WATCHED]->(m:Movie) 2RETURN DISTINCT m.title 3
1"盗梦空间" 2"黑客帝国" 3"楚门的世界" 4
聚合也顺手写一个,给每部电影的评分求平均。
1MATCH (p:Person)-[r:WATCHED]->(m:Movie) 2RETURN m.title, avg(r.score) AS avg_score, count(p) AS voters 3ORDER BY avg_score DESC 4
接着是 MERGE,可以理解成「存在就复用,不存在就创建」。
1MERGE (p:Person {name: 'Alice'}) 2ON CREATE SET p.created_at = timestamp() -- 第一次创建时执行 3ON MATCH SET p.visit_count = coalesce(p.visit_count, 0) + 1 -- 已存在时执行 4RETURN p 5
这个命令在 Agent 场景里出场率极高,写记忆、做实体消歧都靠它。它也有一些微妙的坑,什么时候坑、怎么坑,下篇专门讲。
性能方面记住三件事就够了。查询永远带 LIMIT。变长路径永远给上界,写 *1..3 而不是裸的 *,环状数据会把你拖进无限遍历。高频过滤的属性建索引,CREATE INDEX person_name FOR (p:Person) ON (p.name)。至于看不懂一条查询为什么慢,前面加个 PROFILE 看执行计划,哪些操作在扫全库一目了然。EXPLAIN 和 PROFILE 这对兄弟下篇还有大用,先按下不表。
05 Agent 场景,什么时候值得引入一张图
工具都齐了,回到选型。我在 RAG 系列里说过一句话,GraphRAG 是对症药,不是通用升级。这一节把「对症」具体化成一张决策表。
| 你的需求长这样 | 更合适的选择 | 原因 |
|---|---|---|
| 语义相似、模糊召回(找相关文档) | 向量数据库 | embedding 的主场,图帮不上忙 |
| 精确点查、事务、报表 | 关系数据库 | SQL 生态成熟,别为了时髦上图 |
| 多跳关系、路径、环检测(风控团伙、供应链穿透) | 图数据库 | 遍历是原生操作,深度增加成本几乎不变 |
| 跨文档的全局模式归纳 | GraphRAG | 图加社区摘要,补向量检索的短板 |
| Agent 的长期记忆 | 图加向量的混合 | 实体和关系要长期演化,还要能模糊召回 |
重点展开最后一行,它是 2025 年之后图数据库在 Agent 领域最热的落地点。我之前写《长会话状态治理》时用 Redis 存会话状态,那解决的是「短期工作记忆」。长期记忆是另一回事,用户上周说自己对花生过敏,今天问「帮我订个花生酱」,Agent 得把隔着七天的两个信息点连起来。你想想看,这类实体加关系的记忆天然就是图结构,Zep 的开源框架 Graphiti 和 Mem0 的图记忆模式走的都是这个路线,底层都对接 Neo4j。
图记忆也不是免费午餐。实体抽取要跑 LLM,关系要维护时间有效性,比如用户去年过敏今年好了没,图会越长越大。坦率的讲,如果你的 Agent 会话生命周期只有一轮,那就别碰图,纯浪费。
06 图从哪来
查询写得很爽,但这一切的前提是图已经存在。建图是另一半工程,而且往往是更贵的那一半。
主流路线三条。一是手写 ETL,从关系库抽数据拼节点拼关系,质量最可控,量大了人肉扛不住。二是规则加词典的抽取,适合结构规整的日志类数据。三是让 LLM 从非结构化文本里抽实体和关系,Neo4j 官方的 LLM Knowledge Graph Builder 就是这条路线的参考实现,接上 PDF、网页、YouTube 链接就能自动建图。我在 RAG 系列里提过,LLM 抽取建图的成本可以是向量索引的十到五十倍,这个数字直接决定你该不该走第三条路线。
建图的具体工程细节够单独写一篇,这里先挖个坑。这篇的任务是把「图侧的查询能力」讲清楚,建图路线的取舍留到以后展开。
07 写在最后
回顾一下这篇的要点。
- 图数据库的护城河是关系即存储,遍历深度的边际成本约等于零,多跳和路径类查询是它的主场
- 建模口诀是名词做节点、动词做关系,但属性和节点之间没有永恒边界,跟着需求演化
- Cypher 的核心是模式匹配,变长路径
*1..2是对 SQL 自连接的降维打击 - Agent 场景的选型看需求形态,长期记忆和多跳推理值得上图,模糊召回和报表不要凑热闹
- 建图是另一半工程,LLM 自动建图省人力但真贵,成本要提前算账
说真的,这篇通篇都是人肉写 Cypher,是我自己现学现卖的过程记录,demo 那张小图我反复删了重建了好几轮。但你可能已经注意到了,03 节那句「方向这个细节先记一下」,04 节那句「MERGE 有微妙的坑」,还有被按下不表的 EXPLAIN 兄弟。下篇就让 LLM 亲自下场写 Cypher,把这些埋好的线一根根引爆。
《Neo4j与Cypher入门:从图思维到多跳查询实战》 是转载文章,点击查看原文。