【Graph RAG 实战】LLM 生成的 Cypher 查不到数据,问题出在哪儿?

2434 字
12 分钟
【Graph RAG 实战】LLM 生成的 Cypher 查不到数据,问题出在哪儿?

接上一篇 Neo4j 基础。这篇记录一个真实踩坑过程:用 LangGraph 搭了一个 GraphRAG 流水线——用户问问题 → LLM 生成 Cypher → Neo4j 执行 → LLM 回答。前两个问题完美过关,第三个问题(「台式奶茶有哪些配料」)却安静地失败了。顺着问题一层层排查,找到了根因,并为 LLM 动态注入了真实的图谱数据,让整套系统变得不再脆弱。


一、我们搭了什么#

一个 GraphRAG 流水线,三个节点串成线:

用户问题 → generateCypher → executeGraph → generateAnswer → 最终回答

LangGraph 实现的代码骨架:

const workflow = new StateGraph({ channels: state })
.addNode('generateCypher', generateCypher) // LLM 生成 Cypher
.addNode('executeGraph', executeGraphQuery) // Neo4j 执行查询
.addNode('generateAnswer', generateAnswer) // LLM 组织回答
.addEdge(START, 'generateCypher')
.addEdge('generateCypher', 'executeGraph')
.addEdge('executeGraph', 'generateAnswer')
.addEdge('generateAnswer', END);

三个 Step 的作用:

步骤做什么依赖
generateCypher把自然语言问题翻译成 Cypher 语句LLM + 图谱 Schema
executeGraph把 Cypher 发给 Neo4j 执行,拿到查询结果Neo4jGraph
generateAnswer把查询结果 + 原问题一起喂给 LLM,生成自然语言回答LLM

奶茶知识图谱的节点和关系:

Product(奶茶产品)+ Type(类型) + Ingredient(配料)
+ Method(工艺) + People(人群)
关系:
(Product)-[:属于]->(Type)
(Product)-[:包含]->(Ingredient)
(Product)-[:适合]->(People)
(Ingredient)-[:使用]->(Method)

二、前两个问题完美,第三个却静默失败#

我准备了三个测试问题:

await Promise.all([
runGraphRAG('我们这款珍珠奶茶有哪些配料?'),
runGraphRAG('台式奶茶的饮品都有哪些配料?'),
runGraphRAG('珍珠奶茶适合哪些人群饮用?'),
]);

问题 1 和 3:✅ 正确#

用户问题: 珍珠奶茶适合哪些人群饮用?
生成 Cypher: MATCH (p:Product {name:'珍珠奶茶'})-[:适合]->(people:People) RETURN people.name
检索结果: [{"people.name":"年轻人"},{"people.name":"学生"},{"people.name":"甜食爱好者"}]
最终回答: 根据检索结果,珍珠奶茶适合以下人群饮用:年轻人、学生、甜食爱好者

问题 2:❌ 静默失败#

用户问题: 台式奶茶的饮品都有哪些配料?
生成 Cypher: MATCH (t:Type {name:'台式奶茶'})<-[:属于]-(p:Product)-[:包含]->(i:Ingredient) RETURN i
检索结果: [] ← 空的!
最终回答: 无法从图谱得到答案

LLM 的 Cypher 看起来逻辑完全正确,方向也没反,但执行结果就是空的。 发生了什么?


三、根因排查:LLM 猜错了节点值#

把这个 Cypher 放到 Neo4j Browser 里手动跑一下:

MATCH (t:Type {name:'台式奶茶'}) RETURN t

返回空。再查一下 Type 节点到底存了什么:

MATCH (t:Type) RETURN t
结果:
{ name: "台式" } ← 是"台式",不是"台式奶茶"!
{ name: "港式奶茶" }

根因找到了:

LLM 在 Prompt 里看到:
"- Type: 奶茶类型"
它推断出:Type 的 name 应该是 "台式奶茶"
实际数据库: Type 的 name 是 "台式"
{name:'台式奶茶'} ≠ "台式"
→ MATCH 匹配不到
→ 返回空数组
→ LLM 只能说"无法从图谱得到答案"

这不是 Cypher 语法错,这是 LLM 不知道数据库里的真实值。


四、为什么这个问题如此隐蔽#

两个侥幸过了,一个悄悄挂了。看看区别:

