Neo4j + Graph RAG 工程实践:RAG 真正缺的不是更多文本,而是可查询的关系

很多团队做 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 检索也类似。它擅长关键词、术语、编号、字段过滤,但它不会天然理解"珍珠奶茶 属于 台式奶茶""珍珠奶茶 包含 牛奶"这类结构关系。它能命中文档,却不能保证关系链路被正确展开。

可以把三种检索的差异理解成这样:

flowchart LR Q[用户问题] --> V[向量检索] Q --> E[关键词检索] Q --> G[图谱检索] V --> V1[返回语义相近文本片段] E --> E1[返回字面命中文档] G --> G1[返回实体和关系路径] V1 --> A[适合模糊语义问题] E1 --> B[适合精确术语和过滤] G1 --> C[适合关系查询和多跳推理]

所以 Graph RAG 不是传统 RAG 的替代品,而是检索层的一种补强。它特别适合回答这类问题:

  • A 属于哪个类别?
  • A 包含哪些组成部分?
  • A 和 B 是什么关系?
  • 从 A 出发,经过某种关系能找到哪些对象?
  • 某个结论能不能追溯到图谱里的具体路径?

如果你的知识库主要是政策条款、产品体系、组织架构、供应链、设备依赖、代码调用链、医学概念关系、金融主体关系,这类问题会非常多。此时只靠向量库,很容易把系统做成"看起来什么都能答,但关键关系经常不稳"的状态。

Graph RAG 到底在 RAG 链路里做了什么

Graph RAG 这个词容易被讲得很大,但落到工程里,它做的事情可以拆成两层。

第一层是建图:把业务知识抽象成节点、关系和属性。节点表示实体,例如产品、配料、类型、人群、工艺;关系表示实体之间的连接,例如属于、包含、适合、使用;属性补充实体细节,例如热量、口味、产地。

第二层是查图:当用户提问时,不再只用语义相似度找文本,而是把问题转成图查询语句,沿着关系路径拿到结构化结果,再交给大模型组织成自然语言答案。

在当前项目里,图谱模型可以简化为下面这张图:

graph LR P[Product 珍珠奶茶] -->|属于| T[Type 台式奶茶] P -->|包含| I1[Ingredient 珍珠] P -->|包含| I2[Ingredient 果糖] P -->|包含| I3[Ingredient 红茶] P -->|包含| I4[Ingredient 牛奶] I1 -->|使用| M[Method 煮制] P -->|适合| U1[People 年轻人] P -->|适合| U2[People 学生] P -->|适合| U3[People 甜食爱好者]

这张图看起来很小,但它已经具备 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 兼容接口地址和密钥等运行参数

如果只看功能,它做了两件事:

  1. 先证明 Neo4j 能存和查图谱。
  2. 再把 Neo4j 查询接入 RAG 流程,让 LLM 根据用户问题生成 Cypher。

这条链路可以画成:

flowchart TD U[用户自然语言问题] --> LG[LangGraph 工作流] LG --> C[LLM 生成 Cypher] C --> N[Neo4j 执行图查询] N --> R[结构化检索结果] R --> A[LLM 生成最终回答] A --> U subgraph KG[Neo4j 知识图谱] P[Product] T[Type] I[Ingredient] M[Method] People[People] end N --> KG

注意这里没有 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 时就会不稳定。例如"台式奶茶有哪些配料"理论上要反向从 TypeProduct

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)
  })
}

这段代码做了三件事:

  1. Product 节点"珍珠奶茶"出发。
  2. 匹配所有从它发出的关系。
  3. 返回目标节点和关系类型。

它在工程上的价值,不是实现 RAG,而是验证图谱可用。很多 RAG 问题最后排查下来,并不是模型问题,而是数据库连接、数据没有写入、关系方向不一致、查询语句写错。先有这个最小查询脚本,排障会快很多。

不过当前脚本也有 demo 常见的简化:没有关闭 sessiondriver。真实服务里应该用 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 包裹。
  • 只允许 MATCHRETURNWITHWHERE 等只读语句。
  • 禁止 CREATEMERGEDELETESETCALL 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 是在为后续演进留位置。

可以把它理解成下面的时序:

