很多团队做 RAG 时,第一反应是把文档切得更细、Embedding 模型换得更强、向量库参数调得更复杂。这样做当然有价值,但它解决的主要是"相似文本能不能被找回来"的问题。只要问题变成"谁和谁有什么关系""A 为什么会影响 B""从一个实体顺着关系追两跳后能得到什么结论",单纯向量检索就会开始吃力。
这篇文章围绕一个主结论展开:
Graph RAG 的核心价值,不是把 Neo4j 接进 RAG 流程这么简单,而是把检索对象从"相似文本片段"升级为"可查询、可追踪、可解释的实体关系"。工程上真正难的地方,也不在 LangChain API,而在图谱建模、关系方向、Cypher 生成约束、查询安全和图谱数据质量。
本文基于当前项目 neo4j-graphrag 的实际源码来讲。这个 demo 用 Neo4j 存储奶茶知识图谱,用 LangGraph 串起"用户问题 -> 生成 Cypher -> 查询 Neo4j -> 基于结果回答"的流程。案例不复杂,但它很好地暴露了 Graph RAG 的本质:RAG 不一定只能检索文本,也可以检索一张有结构的知识网络。
为什么传统 RAG 容易卡在"关系问题"上
先看一个很常见的问题:
台式奶茶的饮品都有哪些配料?
如果资料里有一句完整描述:"珍珠奶茶属于台式奶茶,包含珍珠、果糖、红茶和牛奶",向量检索大概率能找回来。但真实业务文档通常没有这么理想。信息可能散落在多个地方:
- 文档 A 说:珍珠奶茶属于台式奶茶。
- 文档 B 说:珍珠奶茶包含珍珠、果糖、红茶、牛奶。
- 文档 C 说:珍珠需要煮制。
向量检索擅长找"语义相近的段落",但不擅长保证这些段落之间的关系能被稳定拼起来。它返回的是一批文本块,文本块之间没有显式边。大模型拿到这些上下文后,可能答对,也可能因为上下文不完整而猜测。
ElasticSearch 这类 BM25 检索也类似。它擅长关键词、术语、编号、字段过滤,但它不会天然理解"珍珠奶茶 属于 台式奶茶""珍珠奶茶 包含 牛奶"这类结构关系。它能命中文档,却不能保证关系链路被正确展开。
可以把三种检索的差异理解成这样:
所以 Graph RAG 不是传统 RAG 的替代品,而是检索层的一种补强。它特别适合回答这类问题:
- A 属于哪个类别?
- A 包含哪些组成部分?
- A 和 B 是什么关系?
- 从 A 出发,经过某种关系能找到哪些对象?
- 某个结论能不能追溯到图谱里的具体路径?
如果你的知识库主要是政策条款、产品体系、组织架构、供应链、设备依赖、代码调用链、医学概念关系、金融主体关系,这类问题会非常多。此时只靠向量库,很容易把系统做成"看起来什么都能答,但关键关系经常不稳"的状态。
Graph RAG 到底在 RAG 链路里做了什么
Graph RAG 这个词容易被讲得很大,但落到工程里,它做的事情可以拆成两层。
第一层是建图:把业务知识抽象成节点、关系和属性。节点表示实体,例如产品、配料、类型、人群、工艺;关系表示实体之间的连接,例如属于、包含、适合、使用;属性补充实体细节,例如热量、口味、产地。
第二层是查图:当用户提问时,不再只用语义相似度找文本,而是把问题转成图查询语句,沿着关系路径拿到结构化结果,再交给大模型组织成自然语言答案。
在当前项目里,图谱模型可以简化为下面这张图:
这张图看起来很小,但它已经具备 Graph RAG 的关键能力。比如用户问"台式奶茶的饮品都有哪些配料",系统不能只查 Type,而要先从 Type 找到属于它的 Product,再从 Product 找到包含的 Ingredient。这就是多跳关系查询。
在传统 RAG 里,这一步通常交给大模型"读上下文后自己推"。在 Graph RAG 里,这一步会变成明确的图查询路径。区别很关键:前者依赖模型阅读和推断,后者依赖数据库执行关系查询。
项目结构:这个 demo 实际做了哪些事
当前项目目录比较克制,主要文件职责如下:
| 文件 | 职责 |
|---|---|
docker-compose.yml |
启动 Neo4j,开放 Web 管理端口和 Bolt 连接端口 |
cypher.md |
初始化奶茶知识图谱,包括节点、关系和验证查询 |
cypher2.md |
演示属性更新、关系删除、节点删除等基础操作 |
src/neo4j-test.mjs |
使用 neo4j-driver 直接连接 Neo4j 并执行 Cypher |
src/graphrag.mjs |
使用 LangGraph + LLM + Neo4jGraph 实现 Graph RAG 查询链路 |
.env |
配置模型名称、OpenAI 兼容接口地址和密钥等运行参数 |
如果只看功能,它做了两件事:
- 先证明 Neo4j 能存和查图谱。
- 再把 Neo4j 查询接入 RAG 流程,让 LLM 根据用户问题生成 Cypher。
这条链路可以画成:
注意这里没有 Embedding,也没有向量库。严格说,这个 demo 展示的是"纯图谱检索型 Graph RAG"。它已经能说明 Graph RAG 的基本机制,但如果放到真实知识库项目里,通常还会叠加向量检索和关键词检索,形成混合检索。
从 Neo4j 建模开始:节点、关系、方向比 API 更重要
Graph RAG 的第一步不是写 LangGraph,而是定义图谱 schema。很多人做 Graph RAG 翻车,不是因为不会调用 Neo4j,而是因为图谱建模太随意。
当前 demo 定义了五类节点:
Product:奶茶产品,例如珍珠奶茶。Type:奶茶类型,例如台式奶茶。Ingredient:配料,例如珍珠、果糖、红茶、牛奶。Method:制作工艺,例如煮制。People:适合人群,例如年轻人、学生、甜食爱好者。
关系方向也明确写死:
(Product)-[:属于]->(Type)(Product)-[:包含]->(Ingredient)(Product)-[:适合]->(People)(Ingredient)-[:使用]->(Method)
为什么要强调方向?因为图数据库里的关系不是普通标签,它会直接影响查询语句。如果方向混乱,LLM 生成 Cypher 时就会不稳定。例如"台式奶茶有哪些配料"理论上要反向从 Type 找 Product:
cypher
MATCH (p:Product)-[:属于]->(t:Type {name: "台式奶茶"})
MATCH (p)-[:包含]->(i:Ingredient)
RETURN p.name, i.name
如果模型误以为 (Type)-[:属于]->(Product),查询就会返回空结果。Graph RAG 的"可解释"建立在图谱结构正确之上,而不是建立在模型聪明之上。
当前 cypher.md 里初始化节点使用的是 CREATE:
cypher
CREATE (product:Product {name: "珍珠奶茶"})
CREATE (type1:Type {name: "台式奶茶"})
CREATE (ing1:Ingredient {name: "珍珠"})
CREATE (ing2:Ingredient {name: "果糖"})
教学 demo 用 CREATE 没问题,它直观、容易理解。但在工程里,我更推荐把可重复执行的导入脚本改成 MERGE:
cypher
MERGE (p:Product {name: "珍珠奶茶"})
MERGE (t:Type {name: "台式奶茶"})
MERGE (i:Ingredient {name: "珍珠"})
MERGE (p)-[:属于]->(t)
MERGE (p)-[:包含]->(i)
差异在于:CREATE 每执行一次都会新增一批节点,重复跑脚本会产生重复实体;MERGE 更接近"按唯一键存在则复用,不存在则创建"。真实知识库通常需要反复导入、修正、增量更新,导入幂等性不是锦上添花,而是基本要求。
更严谨一点,还应该给关键实体加唯一约束:
cypher
CREATE CONSTRAINT product_name IF NOT EXISTS
FOR (p:Product)
REQUIRE p.name IS UNIQUE;
CREATE CONSTRAINT ingredient_name IF NOT EXISTS
FOR (i:Ingredient)
REQUIRE i.name IS UNIQUE;
这个 demo 没有加约束,是为了降低学习成本。但只要进入多人协作或自动抽取阶段,就应该补上。否则同一个"珍珠奶茶"可能被写成多个节点,后续查询会出现重复、漏查或路径膨胀。
Neo4j 服务:Docker 配置背后的工程含义
项目用 docker-compose.yml 启动 Neo4j:
yaml
services:
neo4j:
image: neo4j:latest
container_name: neo4j-container
ports:
- "7474:7474"
- "7687:7687"
environment:
- NEO4J_AUTH=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: unless-stopped
这里有几个点值得单独说。
7474 是 Neo4j Browser 的 Web 端口,适合调试、看图、手写 Cypher。7687 是 Bolt 协议端口,代码连接 Neo4j 用它。很多初学者只记得打开 Web 页面,但应用真正依赖的是 Bolt 连接。
NEO4J_AUTH=neo4j/12345678 是本地 demo 配置,生产环境不能这样写。至少要把密码放入安全的环境变量或密钥系统里,并且按服务拆分只读账号和写入账号。Graph RAG 查询阶段原则上只需要读权限,不应该给它写权限。
APOC 是 Neo4j 常用扩展库。当前 demo 没有直接用 APOC,但打开它是合理的预留。真实项目里,APOC 常用于复杂数据处理、路径展开、批量导入、文本处理等场景。不过 APOC 权限也要收紧,不能在生产环境随便开放所有 unrestricted procedure。
先用 neo4j-driver 跑通基本查询
在引入 LangGraph 之前,项目先用 neo4j-driver 证明代码能连接图数据库。这个步骤很重要,因为它把问题拆开了:先确认 Neo4j 和 Cypher 没问题,再确认 LLM 生成查询没问题。
src/neo4j-test.mjs 的核心连接代码是:
js
import neo4j from "neo4j-driver"
const driver = neo4j.driver(
"bolt://localhost:7687",
neo4j.auth.basic("neo4j", "12345678")
)
const session = driver.session()
这段代码的位置在系统链路的最底层,负责和 Neo4j 建立 Bolt 连接。上层无论用不用 LangChain,最终都要执行 Cypher。
查询示例:
js
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)
})
}
这段代码做了三件事:
- 从
Product节点"珍珠奶茶"出发。 - 匹配所有从它发出的关系。
- 返回目标节点和关系类型。
它在工程上的价值,不是实现 RAG,而是验证图谱可用。很多 RAG 问题最后排查下来,并不是模型问题,而是数据库连接、数据没有写入、关系方向不一致、查询语句写错。先有这个最小查询脚本,排障会快很多。
不过当前脚本也有 demo 常见的简化:没有关闭 session 和 driver。真实服务里应该用 try/finally 释放资源:
js
const session = driver.session()
try {
const result = await session.run(cypher, params)
return result.records
} finally {
await session.close()
await driver.close()
}
如果你的应用是长期运行的服务,通常不会每次请求都创建和关闭 driver,而是复用 driver,在请求级别创建 session,用完关闭 session。连接生命周期要按应用类型设计。
LangGraph 工作流:把"查图"放进 RAG
当前 src/graphrag.mjs 才是 Graph RAG 的核心。它使用了四个关键依赖:
js
import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph"
import { ChatOpenAI } from "@langchain/openai"
import { StateGraph, END, START } from "@langchain/langgraph"
import { HumanMessage } from "@langchain/core/messages"
这些依赖分工很明确:
Neo4jGraph:负责执行图查询。ChatOpenAI:通过 OpenAI 兼容接口调用大模型。StateGraph:把多个步骤编排成工作流。HumanMessage:把 prompt 包装成模型消息。
Neo4j 连接部分:
js
const graph = new Neo4jGraph({
url: "bolt://localhost:7687",
username: "neo4j",
password: "12345678",
})
LLM 配置部分:
js
const llm = new ChatOpenAI({
model: process.env.MODEL_NAME,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
})
这里 temperature: 0 是一个正确选择。生成 Cypher 不是写诗,也不是头脑风暴,它需要稳定、可复现、尽量少发散。温度越高,模型越可能输出解释、Markdown 包裹、错误关系方向或不必要的查询逻辑。
当前工作流状态定义如下:
js
const state = {
messages: {
value: (left, right) => left.concat(Array.isArray(right) ? right : [right]),
default: () => [],
},
cypher: null,
context: null,
answer: null,
}
messages 用 reducer 合并消息,cypher 保存生成的查询语句,context 保存 Neo4j 查询结果,answer 保存最终回答。这个状态设计虽然简单,但已经体现了 RAG 链路的核心数据流:问题、检索指令、检索结果、生成结果。
源码里没有单独的 parseQuestion 节点,而是通过一个函数直接取最后一条用户消息:
js
function userQuery(state) {
const last = state.messages[state.messages.length - 1]
return last.content
}
这是当前源码和参考文档中旧版写法的一个差异。写文章或做代码走读时要以源码为准,因为工作流节点会影响你对链路的理解。
生成 Cypher:Graph RAG 最容易出错的一步
generateCypher 是整个 demo 里最值得认真看的函数:
js
async function generateCypher(state) {
const prompt = `
你是一个专业的 Neo4j Cypher 生成器。
严格按照下面的结构生成正确语句,只返回纯 Cypher 代码,不要任何解释、不要标点、不要 markdown。
节点:
- Product: 奶茶产品
- Ingredient: 配料
- Type: 奶茶类型
- Method: 制作工艺
- People: 适合人群
关系方向(必须严格遵守):
- (Product)-[:属于]->(Type)
- (Product)-[:包含]->(Ingredient)
- (Product)-[:适合]->(People)
- (Ingredient)-[:使用]->(Method)
用户问题:${userQuery(state)}
`
const res = await llm.invoke([new HumanMessage(prompt)])
return { cypher: res.content }
}
这段 prompt 的关键不是"请帮我生成 Cypher",而是明确给出 schema 和关系方向。原因很简单:LLM 不知道你的数据库结构,除非你告诉它。即使它知道 Neo4j 语法,也不知道你的关系到底叫 包含 还是 HAS_INGREDIENT,不知道方向是 Product -> Ingredient 还是 Ingredient -> Product。
这也是 Graph RAG 工程化的第一个判断:默认不要让模型自由发挥查询语句,应该让它在受控 schema 内生成。
更进一步,生产系统里还应该做几件事:
- 对模型输出做清洗,去掉 ```cypher 这类 Markdown 包裹。
- 只允许
MATCH、RETURN、WITH、WHERE等只读语句。 - 禁止
CREATE、MERGE、DELETE、SET、CALL dbms等写入或高危操作。 - 对关系类型、节点标签做白名单校验。
- 对查询超时、返回行数、路径深度设置限制。
否则 Graph RAG 很容易变成"让大模型直接操作数据库"。这在本地 demo 可以接受,在生产环境是明显风险。
一个简单的只读校验可以这样做:
js
function assertReadOnlyCypher(cypher) {
const normalized = cypher.trim().toLowerCase()
const forbidden = ["create", "merge", "delete", "detach", "set", "drop", "remove", "call"]
if (!normalized.startsWith("match")) {
throw new Error("Only MATCH queries are allowed")
}
if (forbidden.some(keyword => normalized.includes(keyword))) {
throw new Error("Write or unsafe Cypher is not allowed")
}
}
这个函数不能替代完整的 SQL/Cypher 安全方案,但至少表达了一个原则:LLM 生成的查询语句不能无条件执行。Graph RAG 不是把数据库权限交给模型,而是让模型在受控空间内生成检索计划。
执行图查询:为什么结构化结果比文本片段更可靠
生成 Cypher 后,源码调用 graph.query:
js
async function executeGraphQuery(state) {
try {
const res = await graph.query(state.cypher)
return { context: JSON.stringify(res) }
} catch (e) {
return { context: "未查询到相关知识" }
}
}
这里 context 不是从文档 chunk 里拼出来的,而是 Neo4j 返回的结构化查询结果。比如"珍珠奶茶有哪些配料"可能得到:
json
[
{ "p.name": "珍珠奶茶", "i.name": "珍珠" },
{ "p.name": "珍珠奶茶", "i.name": "果糖" },
{ "p.name": "珍珠奶茶", "i.name": "红茶" },
{ "p.name": "珍珠奶茶", "i.name": "牛奶" }
]
结构化结果的好处是边界清楚。大模型不需要从长段落里猜"哪些词是配料",而是拿到数据库已经确认的事实。
当然,这段代码也有可以优化的地方。catch 里直接返回"未查询到相关知识"会掩盖两类不同问题:
- 真的没有数据。
- Cypher 语法错误、连接失败、权限错误。
生产里应该把错误分类记录到日志,并给用户一个更稳妥的回答。例如:
js
async function executeGraphQuery(state) {
try {
assertReadOnlyCypher(state.cypher)
const rows = await graph.query(state.cypher)
return { context: JSON.stringify(rows) }
} catch (error) {
console.error("Graph query failed", {
cypher: state.cypher,
message: error.message,
})
return { context: "GRAPH_QUERY_FAILED" }
}
}
这样做不是为了让 demo 变复杂,而是为了避免系统把"查询失败"伪装成"没有知识"。这两个状态在业务上完全不同。
生成答案:让模型回答事实,不要补剧情
最后一步是根据检索结果生成答案:
js
async function generateAnswer(state) {
const prompt = `
你是奶茶专家,根据下方「检索结果」回答用户问题;检索结果为空或不足时简要说明无法从图谱得到答案,不要编造。
回答要求:
- 直接列出事实,不要推断图谱里未出现的配料(如水、冰、添加剂等)。
检索结果:${state.context}
用户问题:${userQuery(state)}
`
const res = await llm.invoke([new HumanMessage(prompt)])
return { answer: res.content }
}
这个 prompt 有一个非常重要的约束:不要推断图谱里没有的配料。奶茶常识里可能还有水、冰、糖浆、奶精,但如果图谱没返回,就不应该答出来。
这正是 RAG 的基本纪律:答案要由检索结果支撑,而不是由模型常识扩写。Graph RAG 只是把检索结果从文本换成了图查询结果,这条纪律没有变化。
如果放到企业知识库里,这个约束更重要。例如用户问"某合同涉及哪些供应商",系统只能回答图谱中查询到的供应商,不能因为模型知道行业常见供应商就补充一堆"可能包括"。知识库问答最怕看起来合理但没有证据的回答。
工作流编排:为什么用 LangGraph 而不是顺序调用函数
当前工作流这样定义:
js
const workflow = new StateGraph({ channels: state })
.addNode("generateCypher", generateCypher)
.addNode("executeGraph", executeGraphQuery)
.addNode("generateAnswer", generateAnswer)
.addEdge(START, "generateCypher")
.addEdge("generateCypher", "executeGraph")
.addEdge("executeGraph", "generateAnswer")
.addEdge("generateAnswer", END)
const app = workflow.compile()
从当前 demo 看,这条链路完全可以写成三个 await:
js
const cypher = await generateCypher(question)
const context = await executeGraphQuery(cypher)
const answer = await generateAnswer(question, context)
那为什么还要用 LangGraph?我的判断是:如果只是这个 demo,顺序函数更简单;如果你准备继续演进,LangGraph 的价值会逐渐显现。
Graph RAG 在真实系统里经常需要加入分支和回路:
- 生成 Cypher 后先做安全校验。
- 查询为空时改用向量检索兜底。
- Cypher 报错时让模型根据错误信息修复一次。
- 查询结果过多时做压缩或二次筛选。
- 高风险问题走人工确认或只返回引用。
这些逻辑用普通函数也能写,但当状态越来越多、分支越来越复杂,工作流编排会更清晰。当前 demo 用 LangGraph 是在为后续演进留位置。
可以把它理解成下面的时序:
与向量检索、BM25 的关系:默认推荐混合检索
Graph RAG 不是银弹。它解决的是关系检索和多跳推理问题,但不擅长所有检索问题。
| 方案 | 擅长 | 不擅长 | 默认建议 |
|---|---|---|---|
| 向量检索 | 语义相似、模糊表达、长文本问答 | 精确关系、强约束过滤、可解释路径 | 作为非结构化知识库的基础能力 |
| BM25/ES | 关键词命中、术语编号、字段过滤、排序 | 语义泛化、多跳关系 | 和向量检索互补,适合精确召回 |
| Neo4j/Graph RAG | 实体关系、多跳路径、层级体系、可解释链路 | 模糊语义召回、海量原文相似匹配 | 用于关系型问题,不建议单独承担全部检索 |
我更推荐的生产架构是混合检索:
比如企业产品知识库里,用户问"某个错误码是什么意思",ES 和向量检索可能更合适;用户问"这个错误码关联哪些模块、由哪些上游服务触发、最终影响哪些产品功能",Graph RAG 就更有价值。
换句话说,Graph RAG 不是把 Milvus 或 ES 干掉,而是补上它们不擅长的结构关系层。把三者混起来,才更像一个能落地的知识库检索系统。
从奶茶 demo 到真实业务:应该怎么升级
当前 demo 用奶茶做知识图谱,教学上很直观。但如果要迁移到真实业务,我建议按下面路径演进。
第一步,明确实体类型和关系类型,不要急着抽取所有信息。比如做企业知识库,可以先定义:
Product:产品。Feature:功能。Module:系统模块。ErrorCode:错误码。Document:文档。Owner:负责人或团队。
关系可以先控制在少数几类:
Product包含Feature。Feature依赖Module。ErrorCode发生于Module。Document描述Feature。Owner负责Module。
第二步,把自然语言文档抽成结构化三元组。三元组不是越多越好,而是越可控越好。一个低质量抽取器会把图谱变成噪声放大器。
第三步,建立实体归一化机制。比如"GraphRAG""Graph RAG""图谱增强 RAG"可能指同一个概念;"珍珠奶茶"和"珍奶"也可能指同一个产品。如果不做归一化,图谱会被同义词撕裂。
第四步,把 Graph RAG 接入现有 RAG,而不是另起一套孤立问答。很多问题既需要原文证据,也需要图谱路径。图谱告诉你"关系是什么",原文告诉你"依据在哪一段"。
一个更完整的数据链路可以是:
这里的难点不是 Neo4j 能不能写入,而是抽取、归一化、去重、版本管理、证据回溯。Graph RAG 的工程质量,很大程度上取决于这些前置治理。
参数与配置:哪些值会直接影响效果
当前项目里有几类配置值得关注。
第一类是 Neo4j 连接配置:
js
const graph = new Neo4jGraph({
url: "bolt://localhost:7687",
username: "neo4j",
password: "12345678",
})
本地 demo 这样写最直接,但生产环境要改成环境变量,并拆分权限。Graph RAG 查询服务通常只需要只读权限。即便模型输出了写入语句,数据库账号也应该让它执行失败。
第二类是模型配置:
js
const llm = new ChatOpenAI({
model: process.env.MODEL_NAME,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
})
MODEL_NAME 决定 Cypher 生成能力和中文理解能力。模型越弱,越需要更严格的 few-shot 示例和后置校验。OPENAI_BASE_URL 说明项目走的是 OpenAI 兼容接口,这让你可以切换到不同供应商,但也要注意模型兼容性和返回格式差异。
第三类是图谱 schema。这个配置不在 .env 里,而是写在 prompt 里。它其实比很多环境变量更关键。节点标签、关系类型、关系方向只要写错,Graph RAG 就会稳定地产生错误查询。
第四类是返回上下文格式:
js
return { context: JSON.stringify(res) }
小数据量时直接 JSON 序列化很方便。数据量变大后,需要考虑压缩和格式化,例如只保留字段名、实体名、路径摘要,不要把完整节点对象都塞给模型。否则上下文会迅速变长,成本和延迟都会上升。
当前 demo 的几个易错点
第一个易错点是重复执行初始化 Cypher。因为使用 CREATE,重复执行会创建重复节点。短期看只是图上多了几个点,长期会导致查询返回重复结果。工程里应改成 MERGE 并加唯一约束。
第二个易错点是关系方向。Graph RAG 的多跳查询依赖方向一致。建模时就要统一方向,prompt 里也要明确写出方向。不要一会儿写"产品包含配料",一会儿又写"配料属于产品"。
第三个易错点是把"查询失败"当成"没有知识"。当前 executeGraphQuery 捕获所有异常后返回"未查询到相关知识",适合演示,但不适合生产。生产要区分空结果、语法错误、连接错误和权限错误。
第四个易错点是让 LLM 生成任意 Cypher。Graph RAG 里最危险的不是模型答错,而是模型生成了不该执行的数据库语句。必须做只读限制、关键字黑名单、schema 白名单和数据库账号权限隔离。
第五个易错点是图谱太细或太粗。太细会导致路径爆炸,查询结果太多;太粗又表达不出有效关系。图谱建模要围绕问题类型设计,而不是把所有名词都抽成节点。
第六个易错点来自删除语句。cypher2.md 里关系删除示例写成了:
cypher
MATCH (p:Product {name:"珍珠奶茶"})-[r: 适合]->(s:People {name:"学生"})
DELETE r
关系类型前多了空格,实际写法应该是:
cypher
MATCH (p:Product {name:"珍珠奶茶"})-[r:适合]->(s:People {name:"学生"})
DELETE r
这类小问题在图数据库里很常见,所以我建议把关键 Cypher 放进可执行脚本或测试里,而不是只放在文档里。
工程建议:把 Graph RAG 做稳,而不是做炫
第一,先做小 schema,不要一上来做大而全的知识图谱。先围绕 5 到 10 类高频问题建模,比如"属于什么""依赖什么""影响什么""由谁负责""引用哪份文档"。Graph RAG 的收益来自可回答问题,而不是节点数量。
第二,图谱数据要有来源。每条边最好能追溯到文档、段落、抽取时间和抽取模型。否则当用户质疑答案时,你只能说"图谱里就是这么写的",这不够。
第三,LLM 生成 Cypher 要有双保险。前置 prompt 约束只能降低错误概率,不能当安全边界。后置校验和数据库权限才是安全边界。
第四,给查询结果做裁剪。多跳查询很容易返回大量路径,直接塞给模型会导致回答发散。建议按路径长度、关系类型、实体重要性、时间版本等维度做限制。
第五,不要把 Graph RAG 和向量 RAG 对立起来。图谱适合结构关系,向量适合语义召回,ES 适合关键词和过滤。真实业务里,混合检索通常比单一路线更稳。
第六,尽早建立评测集。Graph RAG 的评测不能只看"回答像不像",还要看生成的 Cypher 是否正确、关系路径是否正确、答案是否完全由查询结果支撑。可以把评测拆成三层:
- Cypher 生成准确率。
- 图查询召回准确率。
- 最终答案忠实度。
第七,保留可观测性。至少要记录用户问题、生成 Cypher、查询耗时、返回行数、最终回答和错误类型。Graph RAG 的问题常常出在中间步骤,不记录链路就很难排查。
一个更接近生产的 Graph RAG 骨架
基于当前 demo,可以把生产骨架整理成下面这样:
js
async function answerWithGraphRAG(question) {
const cypher = await generateCypherWithSchema(question)
const safeCypher = sanitizeCypher(cypher)
assertReadOnlyCypher(safeCypher)
assertSchemaAllowlist(safeCypher, {
labels: ["Product", "Ingredient", "Type", "Method", "People"],
relationships: ["属于", "包含", "适合", "使用"],
})
const rows = await queryNeo4j(safeCypher, {
timeoutMs: 3000,
maxRows: 50,
})
if (rows.length === 0) {
return "我没有在当前知识图谱中查询到可支撑答案的关系。"
}
return generateGroundedAnswer(question, rows)
}
这段代码不是为了替代当前 demo,而是表达工程上的边界:
generateCypherWithSchema负责生成查询。sanitizeCypher负责清理模型输出格式。assertReadOnlyCypher负责安全限制。assertSchemaAllowlist负责 schema 边界。queryNeo4j负责数据库访问和超时控制。generateGroundedAnswer负责基于结果回答,不负责编造新事实。
如果把这些职责混在一个函数里,demo 会短,但生产会难维护。
什么时候不该优先上 Graph RAG
Graph RAG 有价值,但不是所有知识库都应该第一天就上。
如果你的问题主要是"这段文档怎么解释""某个概念是什么意思""帮我总结某篇文章",向量 RAG 往往已经够用。此时强行建图,可能只是多了一套数据治理成本。
如果你的文档变化极快,但实体关系不稳定,Graph RAG 的维护成本会比较高。因为图谱需要持续更新,实体归一化和关系修正都要跟上。
如果你的团队还没有稳定的文档解析、切分、Embedding、检索评测,建议先把基础 RAG 做扎实。Graph RAG 是增强层,不应该用来掩盖基础链路的问题。
如果你的业务无法定义清楚实体和关系,也不适合急着上图数据库。图谱不是"把所有名词连起来",而是围绕问题类型设计可查询结构。
总结:Graph RAG 的重点是"关系可执行"
回到开头的结论:Graph RAG 的关键不是多引入一个 Neo4j,也不是让大模型看起来更聪明,而是把知识从松散文本片段变成可执行查询的关系网络。
当前 neo4j-graphrag demo 用一个很小的奶茶知识图谱跑通了完整链路:
- 用 Neo4j 存储
Product、Type、Ingredient、Method、People等节点。 - 用
属于、包含、适合、使用表达实体关系。 - 用 LLM 根据用户问题生成 Cypher。
- 用 Neo4j 执行关系查询。
- 用 LLM 基于查询结果生成答案。
这个链路的工程价值在于:当问题需要跨实体、跨关系、多跳追踪时,系统不再只依赖模型从文本里猜,而是可以让数据库沿着明确的边去查。
但也要看到边界。Graph RAG 不擅长替代所有检索,它更适合作为结构关系层,与向量检索、BM25 检索组合使用。真正可落地的方案,通常不是"只用 Graph RAG",而是"用图谱回答关系问题,用向量召回语义证据,用关键词保证精确命中,再让大模型基于证据组织答案"。
如果把 RAG 看成一个知识系统,而不是一个 prompt demo,那么 Neo4j 的意义就很清楚了:它不是为了让系统多一个数据库,而是为了让知识之间的关系变成可查询、可验证、可解释的工程资产。