问题LLM 推断的值实际值结果
「珍珠奶茶有哪些配料」{name:'珍珠奶茶'}"珍珠奶茶"✅ 撞对了
「珍珠奶茶适合哪些人」{name:'珍珠奶茶'}"珍珠奶茶"✅ 撞对了
「台式奶茶有哪些配料」{name:'台式奶茶'}"台式"❌ 猜错了

前两个问题的节点名没有任何歧义——「珍珠奶茶」就是叫「珍珠奶茶」,LLM 直接复用用户输入的词条就行。第三个问题里,用户说了「台式奶茶」,数据库里叫「台式」——LLM 不自己看一眼,永远不知道。

类比你已经踩过的其他坑:就像 ES 里用 matcherrorCode=5001 漂到了 5002——语义近但实际不对。Neo4j 里这个坑更隐蔽:Cypher 是精确匹配,差一个字符就是查不到。


五、低配方案(不推荐):手动把数据写死在 Prompt 里#

第一反应是直接在 Prompt 里补充枚举值:

const prompt = `
节点:
- Product: 奶茶产品,已有数据:珍珠奶茶
- Type: 奶茶类型,已有数据:台式 ← 手动写死
- Ingredient: 配料,已有数据:珍珠、果糖、红茶、牛奶
- People: 适合人群,已有数据:年轻人、学生、甜食爱好者
...
`;

能跑通,但有一个致命问题:

如果我在数据库里加了新节点:
CREATE (p:Product {name: "杨枝甘露"})
代码里的 Prompt 不会自动更新!
→ 搜"杨枝甘露的配料"时 LLM 又会猜错
→ 每次加数据都要改代码
→ 维护噩梦

这个方案没有实际意义。


六、正确方案:启动时动态读取 Schema + 真实值#

Neo4jGraph 提供了一个关键方法 refreshSchema(),调用后 graph.schema 会自动包含图谱的结构信息。但还不够——结构里没有真实值。

需要再多一步:查询每类节点的实际 name 值,和 Schema 一起注入 Prompt。

6.1 动态构建图谱上下文#

let graphContext = ''; // 模块级变量,查一次全局复用
async function buildGraphContext() {
await graph.refreshSchema(); // 读取节点类型和关系结构
// 并发查询每类节点的所有 name 值
const [products, types, ingredients, methods, peoples] = await Promise.all([
graph.query('MATCH (n:Product) RETURN collect(n.name) AS values'),
graph.query('MATCH (n:Type) RETURN collect(n.name) AS values'),
graph.query('MATCH (n:Ingredient) RETURN collect(n.name) AS values'),
graph.query('MATCH (n:Method) RETURN collect(n.name) AS values'),
graph.query('MATCH (n:People) RETURN collect(n.name) AS values'),
]);
return `
图谱结构(Schema):
${graph.schema}
节点现有数据(必须使用以下真实值,不得自行推断):
- Product: ${products[0].values.join('')}
- Type: ${types[0].values.join('')}
- Ingredient: ${ingredients[0].values.join('')}
- Method: ${methods[0].values.join('')}
- People: ${peoples[0].values.join('')}
`.trim();
}

graph.schema 的输出长这样:

Node properties are the following:
Product {taste: STRING, calorie: STRING, name: STRING},
Type {name: STRING},
Ingredient {hard: STRING, name: STRING, origin: STRING},
Method {name: STRING},
People {name: STRING}
Relationship properties are the following:
The relationships are the following:
(:Product)-[:适合]->(:People),
(:Product)-[:属于]->(:Type),
(:Product)-[:包含]->(:Ingredient),
(:Ingredient)-[:使用]->(:Method)

结构 + 枚举值都有了。

6.2 把动态上下文注入 Prompt#

async function generateCypher(state) {
const prompt = `
你是一个专业的 Neo4j Cypher 生成器。
只返回纯 Cypher 代码,不要任何解释、不要标点、不要 markdown。
${graphContext}
规则:
1. 关系方向绝对不能反
2. 多跳查询请使用多个 MATCH,不要连错路径
3. 只返回最终可运行的 Cypher 语句
4. 节点属性值必须使用上方「节点现有数据」中的真实值,不得自行推断
用户问题:${userQuery(state)}
`;
const res = await llm.invoke([new HumanMessage(prompt)]);
return { cypher: res.content };
}

关键:规则第 4 条。LLM 现在能从 Prompt 里看到 Type: 台式、港式奶茶,就不会再猜 台式奶茶

