用 Neo4j + Cypher 手搓一个奶茶知识图谱,顺便搞清楚图数据库到底强在哪
先说一个扎心的真相
我做了这么久的 RAG,一直有这么一种恍惚感:
- Milvus / PgVector 的向量检索,能把"意思相近"的文档捞出来;
- ElasticSearch 的倒排索引 + BM25,能把"关键词命中"的文档捞出来;
- 再加一层 ReRank 精排,看起来已经挺完美了。
直到有一天,用户问我:"珍珠奶茶和台式奶茶到底啥关系?"
我:......
向量库沉默了。它俩在向量空间里可能确实挺像,但"像"和"是同一类"完全是两码事。 ES 也沉默了。这俩词确实都命中了,但 ES 只能告诉我"都出现了",没法告诉我"谁属于谁"。
这就是那句写在笔记里、越看越对的话:
它们无法捕捉数据之间的关联关系,只能实现"单点式"检索,难以应对需要挖掘数据内在逻辑、关联链路的场景。
翻译成人话:向量库和 ES 更像是"精准找货"的工具。但如果你想搞清楚货和货之间的关系、货的来龙去脉,它们就抓瞎了。
打个比方:
- 向量检索 ≈ 拿一张照片去海选相似的人脸;
- ES 检索 ≈ 拿着名字去点名册上打勾;
- 但如果你想知道"老王和小李是啥关系 ",你需要的不是点名册,而是一张社会关系网。
而这张"关系网",在数据库界有一个正经的名字:知识图谱。
Neo4j:一个专注"搞关系"的数据库
笔记里对 Neo4j 的定位写得很清楚:
Neo4j 是一款原生图数据库,以节点、关系存储数据,擅长高效查询实体间复杂关联关系。
它最大的特点是------不关注单个数据本身(关系型数据库、Milvus、ES 都关注单个数据),而是专注于存储和挖掘数据之间的关系。
我们熟悉的 MySQL,干的是"用 SQL 查询记录"这件事:
sql
SELECT * FROM milk_tea WHERE name = '珍珠奶茶';
你拿到的是一行行孤立的记录。
而 Neo4j 干的是"用 Cypher 查询节点间的关系":
cypher
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i)
RETURN p, i
你拿到的是一张有牵连的网。
笔记里那句形容我特别喜欢:
分散的文档、实体、关键词,像蜘蛛网一样串联起来,形成清晰的知识图谱,完美解决 Milvus 和 ES 无法捕捉数据之间关联关系的痛点。
传统 RAG 拿到的是碎片化信息,没结构、没关联,像一个个信息孤岛 。 而知识图谱要做的事,就是把这些孤岛之间架起桥。
比如把"奶茶、配料、制作工艺、适合人群"都塞进 Neo4j,就能轻松实现这条链路:
检索「珍珠奶茶」→ 关联到配料「珍珠」→ 串联到制作工艺 → 延伸到适合的消费场景
这就是传说中的多跳全链路检索 。于是 RAG 也就顺势进化成了 Agentic RAG + Graph RAG。
(GraphRAG = 基于图数据库实现的关联检索。这一篇我们先动手把图建起来,下一篇再让大模型自己来查。)
第一步:用 Docker 把 Neo4j 跑起来
老规矩,docker-compose.yml 伺候:
yaml
services:
neo4j:
image: neo4j:latest
container_name: neo4j-container
ports:
- "7474:7474" # Web 管理界面
- "7687:7687" # Bolt 协议(代码连接)
environment:
- NEO4J_AUTH=neo4j/12345678 # 账号:neo4j 密码:12345678
- NEO4J_PLUGINS=["apoc"] # 安装必备插件
- NEO4J_dbms_security_procedures_unrestricted=apoc.*
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/data:/data
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/logs:/logs
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/neo4j/conf:/conf
restart: no
几个必须划重点的地方:
- 两个端口分工不同 :
7474是给你我这种人类点开浏览器用的 Web 管理界面;7687是 Bolt 协议,专门给代码连的。别搞混了。 NEO4J_AUTH:默认账号neo4j,密码12345678(图方便,生产别这么干)。- APOC 插件 :图数据库界的"瑞士军刀",一堆实用过程函数都在里面,通过
NEO4J_PLUGINS=["apoc"]装,再配一句NEO4J_dbms_security_procedures_unrestricted=apoc.*放开权限。 - volumes 三件套:data / logs / conf 全给你挂出去,容器删了数据还在,这才是老玩家的姿势。
docker compose up -d 一把梭,浏览器打开 http://localhost:7474,看到登录框就说明活了。
第二步:用 Cypher 手搓一张奶茶人脉网
Neo4j 的查询语言叫 Cypher ,语法特别像在画图,圆括号 () 是节点,方括号 [] 是关系,箭头 -> 是方向。记住这三样,你已经会一半了。
1. 先建节点(把"人"请进通讯录)
cypher
CREATE (product:Product {name: '珍珠奶茶'})
CREATE (type1: Type {name: '台湾奶茶'})
CREATE (type2: Type {name: '港式奶茶'})
CREATE (brand1: Brand {name: '蜜雪冰城'})
CREATE (brand2: Brand {name: '古茗'})
cypher
CREATE (ing1: Ingredient {name: '珍珠'})
CREATE (ing2: Ingredient {name: '芋圆'})
CREATE (ing3: Ingredient {name: '果糖'})
CREATE (ing4: Ingredient {name: '红茶'})
CREATE (ing5: Ingredient {name: '牛奶'})
cypher
CREATE (method1: Method {name: '煮制'})
CREATE (method2: Method {name: '冲泡'})
CREATE (people1: People {name: '年轻人'})
CREATE (people2: People {name: '学生'})
CREATE (people3: People {name: '甜食爱好者'})
(变量名:标签 {属性}) ------ 变量名是临时的,标签(Product / Ingredient / Brand / Type / Method / People)才是它的"身份证类别"。
2. 再拉关系(这才是图谱的灵魂)
光有一堆节点,那叫"通讯录";有了关系,才叫"人脉网"。
cypher
MATCH (p:Product {name: '珍珠奶茶'}), (t:Type {name: '台湾奶茶'})
CREATE (p)-[:属于]->(t)
cypher
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '珍珠'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '芋圆'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '果糖'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '红茶'})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: '珍珠奶茶'}), (i:Ingredient {name: '牛奶'})
CREATE (p)-[:包含]->(i)
cypher
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '年轻人'})
CREATE (p)-[:适合]->(peo)
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '学生'})
CREATE (p)-[:适合]->(peo)
MATCH (p:Product {name: '珍珠奶茶'}), (peo:People {name: '甜食爱好者'})
CREATE (p)-[:适合]->(peo)
注意这里的套路:先用 MATCH 把两个节点"找到",再用 CREATE 在它俩之间"牵线" 。[:属于]、[:包含]、[:适合] 就是关系的类型,方向由箭头决定。
于是"珍珠奶茶"这个节点,瞬间就有了:一类出身(台湾奶茶)、五个配料(珍珠/芋圆/果糖/红茶/牛奶)、三拨粉丝(年轻人/学生/甜食爱好者)。这才叫社会关系丰富。
3. 查询验证(看看网织得对不对)
先看一眼整张网:
cypher
MATCH (n)-[*]->(m)
RETURN n, m
[*] 是"任意长度的关系路径",相当于说"把这张网整个端上来"。(数据量小的时候随便浪,数据量大了慎用,容易把机器问懵。)
然后是重头戏------多跳关联查询,这才是 GraphRAG 的看家本领:
cypher
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i)-[:使用]->(m)
RETURN p.name, i.name, m.name
这一句干了什么?从「珍珠奶茶」出发 → 沿「包含」找到配料 → 再沿「使用」找到制作工艺,一口气跳了两跳,把"奶茶-配料-工艺"整条链路拎出来了。
再补一句"适合谁":
cypher
MATCH (p:Product {name: '珍珠奶茶'})-[:适合]->(people)
RETURN p.name, people.name
对了,配料和工艺之间也得牵个线,不然上面那条两跳路径就断在中间:
cypher
MATCH (i:Ingredient {name: '珍珠'}), (m:Method {name: '煮制'})
CREATE (i)-[:使用]->(m)
4. 更新节点属性(给"人"补上备注)
cypher
MATCH (p:Product {name: '珍珠奶茶'})
SET p.calorie = "中高热量", p.taste = "甜香"
cypher
MATCH (i:Ingredient {name: '珍珠'})
SET i.origin = "台湾", i.hard = "Q弹"
SET 就是"改属性",可以一次改好几个,用逗号隔开。(顺便灵魂拷问:珍珠奶茶中高热量这件事,终于被写进数据库了。)
5. 删除(这里有坑,重点看)
删关系:
cypher
MATCH (p:Product {name: '珍珠奶茶'})-[r:适合]->(s.People {name: "学生"})
DELETE r
删节点(这个节点刚好没别的关系,可以直删):
cypher
MATCH (t:Type {name: "港式奶茶"})
DELETE t
删一个"还带着关系"的节点------这就是新人最容易踩的坑:
cypher
MATCH (i:Ingredient {name: '芋圆'})-[r]-()
DELETE r, i
注意 [r]-() 这里故意不写方向、也不写关系类型 :因为要先把"挂在芋圆身上的所有关系"一条条找出来,先 DELETE r 把线剪断,再删节点 i 。Neo4j 有个硬规矩:一个节点只要还有关系连着,就不许直接删。 你必须先把线剪了。
实在懒得一条条剪?那就上终极大招:
cypher
MATCH (n)
DETACH DELETE n
DETACH DELETE 的 DETACH 意思是"先自动把关系全拆了,再删节点",等于一键清空整个库------注意,是删库,慎用!
第三步:不写 Cypher 面板,用代码直接查
光在浏览器里点来点去不够爽,我们整段代码来跑。用官方的 neo4j-driver:
javascript
import neo4j from "neo4j-driver"
const driver = neo4j.driver(
"bolt://localhost:7687",
neo4j.auth.basic("neo4j", "12345678"),
);
// 获取会话
const session = driver.session();
注意地址用的是 bolt:// ,端口 7687------就是前面 docker-compose 里映射出来的那个 Bolt 协议端口。
接下来几件事,跟面板里干的一模一样。建数据:
javascript
async function createData() {
const result = await session.run(`
CREATE (p:Product {name: "珍珠奶茶"})
CREATE (i:Ingredient {name: '珍珠'})
`)
console.log(result)
}
建关系:
javascript
async function createRelation() {
const result = await session.run(`
MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: '珍珠'})
CREATE (p)-[:包含]->(i)
`)
console.log("关系创建成功", result)
}
查询 + 优雅地取值(这段值得收藏):
javascript
async function queryData() {
const result = await session.run(`
MATCH (p:Product {name: "珍珠奶茶"})-[r]->(i)
RETURN p, r, i
`)
result.records.forEach(record => {
console.log('奶茶:', record.get('p').properties.name);
console.log('关系:', record.get('r').type);
console.log('配料:', record.get('i').properties.name);
})
}
划重点:返回的不是普通对象,而是 records 数组 。每条记录都要用 record.get('别名') 取出对应节点,节点的业务属性藏在 .properties 里,关系的类型藏在 .type 里。第一次接触 Neo4j 的人十有八九会在这里卡一下。
更新:
javascript
async function updateData() {
await session.run(`
MATCH (p:Product {name: "珍珠奶茶"})
SET p.price = 15
`)
console.log("更新成功")
}
(给珍珠奶茶定价 15 块,感觉比某蜜接地气。)
删关系 / 删节点:
javascript
async function deleteRelation() {
await session.run(`
MATCH (p:Product {name: "珍珠奶茶"})-[r:包含]->(i:Ingredient {name: '珍珠'})
DELETE r
`)
console.log("删除关系成功")
}
async function deleteNode() {
await session.run(`
MATCH (p:Product {name: "珍珠奶茶"})
DELETE p
`)
console.log("删除节点成功")
}
最后,一定要记得还资源------这是驱动程序的铁律:
javascript
// 释放 session 与 driver 链接
await session.close()
await driver.close()
session 用完就关,driver 整个程序退出前也要关。不关的话连接池迟早被你自己占满,然后你就开始怀疑人生。
小结 & 下一集预告
到这儿,我们就用 Neo4j 给奶茶建好了一张关系网,手写 Cypher 完成了:建节点 → 拉关系 → 多跳查询 → 改属性 → 删(关系/节点/彻底清空) 的全套操作,还用 neo4j-driver 把它搬到了代码里。
但问题也随之而来:
用户问的是人话------"珍珠奶茶里都有啥配料 "。 但我每次都得手动翻译成
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i) RETURN ...。
这翻译官,我当一天可以,当一年不行。
所以下一篇 ,我们就让大模型自己来当这个翻译官:Text2Cypher + LangGraph,把"用户的自然语言"自动变成"Cypher 语句",再自动查图、自动总结答案,把整条 GraphRAG 链路彻底跑通。
顺便还会附上一张向量检索 vs 关键词检索 vs 图谱检索的三方对照表------毕竟都是成年人了,我们全都要。
下篇见。