⛄ 让大模型自己写 Cypher:GraphRAG + Text2Cypher 全流程拆解

21 阅读7分钟

上一篇我们把奶茶知识图谱建好了,这一篇让 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();
    }
}

三个问题分别对应三种意图,设计得挺讲究:

  1. "我们这款珍珠奶茶有哪些配料?" → 单跳查询,查 Product -[:包含]-> Ingredient;
  2. "台式奶茶的饮品都有哪些配料?" → 需要走 Type 这一跳,属于多跳;
  3. "珍珠奶茶适合哪些人群饮用?" → 换一条关系,查 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 自动生成),就是我这个系列里最"上头"的一段。图里的奶茶不会骗人,编造的水和冰才会。