sequenceDiagram participant User as 用户 participant App as LangGraph App participant LLM as 大模型 participant Neo4j as Neo4j User->>App: 提问 App->>LLM: 根据 schema 生成 Cypher LLM-->>App: 返回 Cypher App->>Neo4j: 执行只读图查询 Neo4j-->>App: 返回实体关系结果 App->>LLM: 根据结果生成回答 LLM-->>App: 返回自然语言答案 App-->>User: 输出答案

与向量检索、BM25 的关系:默认推荐混合检索

Graph RAG 不是银弹。它解决的是关系检索和多跳推理问题,但不擅长所有检索问题。

方案 擅长 不擅长 默认建议
向量检索 语义相似、模糊表达、长文本问答 精确关系、强约束过滤、可解释路径 作为非结构化知识库的基础能力
BM25/ES 关键词命中、术语编号、字段过滤、排序 语义泛化、多跳关系 和向量检索互补,适合精确召回
Neo4j/Graph RAG 实体关系、多跳路径、层级体系、可解释链路 模糊语义召回、海量原文相似匹配 用于关系型问题,不建议单独承担全部检索

我更推荐的生产架构是混合检索:

flowchart TD Q[用户问题] --> Router[问题路由] Router -->|语义问答| Vector[向量检索] Router -->|关键词/编号| BM25[BM25 或 ES] Router -->|关系/路径| Graph[Neo4j 图谱检索] Vector --> Merge[结果融合与重排] BM25 --> Merge Graph --> Merge Merge --> Grounding[证据整理] Grounding --> LLM[大模型生成答案] LLM --> Ans[最终回答]

比如企业产品知识库里,用户问"某个错误码是什么意思",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,而不是另起一套孤立问答。很多问题既需要原文证据,也需要图谱路径。图谱告诉你"关系是什么",原文告诉你"依据在哪一段"。

一个更完整的数据链路可以是:

flowchart TD D[原始文档] --> Split[文档切分] Split --> Extract[实体关系抽取] Extract --> Normalize[实体归一化] Normalize --> Neo4j[写入 Neo4j] Split --> Embed[生成 Embedding] Embed --> VectorDB[写入向量库] Q[用户问题] --> Retrieve[混合检索] Neo4j --> Retrieve VectorDB --> Retrieve Retrieve --> Answer[基于证据回答]

这里的难点不是 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 用一个很小的奶茶知识图谱跑通了完整链路:

  1. 用 Neo4j 存储 ProductTypeIngredientMethodPeople 等节点。
  2. 属于包含适合使用 表达实体关系。
  3. 用 LLM 根据用户问题生成 Cypher。
  4. 用 Neo4j 执行关系查询。
  5. 用 LLM 基于查询结果生成答案。

这个链路的工程价值在于:当问题需要跨实体、跨关系、多跳追踪时,系统不再只依赖模型从文本里猜,而是可以让数据库沿着明确的边去查。

但也要看到边界。Graph RAG 不擅长替代所有检索,它更适合作为结构关系层,与向量检索、BM25 检索组合使用。真正可落地的方案,通常不是"只用 Graph RAG",而是"用图谱回答关系问题,用向量召回语义证据,用关键词保证精确命中,再让大模型基于证据组织答案"。

如果把 RAG 看成一个知识系统,而不是一个 prompt demo,那么 Neo4j 的意义就很清楚了:它不是为了让系统多一个数据库,而是为了让知识之间的关系变成可查询、可验证、可解释的工程资产。

相关推荐
神奇小汤圆1 小时前
告别OOM焦虑:Flink 内存模型原理与诊断调优
后端
Raink老师2 小时前
【AI面试临阵磨枪-088】Skill 如何做参数校验、依赖注入、权限控制、超时、重试、幂等?
人工智能·面试·职场和发展
神奇小汤圆2 小时前
Kafka性能调优:从10万到100万条/秒的实战经验
后端
nuowenyadelunwen2 小时前
CS336 Assignment 1 BPE分词器训练初版(朴素版基础上优化)及后续优化方向分析
llm·cs336
Gopher_HBo2 小时前
接入层LVS
后端
404号扳手2 小时前
Java 基础知识(六)
java·后端
前端市界2 小时前
LotDB Vue 阿里云 ECS 部署实战记录
后端
卷无止境2 小时前
SimPy 进程通信:让仿真世界里的"对话"变得优雅
后端
ZengLiangYi2 小时前
多格式文件解析:JSONL / SQLite / Event Stream
前端·javascript·后端