上一篇我们把奶茶知识图谱建好了,这一篇让 LLM 当翻译官,把"人话"自动翻译成"Cypher"
回顾一下上集留下的尴尬
上篇结尾我留了个坑:图建好了,但用户问的是人话——
"我们这款珍珠奶茶有哪些配料?"
而我每次都得手动把它翻译成——
MATCH (p:Product {name: '珍珠奶茶'})-[:包含]->(i)
RETURN i.name
这活儿干一两次还行,干一年谁受得了。所以我们今天的核心目标是:让大模型来当这个翻译官。
这套玩法有个正经名字,叫 Text2Cypher——把「文本」翻译成「Cypher」。整条链路长这样:
用户提问 → LLM 生成 Cypher → 查 Neo4j → 拿到结果 → LLM 组织成人话回答
而把这几个步骤串起来的"流水线工人",还是我们的老朋友 LangGraph。
第一步:同时抓好三个"零件"
整个程序需要三样东西:图数据库连接、大模型、一个能流动的状态。
先连图——用的是 LangChain 社区包里的 Neo4jGraph:
import "dotenv/config"
// 引入Neo4jGraph 类
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";
const graph = new Neo4jGraph({
url: "bolt://localhost:7687",
username: "neo4j",
password: "12345678",
});
还是熟悉的 bolt://localhost:7687、账号 neo4j、密码 12345678,跟上一篇 docker-compose 里配的一模一样。
再建模型(temperature: 0,我们要它老老实实生成语句,不要发挥创意):
const llm = new ChatOpenAI({
model: process.env.MODEL_NAME,
temperature: 0,
configuration: {
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
}
});
然后是最关键的状态设计——这决定了数据在流水线上怎么传:
const state ={
// 会话消息
// reduce
messages: {
// 旧的写法,reduce 新写法
value: (left, right) =>
left.concat(Array.isArray(right)?right:[right]),
default: () => [],
},
query: null,
cypher: null, // llm 生成 cypher 语句 text2cypher
context: null,
answer: null,
}
这里有两个知识点,敲黑板:
1. 四个槽位各司其职:query 放用户问题,cypher 放 LLM 生成的语句,context 放查库结果,answer 放最终答案。整条流水线就是让数据依次流经这四个槽位。
2. messages 用了 value 函数来合并——这是 LangGraph 的 reducer(归约器)。因为多个节点可能都想往 messages 里塞消息,需要一个规则决定"新旧怎么合并"。这里的规则是 left.concat(...),也就是把新消息追加到旧消息后面。
代码里那句注释 // 旧的写法,reduce 新写法 也点破了一个时代变迁:
- 旧的写法(也就是这份代码里用的):用
channels对象 +value/default手动声明; - 新的写法:用
Annotation.Root({ ... })配合Annotation<类型>({ reducer, default })来声明。
两种写法功能等价,只是 API 换了个更现代的脸。老项目里见到 channels 不要慌,就是它。
第二步:四个节点,一条流水线
整条 GraphRAG 链路被拆成了四个节点。我们一个一个看。
节点 1:parseQuestion —— 把问题捞出来
async function parseQuestion (state) {
const lastMessage = state.messages[state.messages.length - 1];
return{
query: lastMessage.content,
}
}
从 messages 里取最后一条,把它的内容丢进 query。朴实无华,但它是整条链路的入口。
节点 2:generateCypher —— 全篇最核心的一步
这一步让大模型把自然语言翻译成 Cypher。而提示词的质量,直接决定翻译的对不对:
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,
}
}
这段提示词里有三处设计,非常值得学:
① 交代"家底"——把节点类型列清楚。
你不告诉模型图里有哪些标签,它就只能瞎猜。这里把 Product、Ingredient、Type、Method、People 全列出来,等于给了它一张"地图"。
② 锁定"关系方向"——这是图查询最容易翻车的地方。
(Product)-[:包含]->(Ingredient) 和 (Ingredient)-[:包含]->(Product) 在语法上都合法,但后者查出来是空的。所以提示词里专门写了一句 "关系方向绝对不能反",还补了条规则 "多跳查询使用多个 MATCH,不能连错路径"。
③ 约束输出格式——"只返回 Cypher 代码,不要解释、不要 markdown"。 这太重要了。你如果不说,模型很可能给你回一段:
好的,以下是为您生成的语句:
MATCH ...
然后这坨带着 markdown 围栏和客套话的字符串,直接就会被塞进图数据库执行——不报错才怪。temperature: 0 也是同一个目的:让它别整活儿,规规矩矩输出。
节点 3:executeGraphQuery —— 查库,且"出事不炸"
拿到 Cypher 就能查了:
async function executeGraphQuery(state) {
try {
const res = await graph.query(state.cypher);
return{
context: JSON.stringify(res),
}
} catch(err) {
return{
context: "未查询到相关知识",
}
}
}
这里有一个被很多人忽略但极其重要的细节:catch 里没有 throw,而是返回了一个字符串 "未查询到相关知识"。
为什么?因为 LLM 生成的 Cypher 不保证 100% 合法。语法错了、标签写错了,graph.query 就会抛异常。
- 如果你在这里
throw,整个 LangGraph 流程当场中断,用户什么也拿不到; - 而现在你把错误"温柔地咽下去",填一句"未查询到相关知识"当
context继续往下走,最后由 LLM 统一措辞回复用户。
这就是所谓的 "优雅降级"——让流水线即使遇到脏数据也能跑到终点,而不是抛尸半路。
节点 4:generateAnswer —— 基于事实说话
最后一步,把查到的结果交给模型组织成回答:
async function generateAnswer(state) {
const prompt = `
你是奶茶专家,根据下方[检索结果]回答用户问题,检索结果为空或不足时简要说明无法从图谱得到
答案,不要编造。
回答要求:
- 直接列出事实,不要推断图谱中未出现的配料(如水、冰、添加剂等)。
检索结果:${state.context}
用户问题:${state.query}
`
const res = await llm.invoke([new HumanMessage(prompt)]);
return{
answer: res.content,
}
}
这段提示词同样藏着一句防幻觉的神来之笔:
直接列出事实,不要推断图谱中未出现的配料(如水、冰、添加剂等)。
为什么要专门点"水、冰、添加剂"?因为如果不加这句,模型很容易顺手补上"还含有水、冰块"——毕竟做奶茶当然要水啊! 但问题是,这些玩意儿图里根本没存。RAG 的铁律是"图里没有的,一个字都不能编",所以必须白纸黑字地堵死它的脑补。
第三步:用 StateGraph 把流水线接起来
四个节点都备好了,现在接线:
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)
new StateGraph({ channels: state }) —— 这里把上面那份 state 传进去当"通道定义",跟第一节呼应上了。
四条 addEdge 就是四个箭头,把节点串成了一条直线流水线:
START → parse → generateCypher → executeGraphQuery → generateAnswer → END
附赠:把流程图打印出来看看
管道接完,还可以让它自己吐一张 Mermaid 流程图,方便你确认连线对不对:
const app = workflow.compile();
const drawable = await app.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log('--- LangGraph 工作流(Mermaid) ---')
console.log(mermaid);
getGraphAsync() 拿到可绘制的图,drawMermaid() 输出 Mermaid 文本。把打印出来的内容粘到任何支持 Mermaid 的地方(比如掘金、GitHub、Notion),流程图立马就出来了。调试复杂工作流时,这一招能救命——图长什么样,一眼便知,不用靠脑补。
第四步:一口气跑三个问题
跑测的地方来了:
async function runGraphRAG(question) {
const res = await app.invoke({
messages: [new HumanMessage(question)],
});
console.log('='.repeat(50));
console.log('用户问题:', question);
console.log('生成Cypher:', res.cypher);
console.log('检索结果:', res.context);
console.log('最终回答:', res.answer);
console.log('='.repeat(50));
return res.answer;
}
async function main() {
try {
const results = await Promise.all([
runGraphRAG('我们这款珍珠奶茶有哪些配料?'),
runGraphRAG('台式奶茶的饮品都有哪些配料?'),
runGraphRAG('珍珠奶茶适合哪些人群饮用?'),
])
console.log(results);
} finally {
// 释放 Neo4j 链接
await graph.close();
}
}
三个问题分别对应三种意图,设计得挺讲究:
- "我们这款珍珠奶茶有哪些配料?" → 单跳查询,查
Product -[:包含]-> Ingredient; - "台式奶茶的饮品都有哪些配料?" → 需要走
Type这一跳,属于多跳; - "珍珠奶茶适合哪些人群饮用?" → 换一条关系,查
Product -[:适合]-> People。
用 Promise.all 三个一起并发问,每个都打印出"问题 → 生成 Cypher → 检索结果 → 最终回答"这条完整链路——非常方便你肉眼去核对:LLM 到底有没有把 Cypher 写对。
最后,又是一个必须记住的收尾动作:
} finally {
// 释放 Neo4j 链接
await graph.close();
}
放在 finally 里,意味着不管跑成功还是跑报错,Neo4j 连接都会乖乖关掉。跟上一篇 session.close() / driver.close() 是一个道理——连接是资源,用完必须还。
最后:三种检索,到底该选谁?
跑通 GraphRAG 是一回事,但别急着"图数据库一辈子"。笔记里那句总结很清醒:
向量检索是语义检索,ES 检索是分词后从倒排索引表通过关键词检索,知识图谱检索则是根据推理路径用 Cypher 语句检索。各有场景,都不可或缺。
三方对照,建议直接收藏:
| 检索方式 | 原理 | 最适合的场景 |
|---|---|---|
| Milvus 向量语义检索 | 向量化 + 语义相似度 | 用户提问没有明确关键词、是自然语言大白话;需要语义相近(意思像,不是字面一样);模糊查询、泛化查询、推荐类场景;非结构化文档(笔记、手册、文章、FAQ 模糊问答) |
| ElasticSearch BM25 关键词检索 | 分词 + 倒排索引 + BM25 | 用户有明确专有名词、专业术语、编号、文件名;需要精准分词、字面命中、高亮匹配;官方文档、规章制度、接口文档;过滤、排序、时间筛选、字段精准匹配 |
| Neo4j 知识图谱检索 | 节点 + 关系 + 多跳推理 | 需要实体关联、关系查询、多跳推理;想查"【A】和【B】到底是什么关系" |
用人话总结一下分工:
- 要"意思相近" → 交给 Milvus(向量);
- 要"精确命中关键词、专业术语" → 交给 ES;
- 要"搞清楚谁和谁有关系、顺藤摸瓜" → 交给 Neo4j(图谱)。
所以真正成熟的 RAG,往往不是"三选一",而是 "图谱管理 + 语义匹配 + 关键词匹配"的多重检索——让 RAG 生成的答案更精准,也更具解释性。
毕竟,传统 RAG 拿到的是一堆信息孤岛;而想做出真正能推理、能关联、能解释的下一代 RAG,知识图谱 + GraphRAG 是绕不过去的一环。
这一整套(Neo4j 建图 + Cypher 手写 + Text2Cypher 自动生成),就是我这个系列里最"上头"的一段。图里的奶茶不会骗人,编造的水和冰才会。