Neo4j 图数据库完整笔记
Neo4j 把数据存成节点 + 关系,两者都能带属性。它最大的特点是 index-free adjacency:每个节点直接持有邻居的物理指针,所以「从一个节点走到它的邻居」不需要再查一次全局索引,代价只跟这个节点自己的…
Neo4j 图数据库完整笔记
阅读提示 面向已经会写 SQL、会用 Python、了解 RAG 与向量库的读者。主线是:哪类问题必须用图 → 图模型怎么表达 → Cypher 怎么写 → Python 怎么接 → 业务怎么建模 → GraphRAG 怎么落地。
全文用同一张「电影小图」贯穿(演员、电影、导演、公司),示例可以在 Neo4j Browser 里按顺序执行。
版本基线:Neo4j 5.x Community Edition(Docker 用
neo4j:5,Python 驱动neo4j>=5.0.0)。有两处例外已就地标注:属性存在性约束是 Enterprise 独有功能(6.2),向量索引需要 5.11+ 且能力随版本持续演进(9.5)。Neo4j 在 5.26 之后转入2025.x/2026.x日历版本号,本笔记不覆盖那之后新增的语法 —— 抄任何 Cypher 之前,先CALL dbms.components() YIELD versions确认你手上是哪个版本。⚠️ = 常见陷阱 🆚 = 与 SQL / 向量库对比 💡 = 选择建议
目录
- 一、为什么需要图数据库
- 二、图模型:节点、标签、属性、关系、路径
- 三、Cypher 基础:CREATE / MATCH / RETURN
- 四、条件、聚合与 WITH 管道
- 五、关系查询:多跳、可变长度、最短路径
- 六、MERGE、索引与约束
- 七、Python 驱动:连接、封装与事务
- 八、从业务问题到图模型
- 九、GraphRAG 实战
- 十、常见陷阱
- 速查表
- 复习重点
核心概念 Neo4j 把数据存成节点 + 关系,两者都能带属性。它最大的特点是 index-free adjacency:每个节点直接持有邻居的物理指针,所以「从一个节点走到它的邻居」不需要再查一次全局索引,代价只跟这个节点自己的关系条数(度数)有关,与整库规模无关。
这决定了它的定位:**关系密集、跳数不定的查询用图;单表批量扫描和大批量明细流水仍然用关系库;语义相似度检索仍然用向量库。**三者在 AI 项目里是配合关系,不是替代关系。
一、为什么需要图数据库
1.1 关系型不是不能做,是越走越贵
用关系库做「张三可能喜欢什么电影」,表大致是这些:
users(id, name) movies(id, title, year)
ratings(user_id, movie_id, score)
casts(movie_id, actor_id) actors(id, name)问「和张三看过同一部电影的人,还看了什么」,SQL 要这样写:
SELECT DISTINCT m2.title
FROM ratings r1
JOIN ratings r2 ON r1.movie_id = r2.movie_id AND r2.user_id <> r1.user_id
JOIN ratings r3 ON r3.user_id = r2.user_id
JOIN movies m2 ON m2.id = r3.movie_id
WHERE r1.user_id = 1;这是 2 跳。再问「朋友的朋友喜欢什么」就是 3 跳,SQL 得再自连接一次;跳数不确定时(「张三 1~3 度人脉内的人」),固定的 JOIN 链就写不出来了——你不知道要 JOIN 几次,只能改用递归 CTE(PostgreSQL、MySQL 8、SQL Server 都支持)。也就是说 SQL 不是做不到,而是要换一套明显更啰嗦的写法,而且优化器对递归查询的执行计划往往不如图引擎的遍历直接。
同一个问题在 Cypher 里是:
MATCH (u:User {name: '张三'})-[:LIKES]->(:Movie)<-[:LIKES]-(other:User)
MATCH (other)-[:LIKES]->(rec:Movie)
WHERE NOT (u)-[:LIKES]->(rec)
RETURN rec.title, count(*) AS score
ORDER BY score DESC LIMIT 10;跳数不定的版本只要把关系写成 [:KNOWS*1..3],语句长度不变。
1.2 差别的根源:JOIN 是查索引,走关系是跳指针
机制:index-free adjacency 关系库做多跳时,每一跳通常都要回到全局的索引或哈希表里再找一次匹配行,代价随表规模增长。(具体是 nested-loop + 索引查找、hash join 还是 merge join 由优化器定,所以别把它简化成一个固定的复杂度公式——重点是「代价和表有多大有关」。)
Neo4j 的每个节点在存储层直接记着它所有关系的物理位置,从一个节点走到邻居不查全局索引,这一步的代价只跟该节点自己的度数有关,和整库有多少数据无关。
两点别记岔:
- 这不等于「常数时间」。整个查询的总成本还包括起点怎么定位(这一步通常仍要走索引)、沿途节点的度数、关系类型过滤的选择性、以及最终匹配出多少条路径。
- 跳数越多、数据量越大,两者差距越明显;反过来,一跳或零跳的查询,图未必有性能优势——但仍可能因为「关系是一等公民、关系自己能带属性、模型和上下游统一」这些建模层面的理由而值得用图。性能不是选型的唯一维度。
| 维度 | 关系型数据库 | Neo4j |
|---|---|---|
| 关系怎么存 | 外键 + JOIN 时现算 | 关系是一等公民,物理落盘 |
| 多跳代价 | 每跳一次索引查找,跳数写死在 SQL 里 | 每跳一次指针跳转,跳数可写成 *1..3 |
| 模式约束 | schema 显式且强制(改列要迁移,或退而用 JSON/JSONB 列) | schema-optional:同标签的节点可以有不同属性,需要时再用约束收紧 |
| 擅长 | 聚合报表、大批量扫描、成熟的事务与审计生态 | 关系遍历、路径查找、图谱推理 |
| 不擅长 | 深度关系、变长路径 | 全表统计、海量明细流水 |
1.3 🆚 那和向量库怎么分工
这是最容易混的一点,两者解决的根本不是同一个问题:
| 向量库(Milvus / Chroma) | Neo4j | |
|---|---|---|
| 回答的问题 | 「有没有意思相近的内容」 | 「这些东西之间是什么关系」 |
| 输入 | 一个查询向量 | 一个入口节点 + 关系模式 |
| 输出 | Top-K 相似片段,彼此孤立 | 结构化子图,带关系语义 |
| 典型失败 | 答不了「BERT 和 Transformer 什么关系」 | 答不了「哪段文字讲了注意力机制」 |
关于相似度本身的计算方式,见 余弦 内积 欧氏距离。
「记账用关系库」不是因为 Neo4j 没有事务 Neo4j 本身就是 ACID 数据库,有完整的事务、隔离级别和回滚。财务、对账这类场景通常仍选关系库,理由是数据模型天然是表状的、生态(报表工具、审计、合规、DBA 技能)更成熟、约束和精度类型(
DECIMAL)更贴合,而不是「图数据库不能保证一致性」。看到别人说「Neo4j 只能做分析不能做交易」,那是把它和早期一些最终一致的 NoSQL 搞混了。
💡 什么进 Neo4j,什么不进
类型 是否适合 例子 实体 ✅ 适合 用户、订单、商品、FAQ、错误码、解决方案 关系 ✅ 适合 购买、包含、依赖、导致、解决、属于 结构化属性 ✅ 适合 状态、评分、时间、类型、价格 大段原文 ❌ 不适合 文档正文、PDF 全文——图里只放它的 id 和摘要 向量 ⚠️ 可选 5.11+ 支持向量索引;是否放图里取决于规模和运维偏好,见 9.5
二、图模型:节点、标签、属性、关系、路径
图里只有五个概念,读图顺序是:先看圈(节点)→ 看圈上的冒号(标签)→ 看虚线框(属性)→ 看箭头(关系)→ 顺着箭头连起来就是路径。
| 概念 | 一句话 | 🆚 关系型里的什么 |
|---|---|---|
| 节点 Node | 一个具体对象 | 一行记录 |
| 标签 Label | 节点的类型,一个节点可有多个 | 表名(但一个节点能同时属于多「表」) |
| 属性 Property | 节点或关系上的键值对 | 列 |
| 关系 Relationship | 两个节点之间有向、有类型的连接,自己也能带属性 | 外键 + 连接表(但关系被真实存下来了) |
| 路径 Path | 节点和关系连成的链路 | 一次多表 JOIN 的结果 |
Cypher 用「画图」的方式写模式,圆括号是节点、方括号是关系、箭头是方向:
(Alice:Person)-[:ACTED_IN {roles: ['Neo']}]->(m:Movie {title: 'The Matrix'})<-[:DIRECTED]-(:Director)读作:Alice 出演了《The Matrix》,而《The Matrix》被某位导演执导。
关系的方向必须存,查询时可以不管 创建关系时必须给方向(
->或<-),这是 Neo4j 的存储要求。 但查询时可以写成无方向的-[:KNOWS]-,两个方向都会匹配上。所以「该不该有方向」要在建模时想清楚:ACTED_IN天然单向;KNOWS这种对称关系通常只存一条,查询时用无方向匹配。
三、Cypher 基础:CREATE / MATCH / RETURN
3.1 建节点
// 单个节点:p 是变量名,:Person 是标签,花括号里是属性
CREATE (p:Person {name: 'Alice', age: 30})
RETURN p;
// 一次建多个,逗号分隔
CREATE (:Person {name: 'Bob', age: 25}),
(:Person {name: 'Charlie', age: 35}),
(:Movie {title: 'The Matrix', released: 1999, rating: 8.7}),
(:Movie {title: 'Inception', released: 2010, rating: 8.8}),
(:Director {name: 'Christopher Nolan', birthYear: 1970});RETURN 不是必须的——CREATE、SET、DELETE 这类写操作不写 RETURN 也会执行,只是看不到结果。调试时才需要加。
3.2 建关系:先 MATCH 找到两头,再 CREATE 连线
MATCH (p:Person {name: 'Alice'}), (m:Movie {title: 'The Matrix'})
CREATE (p)-[:ACTED_IN {roles: ['Neo']}]->(m);⚠️ 建关系时把节点又建了一遍 错误操作: 图省事直接写
CREATE (p:Person {name:'Alice'})-[:ACTED_IN]->(m:Movie {title:'The Matrix'})。实际结果: 语句成功执行,但库里多了一个 Alice 和一部 The Matrix,新关系连的是这两个新节点,原来的 Alice 依旧孤零零。
原因:
CREATE对模式里的每一个元素都是无条件新建,它从不去找已有节点。正确做法: 先
MATCH出两端,再CREATE关系(如上)。脚本可能重复执行时,两端用MERGE。
3.3 查节点
MATCH (p:Person) RETURN p; // 某个标签的全部
MATCH (p:Person {name: 'Alice'}) RETURN p; // 花括号 = 等值过滤
MATCH (p:Person) RETURN p.name, p.age; // 只要某几个属性
MATCH (p:Person) RETURN p.name AS `姓名`; // 起别名⚠️ MATCH (n) 什么都查不到 错误操作: 执行
MATCH (n) RETURN n返回空,但你确信刚刚 CREATE 过。实际结果: 0 rows。
原因: 多半是连错了数据库。Neo4j 4.x 起支持多库,Browser 左上角能切换;Python 驱动里由
session(database=...)决定。数据写进了neo4j库,查询却连在另一个库上。正确做法: 先执行
SHOW DATABASES确认当前库,Python 侧统一用NEO4J_DATABASE环境变量,不要各处硬编码。
3.4 排序与分页
MATCH (m:Movie)
RETURN m.title AS `电影名`, m.rating AS `评分`
ORDER BY m.rating DESC
SKIP 1 LIMIT 2; // 跳过第 1 条,取接下来 2 条四、条件、聚合与 WITH 管道
图里从左到右就是数据流:MATCH 找出一批「行」,后面每个子句都在加工这批行。书写顺序就是逻辑数据流的顺序,这一点和 SQL 不同——SQL 的 SELECT 写在最前但语义上执行在中间。
⚠️ 说的是逻辑顺序:物理上怎么跑由查询规划器决定,它会重排算子、下推过滤、换索引。PROFILE 出来的计划和你写的顺序对不上是正常的,那正是优化在起作用。
4.1 WHERE:过滤
MATCH (p:Person)
WHERE p.age >= 25 AND p.age <= 30
RETURN p.name, p.age;常用操作符:
| 写法 | 含义 |
|---|---|
= <> > < >= <= |
比较 |
IN ['a','b'] |
在列表中 |
CONTAINS / STARTS WITH / ENDS WITH |
字符串包含 / 前缀 / 后缀 |
=~ 'A.*' |
正则匹配 |
IS NULL / IS NOT NULL |
属性是否存在 |
NOT (u)-[:LIKES]->(m) |
模式不存在(图特有,SQL 里要写 NOT EXISTS 子查询) |
模式谓词还有一种更通用的写法 —— EXISTS { } 子查询(Neo4j 5.x)。简单的「这条边在不在」两种都行,但需要在判断里加过滤条件、多跳、甚至 WITH 时,只有子查询写得下:
WHERE NOT EXISTS {
MATCH (u)-[:LIKES]->(rec)
WHERE rec.year > 2020 // ← 这种条件没法塞进裸模式谓词
}本笔记为了保持行短,简单场景仍用裸模式写法。
4.2 聚合:Cypher 没有 GROUP BY
MATCH (d:Director)-[:DIRECTED]->(m:Movie)
RETURN d.name AS `导演`,
collect(m.title) AS `作品`,
count(m) AS `作品数`,
avg(m.rating) AS `平均评分`
ORDER BY avg(m.rating) DESC;机制:非聚合列自动成为分组键 上面这句没有写
GROUP BY d.name,但结果确实是按导演分组的。 Cypher 的规则是:RETURN/WITH里凡是不在聚合函数内的表达式,自动组成分组键。这里只有d.name是非聚合的,所以按它分组。这条规则的副作用是:不小心多
RETURN一个高基数的列(比如m.title),分组立刻碎成一行一组,count()全变成 1。查到「聚合结果不对」时,先数一下有几个非聚合列。
collect() 值得单独记:它把多行收成一个列表,是把图查询结果整理成结构化上下文的主力函数,GraphRAG 里到处都是它。
4.3 WITH:管道里的闸门
WITH 做两件 RETURN 做不到的事:在聚合之后再过滤,以及把中间结果传给下一段。
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
WITH p, count(m) AS movieCount // 先聚合
WHERE movieCount >= 2 // 再对聚合结果过滤
RETURN p.name, movieCount
ORDER BY movieCount DESC;⚠️ WITH 之后变量就不见了 错误操作:
MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH p, count(m) AS n RETURN p.name, n, m.title // ← 这里用 m实际结果: 报错
Variable 'm' not defined。原因:
WITH是管道上的闸门,只有被它显式列出来的变量才能流到后面。m没写进WITH,从这一行起就不存在了。正确做法: 需要什么就带上什么——把
m聚合成列表带过去:WITH p, count(m) AS n, collect(m.title) AS titles。
4.4 OPTIONAL MATCH:图里的 LEFT JOIN
MATCH (p:Person)
OPTIONAL MATCH (p)-[:DIRECTED]->(m:Movie)
RETURN p.name, m.title; // 没导演过片子的人,m.title 是 null,但人还在结果里普通 MATCH 匹配不上就整行丢掉;OPTIONAL MATCH 匹配不上则保留左边、右边给 null。GraphRAG 扩展上下文时几乎只能用 OPTIONAL MATCH——不然一部电影缺了制作公司,整条记录就没了。
4.5 UNWIND:把列表摊成多行
collect() 的逆操作,批量写入的核心:
UNWIND [
{title: 'Avatar', released: 2009, rating: 7.9},
{title: 'Titanic', released: 1997, rating: 7.8}
] AS row
MERGE (m:Movie {title: row.title})
SET m.released = row.released, m.rating = row.rating;Python 侧只要传一个 list 参数进来,一次网络往返就能写一大批——比循环执行几千条语句快得多(具体差多少取决于网络延迟、事务大小、约束和索引数量,别把某个数量级当成保证值)。
⚠️ 但不要一次传十万行:整个列表要在客户端序列化、在服务端反序列化,并且整个事务的变更都得留在内存里直到提交。实践上按 1000~10000 行分批,每批一个事务,既有批量收益又不至于把堆撑爆。
五、关系查询:多跳、可变长度、最短路径
5.1 多跳:把箭头接起来就行
// 演员 → 电影 ← 导演:注意第二个箭头是反向的
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Director)
RETURN p.name AS `演员`, m.title AS `电影`, d.name AS `导演`;5.2 共同出演:同一个模式绕回来
MATCH (p1:Person)-[:ACTED_IN]->(m:Movie)<-[:ACTED_IN]-(p2:Person)
WHERE p1.name < p2.name
RETURN p1.name AS `演员 1`, p2.name AS `演员 2`, m.title AS `共同电影`;WHERE p1.name < p2.name 不是业务条件,是去重技巧:这个模式会同时匹配到 (Alice, Bob) 和 (Bob, Alice),用字典序卡一下只留一份。它顺带也排除了自己和自己配对的情况。
5.3 可变长度:跳数不确定时
// 1 到 3 跳内,和 Alice 有任何关联的节点
MATCH path = (p:Person {name: 'Alice'})-[*1..3]-(other)
RETURN path LIMIT 5;[*1..3] 是 Cypher 相对 SQL 最不可替代的能力——SQL 里「跳数不定」只能靠递归 CTE,写起来和读起来都痛苦。
⚠️
[*]不加上界会把库拖死 错误操作:MATCH path = (a)-[*]-(b) RETURN path实际结果: 查询长时间不返回,内存飙升,严重时整个实例失去响应。
原因: 路径数量随跳数指数增长。一个平均度数为 10 的图,走 6 跳就是百万量级的路径,而且每条路径都要在内存里保留。
正确做法: 永远写上界:
-[*1..3]-。业务上真需要「任意深度」,用shortestPath()(见下)或图算法库,不要用裸[*]枚举。⚠️ 别指望
LIMIT救你。LIMIT限制的是返回行数,不保证执行器在凑够这些行之前少探索路径 —— 起点选得差时,它可能已经把半张图走完了才吐出第一行。真正的护栏是四样:跳数上界、关系类型、方向、以及一个选择性高的起点(最好走索引定位)。
5.4 最短路径
MATCH path = shortestPath(
(a:Person {name: 'Alice'})-[*]-(b:Person {name: 'Charlie'})
)
RETURN path;shortestPath() 里的 [*] 比裸枚举安全得多,但不是无条件安全:
- 通常走的是专门的最短路径算法(双向 BFS),找到一条就停,不枚举所有路径。
- 但当路径上带了规划器无法在遍历过程中判定的谓词时,它可能退化成 exhaustive search(穷举),代价直接回到
[*]的量级。 - 最坑的是两点之间根本不存在合格路径的情况:算法必须把可达范围全部走完才能确认「没有」,这时候没有任何提前退出的机会。
所以仍然建议写上界:shortestPath((a)-[*..6]-(b))。想要全部等长最短路径用 allShortestPaths(),同样加上界。上线前用 PROFILE 看一眼计划里是不是 ShortestPath 而不是退化后的 VarLengthExpand。
六、MERGE、索引与约束
6.1 MERGE = 找不到才建
MERGE (p:Person {name: 'Eve'})
ON CREATE SET p.age = 30, p.createdAt = timestamp() // 只在新建时执行
ON MATCH SET p.lastSeen = timestamp() // 只在已存在时执行
RETURN p;🆚 对应 SQL 的 INSERT ... ON DUPLICATE KEY UPDATE / UPSERT。
用 MERGE 还是 CREATE,判断依据是这份数据有没有业务幂等键:
- 有(用户、商品、电影、概念这类实体,靠 name/id 唯一标识)→ 用
MERGE,脚本重跑不会长出重复节点。 - 没有(事件、日志、交易流水、每一次评分 —— 同样的内容出现两次就是真的发生了两次)→ 就该用
CREATE,硬套MERGE反而会把两笔真实记录合并成一笔,属于数据丢失。
⚠️ MERGE 匹配的是「整个模式」,不是主键 错误操作:
MERGE (p:Person {name: 'Alice', age: 30}) // 第一次跑 MERGE (p:Person {name: 'Alice', age: 31}) // Alice 过生日了,再跑一次实际结果: 库里出现两个 Alice。
原因: MERGE 是拿花括号里的全部属性去找完全匹配的节点。
age从 30 变成 31,就匹配不上原来那个,于是整体新建。正确做法: 花括号里只放业务主键,会变的属性交给
ON CREATE SET/SET:MERGE (p:Person {name: 'Alice'}) SET p.age = 31;再配一条唯一约束兜底(下一节)。
6.2 约束与索引
// 唯一约束:保证不重复,并自动附带一个索引
CREATE CONSTRAINT person_name_unique IF NOT EXISTS
FOR (p:Person) REQUIRE p.name IS UNIQUE;
// 属性必填 —— ⚠️ Enterprise Edition 独有,Community 上执行会报错
CREATE CONSTRAINT movie_title_required IF NOT EXISTS
FOR (m:Movie) REQUIRE m.title IS NOT NULL;
// 普通索引:加速按属性查找
CREATE INDEX movie_title_index IF NOT EXISTS FOR (m:Movie) ON (m.title);
SHOW CONSTRAINTS;
SHOW INDEXES;
DROP INDEX movie_title_index IF EXISTS;⚠️ 约束类型分社区版和企业版,别照抄 唯一约束(
IS UNIQUE)Community 就有,这是本笔记唯一依赖的约束类型。 但属性存在性约束(IS NOT NULL)和节点键约束(IS NODE KEY)是 Enterprise Edition 功能 —— 用文中的neo4j:5镜像(Community)执行上面那条movie_title_required,会直接报错说该约束类型不受支持。Community 上要保证「必填」,只能靠应用层校验,或者导入前先过滤掉缺字段的行。用
SHOW CONSTRAINTS也验证不出来 —— 它只会告诉你这条约束压根没建上。
💡 唯一约束是 MERGE 的安全网 —— 前提是键对得上 上一节那个「两个 Alice」的坑,如果
Person.name上有唯一约束,第二条 MERGE 会直接报错而不是悄悄建重。宁可让脚本挂掉,也不要在图里养出一堆重复节点——重复节点会让后面所有多跳查询的计数全部失真。但它只在约束的属性和
MERGE花括号里的键一致时才兜得住:约束建在Person.name,而你MERGE (p:Person {email: ...}),那这层保护完全没生效。同理,MERGE一整个复杂模式(比如同时带上关系和两端节点)时,唯一约束管的还是单个节点,模式层面的重复它拦不住。顺序上:先建约束,再导数据。已经有重复数据时约束建不上,得先清洗。
查询慢先看执行计划
EXPLAIN只出计划不执行,PROFILE真跑一遍并给出每步实际扫了多少行(db hits)。PROFILE MATCH (m:Movie {title: 'The Matrix'}) RETURN m;计划里出现
NodeByLabelScan说明在扫整个标签。这不自动等于「必须建索引」——标签本身节点很少、或者这个查询本来就要返回大部分节点时,全标签扫描可能正是最优计划,加索引反而多一层间接。判断依据看三个数:估算行数 vs 实际行数(差太多说明统计信息过时)、db hits、以及实际耗时。确实是「从大标签里按属性挑极少数节点」才建索引,建好后计划应该变成
NodeIndexSeek。
6.3 更新与删除
MATCH (p:Person {name: 'Alice'}) SET p.age = 31; // 改属性
MATCH (p:Person {name: 'Alice'}) SET p:VIP; // 加标签
MATCH (p:Person {name: 'Bob'}) REMOVE p.city; // 删属性
MATCH (n:TempNode) DELETE n; // 只能删没有关系的节点
MATCH (p:Person {name: 'Bob'}) DETACH DELETE p; // 连关系一起删
MATCH (n) DETACH DELETE n; // ⚠️ 清空整库,慎用⚠️ DELETE 删不掉带关系的节点 错误操作:
MATCH (p:Person {name:'Alice'}) DELETE p实际结果: 报错
Cannot delete node<0>, because it still has relationships.原因: 图里不允许存在「悬空关系」(一端没有节点的边),所以必须先删关系。
正确做法: 用
DETACH DELETE,它会先断开该节点所有关系再删节点。反过来,删关系不影响节点:
MATCH (p)-[r:ACTED_IN]->(m) DELETE r只删边。
七、Python 驱动:连接、封装与事务
7.1 起一个 Neo4j
docker run -d --name neo4j \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/your_neo4j_password \
neo4j:57474= Browser(浏览器里写 Cypher,学习期的主战场)7687= Bolt(程序连接用的二进制协议)
密码默认至少 8 位,否则容器启动会失败。这是配置项 dbms.security.auth_minimum_password_length 的默认值,可以改 —— 但没有理由为了图省事去调低它。Docker 相关操作见 docker。
pip install neo4j python-dotenv.env:
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=your_neo4j_password
NEO4J_DATABASE=neo4j7.2 最小连接示例
import os
from dotenv import load_dotenv
from neo4j import GraphDatabase
load_dotenv()
driver = GraphDatabase.driver(
os.getenv("NEO4J_URI", "bolt://localhost:7687"),
auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD")),
)
driver.verify_connectivity() # 连不上立刻抛异常,别等到第一次查询才发现
with driver.session(database="neo4j") as session:
result = session.run("MATCH (p:Person) RETURN p.name AS name")
print([r["name"] for r in result])
driver.close()driver 与 session 的生命周期完全不同
- driver 是重的:内部维护连接池,整个应用建一个就够,别在每个函数里
GraphDatabase.driver(...)。- session 是轻的:不是线程安全的,每次操作新建、用完就关,所以永远配
with。这正是把它包成一个 Client 类的理由——把「重的东西单例、轻的东西即用即弃」这条规则锁在一个地方,而不是散落在每个调用点。展开讨论见 基础设施为什么要封装成 Client。
7.3 封装成客户端
class Neo4jClient:
def __init__(self, uri: str, user: str, password: str, database: str = "neo4j"):
if not password:
raise ValueError("请先在 .env 中设置 NEO4J_PASSWORD")
self.driver = GraphDatabase.driver(uri, auth=(user, password))
self.database = database
self.driver.verify_connectivity()
def close(self) -> None:
self.driver.close()
def execute_query(self, query: str, parameters: dict | None = None) -> list[dict]:
"""执行查询,并在 session 内把结果转成普通 dict 返回"""
with self.driver.session(database=self.database) as session:
result = session.run(query, parameters or {})
return [record.data() for record in result]
def get_acted_in_movies(self, person_name: str) -> list[dict]:
query = """
MATCH (p:Person {name: $person_name})-[r:ACTED_IN]->(m:Movie)
RETURN m.title AS title, r.roles AS roles
"""
return self.execute_query(query, {"person_name": person_name})注意 execute_query 里的列表推导写在 with 内部——这不是风格问题,是必须的,原因见 7.5。
7.4 参数化:$param 而不是字符串拼接
# ✅ 正确
query = "MATCH (p:Person {name: $name}) RETURN p"
client.execute_query(query, {"name": user_input})
# ❌ 错误
query = f"MATCH (p:Person {{name: '{user_input}'}}) RETURN p"⚠️ 拼字符串既不安全也不快 错误操作: 用 f-string 把用户输入拼进 Cypher。
实际结果: 一是注入风险(输入里带个
'}) DETACH DELETE n //就能删库);二是每个不同的值都生成一条不同的查询文本,Neo4j 的查询计划缓存完全失效,每次都要重新编译。原因: 参数化的语句文本是固定的,值单独传输,所以既不参与语法解析,也能复用执行计划。
正确做法: 值一律走
$param。
但参数能绑什么、不能绑什么,是随版本变化的,这里要分清三件事:
| 要动态化的东西 | 能不能参数化 | 写法 |
|---|---|---|
值(属性值、列表、LIMIT 的数字) |
✅ 一直可以 | {name: $name} |
| 属性名 | ✅ 一直可以 | n[$prop](动态属性访问) |
| 标签 / 关系类型 | ⚠️ 分版本 | Neo4j 5.26+ 起 MATCH/MERGE/CREATE/SET 支持 (n:$($label));更早的版本不支持 |
| 索引、约束等 DDL 里的标识符 | ❌ 仍然不行 | 只能拼字符串 |
所以「标签只能拼字符串」这句话在 5.26 之后已经不成立了 —— 但建索引这类 DDL 依然只能拼,下面的场景还是躲不掉:
# 白名单:标签和属性名都必须校验,一个都不能漏
_ALLOWED_LABELS = {"Person", "Movie", "Director", "Company"}
_ALLOWED_PROPS = {"name", "title", "released", "rating", "birthYear"}
def create_index(self, label: str, prop: str) -> None:
if label not in _ALLOWED_LABELS:
raise ValueError(f"不支持的标签:{label}")
# TODO(human): 校验 prop
query = f"CREATE INDEX {label}_{prop}_index IF NOT EXISTS FOR (n:{label}) ON (n.{prop})"
self.execute_query(query)🔴 只校验一半的白名单等于没校验 错误操作: 只把
label过了白名单,prop原样拼进语句 —— 而且它同时出现在索引名和属性表达式两个位置。实际结果:
prop里带上)或空格就能改变语句结构。就算 Bolt 协议不让一次跑多条语句、挡住了「顺手删库」,攻击者仍然可以让你建出一堆预期之外的索引(每个索引都要占磁盘、拖慢所有写入),或者构造出必然报错的 DDL 把导入流程整个卡住。原因: 参数化保护的是值;凡是要变成语句「结构」的东西——标签、关系类型、属性名、索引名——都绕过了这层保护,必须自己挡。这段代码的注释写着「白名单,不是可选项」,却只挡了两个标识符里的一个,属于安全意识到位但执行不完整,比完全没意识更容易漏过 review。
正确做法: 拼进语句的每一个标识符都要校验。索引名最好由你自己按已校验的部分拼出来,而不是让外部输入影响它。
7.5 事务:写操作的正确姿势
from neo4j import Transaction
def create_movie_tx(tx: Transaction, title: str, year: int, rating: float) -> dict:
query = """
CREATE (m:Movie {title: $title, released: $year, rating: $rating})
RETURN m.title AS title, m.released AS released
"""
result = tx.run(query, title=title, year=year, rating=rating)
record = result.single() # ← 必须在事务函数内部消费
return record.data() if record else {}
with driver.session(database="neo4j") as session:
movie = session.execute_write(create_movie_tx, "Tenet", 2020, 7.3)session.execute_write() 会自动开启事务、成功则提交、抛异常则回滚,并且遇到瞬时错误(网络抖动、集群主从切换)会自动重试。
「重试所以要幂等」这句话要说准确 常见的误解是「重试会让
CREATE建出重复节点」。大多数情况下不会 —— 失败的事务已经回滚了,重试是在一张干净的牌桌上重来。真正需要当心的是两处:
- 提交成功但响应丢了。 服务端已落盘,客户端没收到确认,于是驱动重试 —— 这一次
CREATE就是实打实的第二次执行。概率低,但在集群切换时并不罕见。- 事务回调里的外部副作用。 回调里发了 HTTP 请求、写了文件、发了消息,这些不在事务里,回滚不掉。所以事务函数应该只做数据库操作,副作用留到事务成功返回之后。
至于
MERGE,它主要防的其实是脚本被重跑、上游数据里有重复行这类更日常的情况。综合下来结论不变:写实体节点默认用MERGE;但写事件、流水这种「本来就该每行新建」的数据,仍然用CREATE(见 6.1)。
⚠️ ResultConsumedError:把 Result 带出了事务 错误操作:
def bad_tx(tx): return tx.run("MATCH (m:Movie) RETURN m") # 直接把 Result 返回出去 result = session.execute_read(bad_tx) print(result.single()) # 在事务外面才读实际结果: 抛出
ResultConsumedError。原因:
Result是一个流式游标,不是已经取回内存的数据。事务一结束游标就失效,此时再去读什么都没有。正确做法: 在事务函数内部就把它消费掉,转成普通 Python 对象再返回——
record.data()、[r.data() for r in result]都行。7.3 里execute_query把列表推导写在with内部,是同一个道理。
7.6 批量导入 CSV
不要一行一条 CREATE,读进来一次性 UNWIND:
import csv
from pathlib import Path
def load_movies_from_csv(self, csv_path: str) -> int:
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as f:
movies = [
{"title": r["title"], "released": int(r["released"]), "rating": float(r["rating"])}
for r in csv.DictReader(f)
]
query = """
UNWIND $movies AS row
MERGE (m:Movie {title: row.title})
SET m.released = row.released, m.rating = row.rating
RETURN count(m) AS processed
"""
return self.execute_query(query, {"movies": movies})[0]["processed"]encoding="utf-8-sig" 是为了吃掉 Excel 导出 CSV 时带的 BOM,否则第一个列名会变成 title,r["title"] 直接 KeyError。
🆚 Cypher 原生还有 LOAD CSV WITH HEADERS FROM 'file:///movies.csv',但它读的是服务端 import 目录——Docker 部署要挂卷、Windows 上路径尤其容易踩坑。用 Python 读文件再 UNWIND 传参,跨平台省心得多。
八、从业务问题到图模型
学 Neo4j 最容易走偏的地方是背 Cypher 语法,真正难的是把业务翻译成节点和关系。
8.1 五步法
- 先写业务问题:用户到底要查什么?(问题写不出来,模型就没法验收)
- 找实体:句子里的名词 → 节点
- 找关系:句子里的动词 → 关系
- 补属性:用来过滤、排序、展示的字段 → 属性
- 写查询:用 Cypher 回答第 1 步那些问题,答不出来就回去改模型
8.2 示例 A:电商订单图谱
业务问题:某用户买过什么?买过同一商品的人还买了什么?某订单经历了哪些状态?
(User)-[:PLACED]->(Order)-[:CONTAINS]->(Product)
(Product)-[:BELONGS_TO]->(Category)
(Order)-[:PAID_BY]->(Payment)
(Order)-[:HAS_STATUS]->(OrderStatus)// 「买过同一商品的用户还买了什么」——协同过滤,一句话
MATCH (u:User {id: $uid})-[:PLACED]->(:Order)-[:CONTAINS]->(p:Product)
MATCH (p)<-[:CONTAINS]-(:Order)<-[:PLACED]-(other:User)
MATCH (other)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Product)
WHERE NOT (u)-[:PLACED]->(:Order)-[:CONTAINS]->(rec)
RETURN rec.name, count(*) AS score ORDER BY score DESC LIMIT 10;8.3 示例 B:客服知识图谱
业务问题:用户问题匹配哪条 FAQ?故障属于哪个模块?方案依赖什么前置条件?
(Question)-[:SIMILAR_TO]->(FAQ)-[:SOLVED_BY]->(Solution)
(FAQ)-[:BELONGS_TO]->(ProductModule)
(Solution)-[:REQUIRES]->(Condition)这个模型让「订单 ORD-001 为什么不能取消」这类问题可以带着真实状态回答:先按订单号定位节点,顺关系拿到当前状态,再由状态找到适用的 FAQ 和处理方案——而不是返回一段泛泛的规则文本。
8.4 建模时反复出现的三个判断
💡 属性还是节点? 判断标准:这东西自己有没有属性、关系和生命周期。
常见的误解是「要按 genre 查询就必须建 Genre 节点」—— 不对,
MATCH (m:Movie {genre: 'Sci-Fi'})完全能查,给genre建个索引就够快了。真正该升级成
(:Genre)节点的信号是:
- 它自己需要带属性(分类的描述、排序权重、启用状态);
- 它要连到别的东西(Genre 之间有父子层级、某个 Genre 关联运营活动);
- 它要独立增删改、有自己的生命周期,而不是跟着电影走;
- 查询要从它出发做多跳(「这个类型下的高分片,它们的导演还拍过什么」)。
只是拿来显示和等值过滤 —— 留成属性,更省事也更快。
💡 关系上放属性,还是拆成节点? 关系属性适合「这次连接的元数据」:
ACTED_IN {roles: ['Neo']}、DIRECTED {year: 1999}。 但如果这个中间物本身有生命周期、要被别的东西引用(比如一次「评分」要能被点赞、被举报),就该拆成节点:(User)-[:GAVE]->(Rating)-[:ON]->(Movie)。
💡 关系类型要不要细分?
-[:RELATED {type: '基于'}]->和-[:BASED_ON]->两种写法都能跑。 细分成具体类型的查询更快(Neo4j 按关系类型做遍历过滤),语义也更清楚;用统一类型 +type属性则更灵活,适合关系种类由数据决定、事先列不全的场景(比如从文本里抽出来的知识图谱)。先验知识明确就细分,开放抽取就统一。
九、GraphRAG 实战
9.1 Neo4j 在链路里负责哪一段
对比着看:真正的增量在「召回之后」。普通 RAG 拿到 Top-K 个彼此孤立的文本片段就直接进 prompt;GraphRAG 多做两步——沿关系扩展、组织成结构化上下文,再交给模型。
至于「怎么找到入口节点」这一步,向量召回只是其中一条路,并非必经之路。常见的还有:从问题里做实体抽取 + 实体链接直接命中节点、用全文索引按关键词定位、把问题套进预置的 Cypher 模板、或者在大图上先取社区摘要再下钻。图谱不大时后几种往往比向量召回更准也更省事。
一句话记住分工:向量库负责「找得到」,Neo4j 负责「说得清关系」,LLM 负责「讲成人话」。
9.2 为什么纯向量检索答不了关系问题
问「BERT 和 Transformer 是什么关系」,向量检索会召回一堆同时提到这两个词的段落,但这层关系有没有被某段文字明确写出来,是碰运气的。而图里这条边是确定存在的:
MATCH (a:Concept {name: 'BERT'})-[r:RELATED]->(b:Concept {name: 'Transformer'})
RETURN r.type; // → '基于'同理,「哪些技术属于 Agent 架构」这种按分类聚合的问题,向量检索天然做不好(它按相似度排序,不保证召全),图里就是一次属性过滤。
9.3 关键的一步:沿关系扩展上下文
拿到入口节点之后,把它周围的结构化信息一次性捞出来:
MATCH (movie:Movie {title: $title})
OPTIONAL MATCH (movie)<-[:DIRECTED]-(director:Director)
OPTIONAL MATCH (actor:Person)-[:ACTED_IN]->(movie)
OPTIONAL MATCH (movie)-[:PRODUCED_BY]->(company:Company)
RETURN movie.title AS title,
director.name AS director,
collect(DISTINCT actor.name) AS actors,
company.name AS company;三个要点:
- 必需的用
MATCH,可缺的才用OPTIONAL MATCH。这里入口那句是MATCH(电影都找不到就该返回空),导演/演员/公司才是OPTIONAL—— 缺一个制片公司不该让整条上下文消失。全都写成OPTIONAL会让「查无此电影」也返回一行全是 null 的结果,反而更难排查。 - 用
collect(DISTINCT ...)收成列表——多跳会产生笛卡尔行,不去重的话演员名字会重复好几遍,白白挤占上下文窗口。 ⚠️ 连着写多个「一对多」的OPTIONAL MATCH时(比如 10 个演员 × 3 个公司)行数是相乘的,会放大成 30 行。要么像这里一样每个多值字段各自collect(DISTINCT ...),要么用WITH分段先收一批再接着查。 - 只捞需要的字段,别
RETURN movie——整个节点的所有属性会被原样塞进 prompt。如果你把 embedding 也存在了节点上(见 9.5,这取决于你的架构选择),那就是上千个浮点数直接进上下文窗口。
9.4 一个能跑的最小 GraphRAG
class GraphRAG:
def __init__(self, client: Neo4jClient):
self.client = client
def retrieve_context(self, entities: list[str]) -> str:
"""第 1-2 步:拿实体去图里捞关系,拼成给模型看的文本"""
query = """
MATCH (c:Concept) WHERE c.name IN $names
OPTIONAL MATCH (c)-[r:RELATED]->(other:Concept)
RETURN c.name AS name,
c.description AS desc,
collect(DISTINCT r.type + ' → ' + other.name) AS relations
"""
rows = self.client.execute_query(query, {"names": entities})
return "\n".join(
f"- {r['name']}({r['desc']})\n 关系:{'; '.join(r['relations']) or '无'}"
for r in rows
)
def answer(self, question: str, entities: list[str], llm) -> str:
"""第 3-5 步:塞进 prompt 交给模型"""
context = self.retrieve_context(entities)
prompt = f"""请根据以下知识图谱信息回答问题。
知识图谱中的相关信息:
{context}
用户问题:{question}
要求:优先使用图谱中的关系作答;图谱信息不足时,明确说明哪部分是你补充的。"""
return llm.invoke(prompt)entities 从哪来?两条路:小图谱直接用 LLM 做一次实体抽取(或按节点名做关键词匹配);大图谱先向量召回节点,再从召回结果扩展。后者就是 9.1 图里画的那条路径。
9.5 Neo4j 自己的向量检索
Neo4j 从 5.11 引入向量索引、5.13 起转正(GA),可以在一句 Cypher 里完成「召回 + 扩展」:
CREATE VECTOR INDEX movie_embedding_index IF NOT EXISTS
FOR (m:Movie) ON (m.embedding)
OPTIONS { indexConfig: {
`vector.dimensions`: 1024,
`vector.similarity_function`: 'cosine'
} };
CALL db.index.vector.queryNodes('movie_embedding_index', 3, $queryVector)
YIELD node, score
OPTIONAL MATCH (node)<-[:DIRECTED]-(d:Director)
RETURN node.title AS title, d.name AS director, score
ORDER BY score DESC;维度必须和 embedding 模型的实际输出一致,写进索引的向量维度对不上会直接失败。
⚠️ 上面写 1024 是因为阿里云百炼的 text-embedding-v4 默认输出 1024 维 —— 但这类模型不少支持在调用时指定输出维度(同一个模型也能出 768 或 512)。所以别照抄数字,用你实际的调用参数发一条请求、len(vector) 量一下再填。
💡 该用它吗 用:图谱规模不大、想少维护一套组件、召回后马上就要沿关系扩展 —— 省掉一次跨服务往返,运维也简单一截。 不用:向量规模到千万级以上、需要精细的索引参数调优(量化、分片、多种索引结构)、或者向量本来就有独立于图的生命周期。这些是 Milvus 这类专门向量库的主场。
⚠️ 别再用「Neo4j 不支持过滤」当理由 —— Cypher 本来就能在
queryNodes之后接WHERE做后置过滤,而且向量检索的过滤能力在 5.x 之后一直在增强。选 Milvus 的正当理由是规模和调优深度,不是功能有无。组合成 向量库召回 → Neo4j 扩展 → LLM 生成 是一种常见架构,但不是唯一解;图谱小的时候全放 Neo4j 也完全够用,少一个组件就少一处要运维的东西。
9.6 Agent 记忆图谱
Agent 的记忆通常是一个 List 存最近 N 条对话,问题是它只有时间顺序,没有关联。用图存「经历」就能回答「我以前用什么工具解决过类似问题」:
(Agent)-[:ENCOUNTERED]->(Problem)-[:SOLVED_WITH]->(Tool)
(Problem)-[:PRODUCED]->(Result)-[:TAUGHT]->(Experience)// 遇到新问题时,找相似问题当时用了什么工具、效果如何
MATCH (p:Problem)-[:SOLVED_WITH]->(t:Tool)
WHERE p.category = $category
OPTIONAL MATCH (p)-[:PRODUCED]->(r:Result)
RETURN t.name, count(*) AS used, avg(r.score) AS avgScore
ORDER BY avgScore DESC;Agent 侧的整体设计见 Agent 概念。
十、常见陷阱
前面各节已经就地展开了六个:CREATE 重复建节点(3.2)、WITH 之后变量消失(4.3)、[*] 无上界(5.3)、MERGE 匹配整个模式(6.1)、DELETE 带关系的节点(6.3)、ResultConsumedError(7.5)。这里补齐其余的,严重程度和前面那批相当,只是没有合适的地方就地展开。
⚠️ OPTIONAL MATCH 后面的 WHERE 不会过滤掉行 错误操作:
MATCH (p:Person) OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie) WHERE m.rating > 8.5 RETURN p.name, m.title;期望:「只返回演过高分片的人」。
实际结果: 所有人都在结果里,没演过高分片的人
m.title是null。原因: 这个
WHERE属于OPTIONAL MATCH子句,它参与的是「可选匹配」本身——条件不满足时右半边给 null,左半边的行照样保留。正确做法: 想真过滤,把条件挪到后面独立的一段:
MATCH (p:Person) OPTIONAL MATCH (p)-[:ACTED_IN]->(m:Movie) WITH p, m WHERE m.rating > 8.5 RETURN p.name, m.title;
⚠️ 多跳查询里 count 莫名其妙翻倍 错误操作:
MATCH (d:Director)-[:DIRECTED]->(m:Movie)<-[:ACTED_IN]-(a:Person) RETURN d.name, count(m) AS `电影数`;实际结果: 一部有 3 个演员的电影被数成 3 部。
原因: 图匹配返回的是路径行,不是节点。一部电影有几个演员就产生几行,
count(m)数的是行数。正确做法:
count(DISTINCT m)。同理,收集列表用collect(DISTINCT ...)。这是多跳查询里最高频的统计错误。
⚠️ 版本差异导致语句直接报错 错误操作: 抄一段网上的 Cypher,报
Unknown function 'exists'或Invalid input 'IF'。原因与对照:
写法 情况 换成 exists(n.prop)判断属性存在,5.x 已移除 n.prop IS NOT NULLCREATE INDEX ... IF NOT EXISTS老版 4.x 不支持 去掉 IF NOT EXISTS,用 try/except 吞掉「已存在」CREATE FULLTEXT INDEX需要 4.4+ / 5.x 降级用 WHERE m.title CONTAINS 'xx'CREATE VECTOR INDEX需要 5.11+ 向量放外部向量库 session.write_transaction()4.x 驱动 API 5.x 用 session.execute_write()正确做法: 动手前先
CALL dbms.components() YIELD versions确认版本,别照抄不知道版本的博客。
⚠️ 别名里有空格或以数字开头 错误操作:
RETURN p.name AS 姓 名、RETURN count(m) AS 2024年产量实际结果: 语法错误。
原因: 标识符不能含空格、不能以数字开头。(纯中文别名在多数版本能直接用,但一旦掺进空格、
-或数字开头就会挂。)正确做法: 统一用反引号包起来,一劳永逸:
AS `姓 名`、AS `2024年产量`。
⚠️ 把 MERGE 的返回值当成「新增了几条」 错误操作:
UNWIND $rows AS row MERGE (m:Movie {title: row.title}) RETURN count(m) AS imported;然后把
imported当作「本次新增数量」打日志。实际结果: 无论数据是不是全都已存在,返回的数字都等于传入的行数。
原因:
count(m)数的是 MERGE 处理过的行,匹配到的和新建的都算。正确做法: 想区分就读事务统计:
summary = session.run(query, rows=rows).consume() print(summary.counters.nodes_created, summary.counters.properties_set)
⚠️ 大段原文直接塞进节点属性 错误操作: 把整篇文档正文存成
(:Doc {content: "...5 万字..."})。实际结果: 库体积暴涨、查询变慢,而且一个
RETURN d会把这坨文本整个塞进 prompt。原因: Neo4j 的存储和缓存是为遍历关系优化的。超大属性值会挤占页缓存 —— 而页缓存本该用来放那些让多跳查询变快的节点和关系记录。
正确做法: 图里只放
doc_id、title、summary,正文留在向量库或对象存储,需要时按 id 回查。⚠️ 注意这是成本权衡,不是能力缺失:Neo4j 有全文索引(
CREATE FULLTEXT INDEX,见上面的版本对照表),关键词检索它做得了。真正的问题是数据规模、更新频率和缓存效率 —— 几百字的summary放进去建全文索引完全合理,五万字的正文就不划算了。
速查表
Cypher 子句速查
| 分类 | 写法 | 说明 |
|---|---|---|
| 创建 | CREATE (n:Label {k: v}) |
无条件新建节点 |
CREATE (a)-[:REL {k: v}]->(b) |
新建关系(a、b 需先 MATCH) | |
MERGE (n:Label {key: v}) |
找不到才建,配 ON CREATE SET / ON MATCH SET |
|
| 查询 | MATCH (n:Label) |
按标签找 |
MATCH (a)-[:REL]->(b) |
顺关系走一跳 | |
MATCH (a)-[:REL*1..3]-(b) |
可变长度,必须写上界 | |
MATCH p = shortestPath((a)-[*]-(b)) |
最短路径 | |
OPTIONAL MATCH |
左连接,匹配不上给 null | |
| 过滤 | WHERE n.p > 1 / IN / CONTAINS / =~ |
条件 |
WHERE NOT (a)-[:REL]->(b) |
模式不存在 | |
WHERE n.p IS NOT NULL |
属性存在(5.x 写法) | |
| 聚合 | count() count(DISTINCT) sum() avg() max() min() |
无需 GROUP BY |
collect(DISTINCT x) |
多行收成列表 | |
| 管道 | WITH a, count(b) AS n WHERE n > 1 |
聚合后过滤;未列出的变量会丢失 |
UNWIND $rows AS row |
列表摊成多行,批量写入 | |
| 修改 | SET n.p = v / SET n:NewLabel |
改属性 / 加标签 |
REMOVE n.p / REMOVE n:Label |
删属性 / 删标签 | |
| 删除 | DELETE r |
删关系 |
DETACH DELETE n |
删节点连同其关系 | |
| 排序 | ORDER BY x DESC SKIP n LIMIT m |
排序分页 |
| 索引 | CREATE CONSTRAINT ... REQUIRE ... IS UNIQUE |
唯一约束(自带索引) |
CREATE INDEX ... FOR (n:L) ON (n.p) |
普通索引 | |
SHOW INDEXES / SHOW CONSTRAINTS |
查看 | |
| 诊断 | EXPLAIN / PROFILE |
看计划 / 看实际 db hits |
🆚 Cypher ↔ SQL 对照
| SQL | Cypher | 差别 |
|---|---|---|
FROM t JOIN t2 ON ... |
MATCH (a)-[:REL]->(b) |
关系已落盘,不用现算 |
WHERE |
WHERE |
基本一致 |
GROUP BY |
无——非聚合列自动分组 | 少写一句,但也容易分组分错 |
HAVING |
WITH ... WHERE |
聚合后过滤只能靠 WITH |
SELECT |
RETURN(写在最后) |
书写顺序 = 逻辑数据流顺序(物理执行由规划器决定) |
LIMIT / OFFSET |
LIMIT / SKIP |
顺序上 SKIP 写在前 |
INSERT ... ON DUPLICATE KEY UPDATE |
MERGE ... ON CREATE/MATCH SET |
MERGE 按整个模式匹配 |
LEFT JOIN |
OPTIONAL MATCH |
后接的 WHERE 语义不同(见第十章) |
| 递归 CTE | [:REL*1..3] |
图的核心优势 |
EXISTS (SELECT ...) |
WHERE (a)-[:REL]->(b) |
模式直接当布尔用 |
💡 选型决策
| 这类数据 / 需求 | 放哪 |
|---|---|
| 实体、实体间的关系、结构化属性 | Neo4j |
| 多跳推理、路径查找、关联推荐 | Neo4j |
| 文档正文、语义相似检索 | 向量库(Milvus / Chroma) |
| 交易流水、对账、财务记账 | 关系库(MySQL / PostgreSQL)——理由是数据模型和生态,不是 Neo4j 没有事务 |
| 大批量聚合报表 | 关系库 / 数仓 |
| 热点缓存、会话 | Redis |
AI 后端的常见组合就是这几样各司其职:关系库存业务主数据 → Neo4j 存实体关系 → 向量库存语义索引 → LLM 组织答案。
复习重点
复习重点
- 图快在哪:index-free adjacency——走一跳不查全局索引,代价只跟该节点度数有关,所以跳数越多、库越大优势越明显;一跳查询未必有性能优势,但可能因为建模理由仍然选图。
- Cypher 的执行模型:书写顺序 = 逻辑数据流顺序(物理执行由规划器重排);
WITH是管道上的主要闸门,没被它列出来的变量后面就用不到;聚合后想过滤只能靠WITH。- 没有 GROUP BY:
RETURN里所有非聚合列自动成为分组键——聚合结果不对时,先数非聚合列有几个。- MERGE 匹配的是整个模式:花括号里只放业务主键,可变属性交给
SET,再用唯一约束兜底(约束键必须和 MERGE 键一致才管用)。有幂等键的实体用MERGE,事件流水这类「重复就是真发生了两次」的数据用CREATE。- 多跳必去重:图返回的是路径行不是节点,
count(DISTINCT x)和collect(DISTINCT x)是默认写法。- Python 三条铁律:driver 全局单例、session 即用即弃、Result 必须在事务内消费完。值和属性名一律走参数(
$param/n[$prop]);标签在 5.26+ 可以用$()动态化,但索引约束这类 DDL 仍只能拼字符串 —— 拼进去的每一个标识符都要过白名单,漏一个就等于没做。- 版本是这门技术的隐藏参数:属性存在性约束是 Enterprise 独有、向量索引 5.11 起且能力持续演进、动态标签要 5.26+。抄任何 Cypher 前先确认版本和版别。
- GraphRAG 的增量在召回之后:「沿关系扩展」+「组织成结构化上下文」。入口节点不一定靠向量召回——实体链接、全文索引、Cypher 模板都可以。分工是:找得到、说得清关系、讲成人话。
素材来自项目 wolin_learn-master/neo4j_examples/:neo4j_cypher_complete.cypher(Cypher 18 部分)、neo4j_python_guide.py(Neo4jClient 封装)、README.md(建模与 GraphRAG 场景)、《Neo4j 图数据库指导》课程讲义。