6.3 入口初始化#

;(async () => {
// 启动时查一次,后续所有问题复用
graphContext = await buildGraphContext();
console.log('=== 动态图谱上下文 ===');
console.log(graphContext);
await Promise.all([
runGraphRAG('珍珠奶茶适合哪些人群饮用?'),
runGraphRAG('我们这款珍珠奶茶有哪些配料?'),
runGraphRAG('台式奶茶的饮品都有哪些配料?'),
]);
})().catch(console.error);

七、修复后的完整运行结果#

=== 动态图谱上下文 ===
图谱结构(Schema):
...
节点现有数据(必须使用以下真实值,不得自行推断):
- Product: 珍珠奶茶
- Type: 台式、港式奶茶
- Ingredient: 珍珠、芋圆、果糖、红茶、牛奶
- Method: 煮制、冲泡
- People: 年轻人、学生、甜食爱好者
-----------------------------------------------------------
======================================
用户问题: 我们这款珍珠奶茶有哪些配料?
生成 Cypher: MATCH (p:Product {name:'珍珠奶茶'})-[:包含]->(i:Ingredient) RETURN i.name
检索结果: [{"i.name":"珍珠"},{"i.name":"果糖"},{"i.name":"红茶"},{"i.name":"牛奶"}]
最终回答: 这款珍珠奶茶的配料包括:珍珠、果糖、红茶、牛奶。
======================================
用户问题: 台式奶茶的饮品都有哪些配料?
生成 Cypher: MATCH (p:Product)-[:属于]->(t:Type {name:'台式'})
MATCH (p)-[:包含]->(i:Ingredient)
RETURN i.name
检索结果: [{"i.name":"牛奶"},{"i.name":"红茶"},{"i.name":"果糖"},{"i.name":"珍珠"}]
最终回答: 台式奶茶的配料包括:牛奶、红茶、果糖、珍珠。
======================================
用户问题: 珍珠奶茶适合哪些人群饮用?
生成 Cypher: MATCH (p:Product {name:'珍珠奶茶'})-[:适合]->(people:People) RETURN people.name
检索结果: [{"people.name":"年轻人"},{"people.name":"学生"},{"people.name":"甜食爱好者"}]
最终回答: 珍珠奶茶适合以下人群饮用:年轻人、学生、甜食爱好者
======================================

三个全部正确。 第三个问题终于不是空数组了。


八、对比总结#

修复前后的 Cypher 差异#

问题旧版(失败)新版(成功)
台式奶茶查询{name:'台式奶茶'}{name:'台式'}
多跳写法单个 MATCH 连错路径两个 MATCH 各自匹配,路径清晰

修复前后 Prompt 的差异#

旧版 Prompt:
节点:
- Type: 奶茶类型
→ LLM 自己猜 = "台式奶茶" → 猜错
新版 Prompt:
节点现有数据(必须使用以下真实值,不得自行推断):
- Type: 台式、港式奶茶
→ LLM 用真实值 = "台式" → 正确

这个方案的优势#

手动方案(不推荐): 动态方案(推荐):
值写死在代码里 启动时从 Neo4j 自动读取
加节点要改代码 加节点自动生效
容易遗漏/写错 永远和数据库一致
以后往图谱里加新数据:
加一个新 Product → 自动出现在 LLM 的 Prompt 里
加一个新关系 → Schema 自动更新
完全不需要改代码 ✅

九、这个坑的核心教训#

GraphRAG 的「LLM 生成 Cypher」这一步,有两个关键信息 LLM 必须知道:

1. 图谱的结构 → 有哪些节点类型、关系方向
2. 图谱的真实值 → 每个节点的 name 具体是什么
旧版只有 1,没有 2
→ LLM 猜值
→ 猜错 = 查不到 = 静默失败
新版 1 + 2 都有
→ LLM 看到真实值
→ 生成的 Cypher 100% 命中

一句话记住:Cypher 是精确匹配,不是 LIKE '%台式%'。差一个「奶茶」就是完全不同的条件。让你的 LLM 知道数据库里到底存了什么——不要让它猜。


昇哥 · 2026年7月

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【Graph RAG 实战】LLM 生成的 Cypher 查不到数据,问题出在哪儿?
https://blog.fridolph.top/posts/2026-06-16__neo4j-graphrag/
作者
Fridolph
发布于
2026-07-04
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录