传统 RAG 通常通过向量检索或全文检索寻找相关文档。
Milvus 更擅长解决"哪些内容和问题语义相似",Elasticsearch 更擅长解决"哪些内容包含指定关键词"。
但如果问题变成:
珍珠奶茶属于哪种类型?
珍珠奶茶包含哪些配料?
这些配料使用什么制作工艺?
台式奶茶下面的产品又包含哪些配料?
真正重要的就不再是文本相似度,而是实体之间的关系。
这正是 Neo4j 和 GraphRAG 擅长解决的问题。
整套流程可以先记成:
vbnet
Question
↓
Text-to-Cypher
↓
Neo4j
↓
Graph Context
↓
LLM
↓
Answer
一、Neo4j 解决的是什么问题
可以把几种检索方式简单区分为:
Milvus
→ 语义相似
→ "意思像不像?"
Elasticsearch
→ 关键词匹配
→ "字面有没有?"
Neo4j
→ 实体关系 / 图路径
→ "它们怎么关联?"
例如:
珍珠奶茶
│
│ 包含
▼
珍珠
│
│ 使用
▼
煮制
这里表达的不再是三个孤立的数据,而是一条知识路径:
sql
Product
↓ 包含
Ingredient
↓ 使用
Method
所以图数据库真正有价值的地方,不只是"存了哪些实体",而是:
实体之间可以沿着什么关系继续走。
二、Neo4j 的基本心智模型
Neo4j 最核心的是节点、关系和属性。
例如:
css
(:Product {name: "珍珠奶茶"})
Product 是节点类型,name 是属性。
关系可以写成:
scss
(:Product)-[:包含]->(:Ingredient)
表示:
Product ──包含──▶ Ingredient
在知识图谱里,可以定义:
scss
(Product)-[:属于]->(Type)
(Product)-[:包含]->(Ingredient)
(Product)-[:适合]->(People)
(Ingredient)-[:使用]->(Method)
最终形成类似:
markdown
珍珠 ──使用──▶ 煮制
▲
│ 包含
│
年轻人 ◀──适合── 珍珠奶茶 ──属于──▶ 台式奶茶
学生 ◀──适合── │
├──包含──▶ 果糖
├──包含──▶ 红茶
└──包含──▶ 牛奶
这时知识不再只是"珍珠奶茶""珍珠""煮制"几个词,而是:
珍珠奶茶 ──包含──▶ 珍珠
珍珠 ──使用──▶ 煮制
也就是可以继续遍历的关系网络。
三、Cypher:用文本描述图
Neo4j 使用 Cypher 查询图。
例如:
css
MATCH (p:Product {name: "珍珠奶茶"})
-[:包含]->
(i:Ingredient)
RETURN i.name
不要把它理解成传统数据库里的 JOIN。
更自然的理解方式是直接看成:
Product
│
│ 包含
▼
Ingredient
MATCH 本质是在说:
在数据库中寻找符合这个图形模式的数据。
因此多跳查询也非常直观。
例如:
less
MATCH (p:Product {name: "珍珠奶茶"})
-[:包含]->(i:Ingredient)
-[:使用]->(m:Method)
RETURN p.name, i.name, m.name
表示:
sql
Product
↓ 包含
Ingredient
↓ 使用
Method
而"台式奶茶有哪些产品配料"则需要走:
台式奶茶
▲
│ 属于
Product
│
│ 包含
▼
Ingredient
对应:
less
MATCH
(t:Type {name: "台式奶茶"})
<-[:属于]-
(p:Product)
-[:包含]->
(i:Ingredient)
RETURN DISTINCT i.name
这就是图数据库最核心的能力之一:沿关系完成多跳检索。
四、从 Cypher 到 Text-to-Cypher
真正的用户不会自己写 Cypher。
用户只会问:
珍珠奶茶有哪些配料?
所以需要 LLM 完成:
自然语言
↓
LLM
↓
Cypher
例如:
scss
async function generateCypher(state) {
const prompt = `
你是 Neo4j Cypher 生成器。
图谱结构:
(Product)-[:属于]->(Type)
(Product)-[:包含]->(Ingredient)
(Product)-[:适合]->(People)
(Ingredient)-[:使用]->(Method)
只返回可以直接执行的 Cypher。
用户问题:
${state.query}
`;
const res = await llm.invoke([
new HumanMessage(prompt),
]);
return {
cypher: res.content,
};
}
用户输入:
珍珠奶茶有哪些配料?
LLM 可能生成:
css
MATCH (p:Product {name: "珍珠奶茶"})
-[:包含]->
(i:Ingredient)
RETURN i.name AS ingredient
注意,这时候 LLM 还没有回答问题。
它只是在完成:
sql
Natural Language
→
Graph Query Language
也就是 Text-to-Cypher。
这里必须把图谱 Schema 告诉 LLM,因为模型本身并不知道数据库有哪些节点、关系以及关系方向。
可以把 Schema 理解成:
LLM 在图数据库里的地图。
五、Graph Retrieval:真正从知识图谱取事实
拿到 Cypher 后,就可以交给 Neo4j:
javascript
async function executeGraphQuery(state) {
const result = await graph.query(
state.cypher
);
return {
context: JSON.stringify(result),
};
}
例如 Neo4j 返回:
css
[ {"ingredient":"珍珠"}, {"ingredient":"果糖"}, {"ingredient":"红茶"}, {"ingredient":"牛奶"}]
这一步才是真正的 Retrieval。
也就是说:
Question
↓
LLM
↓
Cypher
↓
Neo4j
↓
Graph Facts
LLM 负责决定"怎么查",Neo4j 负责回答"数据库里真实有什么"。
六、基于图谱结果生成答案
有了检索结果之后,再进行第二次 LLM 调用:
javascript
async function generateAnswer(state) {
const prompt = `
请严格根据知识图谱检索结果回答问题。
检索结果:
${state.context}
用户问题:
${state.query}
不要补充检索结果中不存在的信息。
`;
const res = await llm.invoke([
new HumanMessage(prompt),
]);
return {
answer: res.content,
};
}
数据流变成:
markdown
Question
+
Graph Context
↓
LLM
↓
Answer
最终可能得到:
珍珠奶茶包含珍珠、果糖、红茶和牛奶。
因此同一个 LLM 实际扮演了两个角色:
第一次:
Question → Cypher
负责"怎么查"
第二次:
Question + Context → Answer
负责"怎么回答"
而 Neo4j 位于中间,负责提供真实的结构化知识。
七、用 LangGraph 把流程串起来
整个 GraphRAG 的 State 可以设计成:
yaml
const state = {
messages: {
value: (left, right) =>
left.concat(
Array.isArray(right)
? right
: [right]
),
default: () => [],
},
query: null,
cypher: null,
context: null,
answer: null,
};
因此真正的数据流是:
erlang
messages
↓
query
↓
cypher
↓
context
↓
answer
工作流:
sql
const workflow = new StateGraph({
channels: state,
})
.addNode("parse", parseQuestion)
.addNode("generateCypher", generateCypher)
.addNode("executeGraph", executeGraphQuery)
.addNode("generateAnswer", generateAnswer)
.addEdge(START, "parse")
.addEdge("parse", "generateCypher")
.addEdge("generateCypher", "executeGraph")
.addEdge("executeGraph", "generateAnswer")
.addEdge("generateAnswer", END);
对应:
sql
START
↓
parse
↓
generateCypher
↓
executeGraph
↓
generateAnswer
↓
END
调用:
arduino
await app.invoke({
messages: [
new HumanMessage(question),
],
});
这里传进去的 { messages: [...] } 本身就是初始 State 的一部分。
parseQuestion():
ini
async function parseQuestion(state) {
const lastMessage =
state.messages[state.messages.length - 1];
return {
query: lastMessage.content,
};
}
返回的 { query } 是一次局部 State 更新,LangGraph 会自动合并,再把新的 State 传给下一个节点。
所以整套流程可以进一步压缩成:
erlang
messages
→ query
→ cypher
→ context
→ answer
八、GraphRAG 和传统 RAG 的区别
传统 RAG:
Question
↓
Embedding / BM25
↓
Vector DB / Elasticsearch
↓
Document Chunks
↓
LLM
↓
Answer
这里实现的 GraphRAG:
vbnet
Question
↓
Text-to-Cypher
↓
Neo4j
↓
Entities / Relationships / Paths
↓
LLM
↓
Answer
两者本质上仍然都是:
Retrieval
↓
Augmented
↓
Generation
真正不同的是 Retrieval。
传统 RAG 更多是在找:
相关文本
图检索则可以直接找:
实体
关系
路径
多跳事实
所以 GraphRAG 并不是简单替代向量检索,而是在"需要关系、层级和多跳推理"的场景下提供另一种检索能力。
总结
如果以后忘记所有代码,只需要记住下面这一条:
Question
→ Cypher
→ Graph
→ Context
→ Answer
对应到各组件:
LLM
→ 理解问题、生成 Cypher、组织答案
Neo4j
→ 保存实体关系、执行图检索
LangGraph
→ 编排流程、管理 State
因此 GraphRAG 可以理解为:
让 LLM 把自然语言问题翻译成图查询,通过知识图谱检索实体关系和多跳路径,再将检索到的结构化事实作为上下文,生成最终答案。
这就是从 Neo4j 到 GraphRAG 最值得建立的心智模型。