引子:你可能被"传统 RAG"骗了
做 RAG 的人几乎都从这套组合拳开始:向量库(Milvus/PgVector)做语义检索 + ES 的 BM25 做关键词检索 + ReRank 精排。
它们很强,但有个共同的"盲区"------只认"单个文档",不认"文档之间的关系" 。
- Milvus 是"语义相近就捞出来",本质是拿着你的问题去比相似度;
- ES 的 BM25 是"分词后从倒排索引里字面命中",本质是找包含关键词的那几段;
- ReRank 是"在捞出来的候选里再排个序"。
你会发现,它们全都停留在 "单点式检索" :能告诉你"这篇文档里有珍珠奶茶的做法",却回答不了"珍珠奶茶 → 用了哪种珍珠 → 这种珍珠怎么煮 → 适合谁喝"这种连锁问题。
向量和倒排索引是"精准找货"的工具,但搞不清"货与货的来龙去脉"。
而这恰恰是 Agent 场景下 RAG 最需要的能力:基于关联去推导答案、梳理知识体系。传统 RAG 拿到的是一堆"信息孤岛",碎片化、无结构、无关联------想让它"能推理、能关联、能解释",光靠向量是不够的。
这就是 GraphRAG 要解决的问题:在"语义匹配 + 关键词匹配"之外,补上一块"图谱关联匹配"。
传统 RAG:问题 → 向量/关键词 → 一堆孤立文档 → 拼答案
GraphRAG:问题 → 图谱多跳关联 → 一条关系链 → 有据可依的答案
一、为什么是 Neo4j?
说到知识图谱,绕不开 Neo4j ------ 一款原生图数据库。
它和 MySQL 的差别,不是"换个数据库",而是换了一种世界观:
| MySQL | Neo4j |
|---|---|
| 存储核心:表、行、列 | 存储核心:节点、关系 |
| 查询语言:SQL | 查询语言:Cypher |
| 擅长的事:查记录 | 擅长的事:查关系、多跳、因果链路 |
用一句话区分它们的心态:MySQL 关注"这条数据是什么",Neo4j 关注"这条数据和别的数据是什么关系"。
把散落的文档、实体、关键词像知网那样串起来,就形成了一张清晰的知识图谱。举个奶茶的例子,你在 Neo4j 里可以这样一路走通:
珍珠奶茶 --包含--> 珍珠 --制作--> 煮制 --适合--> ...
一条多跳的全链路检索就此打通。这正是 Milvus 和 ES 的短板:它们能检索"点",却串不起"线",更画不出"网"。
二、三分钟把 Neo4j 跑起来
别急着写代码,先把数据库用 Docker 拉起来。这套 docker-compose.yml 已经把关键配置都备好了:
services:
neo4j:
image: neo4j:latest
container_name: neo4j-container
ports:
- "7474:7474" # Web 管理界面(浏览器打开就能点着玩 Cypher)
- "7687:7687" # Bolt 协议(代码连接用的就是这个端口)
environment:
- NEO4J_AUTH=neo4j/12345678 # 账号 neo4j / 密码 12345678
- NEO4J_PLUGINS=["apoc"] # 装上 APOC 插件,后面扩展好用
- NEO4J_dbms_security_procedures_unrestricted=apoc.*
volumes:
- ./volumes/neo4j/data:/data # 数据持久化,别让容器重启就丢图
- ./volumes/neo4j/logs:/logs
- ./volumes/neo4j/conf:/conf
restart: unless-stopped
两个端口记住就够:7474 是给人看的(Web UI),7687 是给代码连的(Bolt) 。
docker compose up -d
# 浏览器打开 http://localhost:7474 ,用 neo4j / 12345678 登录
登录后你可以直接在 Web 界面里敲 Cypher,边写边看图谱实时长出来------这是学习图数据库最爽的地方:所见即所得。
三、Cypher 实战:手搓一张奶茶知识图谱
理论说再多,不如亲手建一张图。下面这套语句,直接抄进 Neo4j 就能跑。
1. 建节点:给每种"实体"一个标签
// 奶茶品类与品牌
CREATE (product: Product {name: '珍珠奶茶'})
CREATE (type1: Type {name: "台湾奶茶"})
CREATE (type2: Type {name: "港式奶茶"})
CREATE (brand1: Brand {name: "蜜雪冰城"})
CREATE (brand2: Brand {name: "古茗"})
// 常见配料
CREATE (ing1: Ingredient {name: "珍珠"})
CREATE (ing2: Ingredient {name: "芋圆"})
CREATE (ing3: Ingredient {name: "果糖"})
CREATE (ing4: Ingredient {name: "红茶"})
CREATE (ing5: Ingredient {name: "牛奶"})
// 制作工艺 & 适宜人群
CREATE (method1: Method {name: "煮制"})
CREATE (method2: Method {name: "冲泡"})
CREATE (people1: People {name: "年轻人"})
CREATE (people2: People {name: "学生"})
CREATE (people3: People {name: "甜食爱好者"})
(变量: 标签 {属性}) 就是 Cypher 里"造一个节点"的语法。标签(Label)相当于给节点分类,检索时靠它筛选。
2. 建关系:知识图谱真正的灵魂
节点只是"珠子",关系才是把珠子串成项链的"线"。图的价值,90% 在关系上:
// 珍珠奶茶 属于 台湾奶茶
MATCH (p:Product {name: "珍珠奶茶"}), (t:Type {name: "台湾奶茶"})
CREATE (p)-[:属于]->(t)
// 珍珠奶茶 包含 各种配料
MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: "珍珠"})
CREATE (p)-[:包含]->(i)
MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: "牛奶"})
CREATE (p)-[:包含]->(i)
// ......芋圆、果糖、红茶同理
// 珍珠奶茶 适合 各类人群
MATCH (p:Product {name: "珍珠奶茶"}), (people:People {name: "学生"})
CREATE (p)-[:适合]->(people)
注意这里的套路:先 MATCH 找到两个已存在的节点,再 CREATE 一条关系 。-[:关系名]-> 里的箭头方向非常重要,它是图语义的一部分。
3. 查询验证:从"看全图"到"多跳推理"
// 看全图(调试用,数据量大时别乱跑)
MATCH (n)-[r]->(m)
RETURN n, r, m
// 多跳关联:珍珠奶茶 → 配料 → 制作工艺
MATCH (p:Product {name: "珍珠奶茶"})-[:包含]->(i)-[:制作]->(m)
RETURN p.name, i.name, m.name
// 珍珠奶茶适合哪些人
MATCH (p:Product {name: "珍珠奶茶"})-[:适合]->(people)
RETURN p.name, people.name
看到那条 -[:包含]->(i)-[:制作]->(m) 了吗?两个关系连起来,就是一次"多跳查询" ------这就是 GraphRAG 推理能力的来源。向量检索永远做不到这种"顺藤摸瓜"。
4. 更新与删除:图也是要维护的
// 更新属性
MATCH (p:Product {name: "珍珠奶茶"})
SET p.calorie = "中高热量", p.taste = "甜香"
// 删关系(只断开,不删节点)
MATCH (p:Product {name: "珍珠奶茶"})-[r:适合]->(s:People {name: "学生"})
DELETE r
// 删节点前必须先删它的关系,否则会报错"节点仍有关系"
MATCH (i:Ingredient {name: "芋圆"})-[r]-()
DELETE r, i
// 清空全图(慎用)
MATCH (n) DELETE n
踩坑预警:Neo4j 里删节点前必须先删掉它的所有关系,否则会报
Cannot delete node, because it still has relationships。上面-[r]-()不写方向,就是为了"匹配任意方向的所有关系",一次删干净。
四、代码里怎么连 Neo4j:neo4j-driver 上手
Web UI 玩熟了,就得让代码连上。最底层的 neo4j-driver 长这样:
import neo4j from 'neo4j-driver';
const driver = neo4j.driver(
"bolt://localhost:7687",
neo4j.auth.basic("neo4j", "12345678"),
);
const session = driver.session();
// 建节点
await session.run(`
CREATE (p:Product {name: "珍珠奶茶"})
CREATE (i:Ingredient {name: "珍珠"})
`);
// 建关系
await session.run(`
MATCH (p:Product {name: "珍珠奶茶"}), (i:Ingredient {name: "珍珠"})
CREATE (p)-[:包含]->(i)
`);
// 查询并读取结果
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);
});
// 用完记得关,不然连接泄漏
await session.close();
await driver.close();
关键就三个动作:session.run(cypher) 执行、record.get('别名') 取结果、close() 收尾。RETURN 里起的别名,就是你在代码里 get() 的钥匙。
五、重头戏:用 LangGraph 搭一条 GraphRAG 流水线
底层会了,接下来把整套 text2cypher 的 GraphRAG 工作流拼起来------这是整篇最有含金量的部分。
我们用的是 @langchain/community 的 Neo4jGraph + @langchain/openai 的 LLM + @langchain/langgraph 编排。整体是一条四节点的直线流水线:
翻译成人话就是:解析问题 → 生成 Cypher → 查图谱 → 组织答案。

