【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 里用 match 搜 errorCode=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月
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!