第一步:定义状态(State)
LangGraph 是"状态机"思维,先把流转中的数据槽位定义好:
const state = {
messages: {
// 自定义 reducer:新消息永远 append,不覆盖
value: (left, right) => left.concat(Array.isArray(right) ? right : [right]),
default: () => [],
},
query: null,
cypher: null, // LLM 生成的 Cypher(text2cypher)
context: null, // 图谱检索结果
answer: null,
}
messages 那个 value 函数就是 reducer ------ 决定"新值怎么合并进旧值"。这里定成"追加",这样多轮对话的消息不会被吃掉。
第二步:四个节点,各司其职
节点 1 · parse:把最后一句用户问题拎出来
async function parseQuestion(state) {
const lastMessage = state.messages[state.messages.length - 1]
return { query: lastMessage.content }
}
节点 2 · generateCypher:让 LLM 把自然语言翻译成 Cypher
这一步是整个 GraphRAG 的心脏。Prompt 里必须把图谱的 schema 喂给 LLM,否则它就是在黑盒里瞎猜:
async function generateCypher(state) {
const prompt = `
你是一个专业的 Neo4j Cypher 生成器。
严格按照下面的结构生成正确语句,只返回 Cypher 代码,不要任何解释、不要标点、不要 markdown。
节点:
- Product: 奶茶品牌
- Ingredient: 配料
- Type: 奶茶类型
- Method: 制作工艺
- People: 适合人群
关系方向 (必须严格遵守):
- (Product)-[:属于]->(Type)
- (Product)-[:包含]->(Ingredient)
- (Product)-[:适合]->(People)
- (Ingredient)-[:使用]->(Method)
规则:
1. 关系方向绝对不能反
2. 多跳查询使用多个 MATCH,不能连错路径
3. 只返回最终可运行的 Cypher 语句
用户问题: ${state.query}
`
const res = await llm.invoke([new HumanMessage(prompt)])
return { cypher: res.content }
}
节点 3 · executeGraphQuery:拿 Cypher 去查图,查不到要兜底
async function executeGraphQuery(state) {
try {
const res = await graph.query(state.cypher)
return { context: JSON.stringify(res) }
} catch (err) {
return { context: "未查询到相关知识" } // 关键:别让异常炸掉整条链
}
}
节点 4 · generateAnswer:基于图谱上下文组织人话答案
async function generateAnswer(state) {
const prompt = `
你是奶茶专家,根据下方[检索结果]回答用户问题,检索结果为空或不足时简要说明无法从图谱得到答案,不要编造。
回答要求:
- 直接列出事实,不要推断图谱中未出现的配料(如水、冰、添加剂等)。
检索结果: ${state.context}
用户问题: ${state.query}
`
const res = await llm.invoke([new HumanMessage(prompt)])
return { answer: res.content }
}
注意这两处 prompt 里的"防幻觉"设计:检索为空就老实说查不到 、别脑补图谱里没写的配料。这是让 RAG 可信的关键所在。
第三步:把节点连起来,编译成可执行工作流
const workflow = new StateGraph({ channels: state })
.addNode("parse", parseQuestion)
.addNode("generateCypher", generateCypher)
.addNode("executeGraphQuery", executeGraphQuery)
.addNode("generateAnswer", generateAnswer)
.addEdge(START, "parse")
.addEdge("parse", "generateCypher")
.addEdge("generateCypher", "executeGraphQuery")
.addEdge("executeGraphQuery", "generateAnswer")
.addEdge("generateAnswer", END)
const app = workflow.compile()
编译后还能自动吐出 Mermaid 拓扑图,顺手校验一下流程对不对:
const drawable = await app.getGraphAsync()
const mermaid = drawable.drawMermaid({ withStyles: true })
console.log(mermaid)
最后把它跑起来,批量问三个问题:
async function runGraphRAG(question) {
const res = await app.invoke({ messages: [new HumanMessage(question)] })
console.log('用户问题:', question)
console.log('生成Cypher:', res.cypher)
console.log('检索结果:', res.context)
console.log('最终回答:', res.answer)
return res.answer
}
await Promise.all([
runGraphRAG('我们这款珍珠奶茶有哪些配料?'),
runGraphRAG('台式奶茶的饮品都有哪些配料?'),
runGraphRAG('珍珠奶茶适合哪些人群饮用?'),
])
把「生成 Cypher → 查图 → 出答案」三个环节都打印出来,每一步都透明可查 ------这正是 GraphRAG 优于黑盒向量 RAG 的地方:答案从哪来,一目了然,可信、可解释。
六、踩坑实录:GraphRAG 最容易翻车的三个地方
这套东西跑通不难,但有几个坑,我踩过,希望你别再踩。
坑一:图谱 schema 必须"内外一致"
我笔记里建关系用的是 (Ingredient)-[:制作]->(Method),但 generateCypher 的 prompt 里写成了 (Ingredient)-[:使用]->(Method)。
你建图用"制作",让 LLM 生成"使用",查出来当然是空。这是 text2cypher 最隐蔽的 bug------不报错,只是永远查不到。
text2cypher 的命门,是"你告诉 LLM 的 schema"和"你真实建图的 schema"必须严丝合缝。差一个字,全盘皆输。
坑二:让 LLM"只吐 Cypher",别吐废话
prompt 里那句"只返回 Cypher 代码,不要任何解释、不要 markdown"不是客套。LLM 天生爱解释,一旦它给你返回:
```cypher
MATCH (p:Product) RETURN p
```
这就带着 markdown 代码块围栏,直接丢进 graph.query() 会语法报错。约束它的输出格式,比事后清洗便宜一百倍。
坑三:查询失败要有兜底,整个链路别崩
executeGraphQuery 里那个 try/catch 不能省。LLM 生成的 Cypher 语法错误是家常便饭,一旦抛异常没接住,整条 LangGraph 流水线就断了。兜一句"未查询到相关知识",让流程优雅降级。
金句:RAG 工程的一半功力,在"查不到时怎么体面地退场"。
七、选型对照:向量、关键词、图谱,谁也别想独吞
最后来张"选型速查表"。它们不是替代关系,而是互补关系,各有不可替代的场景。
| 检索方式 | 底层原理 | 适合场景 |
|---|---|---|
| Milvus 向量语义检索 | 语义相似度 | 用户提问没有明确关键词(大白话)、意思相近即可、模糊泛化推荐、非结构化文档(笔记/手册/FAQ) |
| ElasticSearch BM25 | 分词 + 倒排索引 | 有明确专有名词/编号/文件名、需精准字面命中与高亮、官方文档/规章制度/接口文档检索、字段过滤排序 |
| Neo4j 知识图谱 | Cypher 关系推理 | 需实体关联、多跳推理、"A 和 B 什么关系"、逻辑推理与脉络梳理 |
一句话决策:
- 要"意思相近" → 向量(Milvus)
- 要"字面精准" → 关键词(ES)
- 要"关系推理" → 图谱(Neo4j)
真正的下一代 RAG,是图谱关联 + 语义匹配 + 关键词匹配的多重检索组合拳------让答案更精准、更具解释性。
结语:RAG 的下半场是"结构"
回头看这一路,从 docker-compose 起库,到手搓奶茶知识图谱,再到 LangGraph 编排 text2cypher 四节点流水线------你其实只做了一件事:
把"信息孤岛"重新连成一张"知识网络"。
向量库帮你找到"点",ES 帮你锚定"点",而知识图谱帮你把点连成"线"和"网"。当 RAG 能顺着关系链推理、能对每个答案给出依据,它才算真正跨过了"能查"到"能想"的门槛。
最后送一句:上一代 RAG 拼的是"谁能检索到",下一代 RAG 拼的是"谁能把关系讲清楚"。而 GraphRAG + Neo4j,就是把这句话变成代码的最短路径。
下次再想加个"品牌 → 门店 → 区域"的新关系?在 Cypher 里补几条边的事------图的扩展性,就是让你加关系时,不用重构任何东西。