
在前面一篇 Agentic RAG 实战:PostgreSQL + LangGraph 一条链路 里,检索已经实现了:
- pgvector 按意思找内容,适合口语、模糊描述;
- Elasticsearch + BM25 按词找内容,适合景点名、线路编号;
- Rerank 根据检索的内容文档进行重排。
这两路都很有用,但做的是同一件事:从文档里找出更可能有用的片段,再交给大模型阅读。
洪崖洞更适合白天还是晚上?
知识库中已经存在相关内容,用户只需要拿到对应的介绍片段,系统核心任务就是把这段文本找出来。语义相近靠向量检索,关键词命中靠 ES;对于这类查询,混合检索刚好可以胜任。
但是换个问题:
洪崖洞是第几天的行程?它前面是哪个景点?
同样是查询洪崖洞,但诉求完全不同:此时需要的不再是景点介绍文本,而是洪崖洞与其他实体之间的关联关系 ------ 它归属哪一天行程,它的前序景点是什么。
这些行程关系原本就存在知识库中:例如第一天行程从解放碑出发,随后前往洪崖洞;李子坝安排在第二天。 但文档入库切分 chunk 之后,存入 PostgreSQL、ES 的只有纯文本片段。"第一天""随后" 退化为普通文本词汇,失去了结构化含义,无法直接作为关系条件进行查询。
传统混合检索只能召回相关文档片段,需要大模型阅读文本后自行推导 "归属第几天""前一个景点" 这类关系。前文提到的多跳检索也是同样思路:拆分问题、多次检索文档,交由模型从文本中提炼关系。本质属于猜关系,而非直接查询关系。
因此问题不在于缺少文档,而是缺少对关系本身的结构化存储。 将洪崖洞、解放碑、第一天抽象为节点 ;把属于、随后抽象为边。查询关系时直接沿着边检索,不再需要反复从文本片段做解析。
Neo4j 正是用于存储节点‑边、支持关系查询的图数据库。它并不会替代 pgvector 和 ES:pgvector、ES 继续承担文档检索,Neo4j 专门负责关系查询。
简单概括:
- 向量检索:回答哪段文本语义最匹配
- ES:回答哪个关键词命中
- Neo4j:回答实体 A 与实体 B 是什么关联
Neo4j 介绍:节点、边和基本查询
Neo4j 是图数据库,它是属性图(Property Graph):
- 节点 Node:一个实体,例如景点「洪崖洞」、行程「第一天」;
- 标签 Label :给节点分类,例如
:Attraction、:DayPlan,方便按类型查询; - 关系 Relationship :连接两个节点的有向边,必须有类型,例如
包含、随后、属于; - 属性 Property :节点和关系上的键值对,例如
name、suitableTime。
可以先和关系型数据库做一个对照:
| Neo4j | 关系型数据库 | 示例 |
|---|---|---|
| 节点 | 一行记录 | 洪崖洞、解放碑、第一天 |
| 标签 | 表名 / 类型 | Attraction、DayPlan |
| 关系 | 外键或中间表 | 包含、随后 |
| 属性 | 列 | name、suitableTime |
关系型数据库将实体关联通过外键分散存储,多跳关联查询需要执行多次JOIN;而图数据库直接把实体之间的关联保存为边,查询时直接按图模式遍历。这正是它擅长处理「A 与 B 是什么关系」「第一天之后去往哪里」这类问题的核心原因。
使用之前,先安装一下。
采用 Docker Compose 启动即可。7474 是浏览器管理界面,7687 是 Bolt 协议,后面用代码连接时走这个端口。
yml
services:
neo4j:
image: neo4j:latest
container_name: neo4j-container
ports:
- "7474:7474" # Web 管理界面
- "7687:7687" # Bolt 协议(代码连接)
environment:
- NEO4J_AUTH=neo4j/12345678 # 账号: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
要点说明:
NEO4J_AUTH=neo4j/12345678会在首次启动时设置登录密码,长度需要至少 8 位;- 数据、日志、配置挂到本地目录,删除容器后图数据还在;
APOC是常用过程库,基础的增删改查用不到,后面做批量导入或图处理时会用上,这里先装上;
启动服务:
bash
docker compose up -d
浏览器打开 http://localhost:7474,输入账号密码登录。

接下来,简单的了解一下其语法。
操作图使用 Cypher。圆括号是节点,方括号是关系,箭头是方向:
cypher
(a:Attraction {name: '解放碑'})-[r:随后 {交通: '步行'}]->(b:Attraction {name: '洪崖洞'})

写入用 CREATE,按上面这套图案把点和边画进图里:
cypher
CREATE
(day1:DayPlan {name: '第一天', theme: '老城与江景'}),
(jiefangbei:Attraction {name: '解放碑'}),
(hongyadong:Attraction {name: '洪崖洞', suitableTime: '晚上夜景'}),
(day1)-[:包含]->(jiefangbei),
(day1)-[:包含]->(hongyadong),
(jiefangbei)-[:随后 {交通: '步行'}]->(hongyadong)
CREATE 每次执行都会新建。可以使用 MERGE 避免这个问题。
执行后,左侧面板里应能看到 DayPlan、Attraction 两个标签,以及 包含、随后 两类关系。
查询使用 MATCH,然后把结果返回:MATCH 按图案去找,RETURN 把需要的结果拿出来。

查看当前写入是否成功:
cypher
MATCH (n)
RETURN n
// 返回当前库里的全部节点。数据变多后不要这样扫全图,学习阶段用来确认即可
按名称找一个景点:
cypher
MATCH (a:Attraction {name: '洪崖洞'})
RETURN a
按关系问「第一天包含什么」,匹配的是 包含 这条边:
cypher
MATCH (d:DayPlan {name: '第一天'})-[:包含]->(a:Attraction)
RETURN a.name
按边问「解放碑之后去哪」,匹配的是 随后。关系也可以起变量,用来读边上的属性:
cypher
MATCH (a:Attraction {name: '解放碑'})-[r:随后]->(b)
RETURN b.name, r.交通
修改属性用 SET:
cypher
MATCH (a:Attraction {name: '洪崖洞'})
SET a.suitableTime = '白天拍照,晚上夜景'
RETURN a
删除节点前,必须先处理连在它上面的边。只写 DELETE 时,若节点还连着关系,语句会失败。学习阶段用 DETACH DELETE,会一并删掉该节点及其关系:
cypher
MATCH (a:Attraction {name: '洪崖洞'})
DETACH DELETE a
清空当前学习数据:
cypher
MATCH (n)
DETACH DELETE n
// 生产环境不要对全库使用这条语句。
CREATE会反复新建节点,为了解决该问题,可以使用MERGE。
构建知识图谱时,同一个景点实体可能会多次出现。如果反复使用CREATE,图谱中会生成大量重复节点,导致关系挂载到错误节点上。
MERGE逻辑: 按照给定模式查找节点 / 关系,找到则直接复用,找不到再执行创建。
cypher
MERGE (a:Attraction {name: '洪崖洞'})
ON CREATE SET a.suitableTime = '晚上夜景'
RETURN a
关系同样支持MERGE: 先保证两端节点存在,再保证二者之间存在指定关系。
cypher
MERGE (a:Attraction {name: '解放碑'})
MERGE (b:Attraction {name: '洪崖洞'})
MERGE (a)-[r:随后 {交通: '步行'}]->(b)
RETURN a, r, b
✅ 记忆分工:
MATCH:专门做匹配查询MERGE:保证节点 / 关系一定存在,存在就复用,不存在才创建CREATE:明确需要全新创建一份实体时使用
图谱入库场景优先使用MERGE,用来规避重复实体。
Cypher 不止这些写法,不必一次记完,用到再查即可。
Neo4j 集成至 Agentic RAG
图谱入库
前面入库动作,就是把切片后的文档保存到 PostgreSQL 和 Elasticsearch。现在需要新增多一步,要把关系图保存到 Neo4j 中。

用 LLM 建图,最容易翻的地方是:把 Label 和关系类型也交给模型现编。标签会漂,同义体会裂成多个点,查询对不上。
所以类型人定,模型只填实例。Label、Rel Type、边模式、属性先写死。当前示例用最小的一套:
| 类型 | 取值 | 作用 |
|---|---|---|
| Label | DayPlan、Attraction |
行程日、景点 |
| Rel Type | 包含、随后 |
从属、先后 |
| 属性 | name、source |
合并主键、文档溯源 |
边写成白名单,不在名单里的关系直接丢掉:
DayPlan -包含-> Attraction:某天包含哪些点Attraction -随后-> Attraction:同一时间范围内的先后DayPlan -随后-> DayPlan:天与天的先后
js
const NODE = {
DAY_PLAN: "DayPlan",
ATTRACTION: "Attraction",
};
const REL = {
CONTAINS: "包含",
NEXT: "随后",
};
const ALLOWED_EDGES = [
{ from: NODE.DAY_PLAN, type: REL.CONTAINS, to: NODE.ATTRACTION },
{ from: NODE.ATTRACTION, type: REL.NEXT, to: NODE.ATTRACTION },
{ from: NODE.DAY_PLAN, type: REL.NEXT, to: NODE.DAY_PLAN },
];
换同类源文档,只换实例(「第一天」「洪崖洞」),不改 Label 和边。换领域才改 Schema。
接下来是文档分块。RAG 检索的核心诉求是细粒度、高精准降噪,适合短文本切片;而知识图谱抽取的核心诉求是上下文完整,必须保证一条关系的两端实体、完整语义落在同一个文本片段中。二者诉求完全不同,不可共用一套分块规则。
因此,可以采用双分块策略隔离,分别适配检索、抽图两大场景。
- 检索分块(ES/PG):500 字文本长度 + 80 字重叠,适配细粒度语义召回、检索降噪场景
- 抽图分块(Neo4j):3000 字文本长度 + 200 字重叠,最大化保留完整上下文,保障实体关系完整性
为兼顾抽取效率与语义完整性,短文直接全量抽取,长文本则采用滑动窗口分片,搭配重叠文本规避切口截断导致的关系丢失问题。
js
const EXTRACT_SIZE = 3000;
const EXTRACT_OVERLAP = 200;
const text = document.pageContent;
const chunks = [];
// 短文本
if (text.length <= EXTRACT_SIZE) {
chunks.push(text);
} else {
// 长文本
for (let i = 0; i < text.length; i += EXTRACT_SIZE - EXTRACT_OVERLAP) {
chunks.push(text.slice(i, i + EXTRACT_SIZE));
}
}
最好不要使用检索短切片开展知识图谱抽取工作,短切片上下文有限,极易引发关系断裂、单边孤立实体、行程顺序缺失等问题
接下来是实体关系抽取。在整套抽取流程中,大模型仅承担「实例识别与填充」的工作,不参与图谱结构定义。采取 Zod 结构化校验 + 精准 Prompt 约束双重兜底。
为确保模型输出结构化,需使用 Zod 严格定义实体与关系的输出格式及字段说明,使模型结果与预定义 Schema 完全对齐。
js
const schema = z.object({
days: z
.array(z.string())
.describe("行程日名称,必须是原文中的叫法,如 第一天"),
attractions: z
.array(z.string())
.describe("景点名称,必须是原文出现过的专有名称"),
contains: z
.array(
z.object({
day: z.string().describe("行程日名称"),
attraction: z.string().describe("该日包含的景点"),
}),
)
.describe("天包含景点"),
attractionNext: z
.array(
z.object({
from: z.string(),
to: z.string(),
}),
)
.describe("同一天内景点的随后关系"),
dayNext: z
.array(
z.object({
from: z.string(),
to: z.string(),
}),
)
.describe("行程日之间的随后关系"),
});
为最大程度降低幻觉,需在 Prompt 中反复强调 Schema 约束。
js
for (const chunk of chunks) {
const extracted = await llm
.withStructuredOutput(schema)
.invoke(
[
"从下面文档中抽取知识图谱。",
"只允许以下节点标签:",
"- DayPlan:行程日,name 用原文里的叫法,如「第一天」",
"- Attraction:景点或可游览地点,name 必须是原文中的专有名称",
"",
"只允许以下关系:",
"- (DayPlan)-[:包含]->(Attraction):某天安排了某景点",
"- (Attraction)-[:随后]->(Attraction):同一天内明确的先后顺序",
"- (DayPlan)-[:随后]->(DayPlan):行程日之间的顺序",
"",
"约束:",
"- 名称必须是原文子串,不要改写、不要自行加「景区/公园」等后缀",
"- 并列可选(「或」「也可」)不要用随后连接",
"- 火锅、地铁线路、穿衣建议不是景点",
"",
"原文:",
chunk,
].join("\n"),
);
}
这样,Schema 约束 + Prompt 双重兜底,层层拦截模型幻觉,提升抽取准确率。
即便大模型输出了规范的结构化数据,依然无法直接入库。模型大概率存在虚假实体、非法关联、单边关系等问题。因此入库前必须执行三层强制清洗校验,坚守「脏数据宁缺毋滥」的原则。
- 幻觉过滤:所有实体名称必须是原文原生子串,非原文存在的实体直接判定为幻觉,全部丢弃
- 实体去重归一化:自动合并同名实体,避免重复节点入库,保证全局实体唯一
- 边合法性校验:关系两端实体必须同时有效存在,拦截单边、悬空、非法关联关系
js
// 1. 过滤幻觉实体 + 去重
const days = [...new Set(extracted.days.filter((name) => text.includes(name)))];
const attractions = [
...new Set(extracted.attractions.filter((name) => text.includes(name))),
];
// 2. 过滤非法关系(两端必须存在)
const contains = extracted.contains.filter(
(row) => days.includes(row.day) && attractions.includes(row.attraction),
);
const attractionNext = extracted.attractionNext.filter(
(row) => attractions.includes(row.from) && attractions.includes(row.to),
);
const dayNext = extracted.dayNext.filter(
(row) => days.includes(row.from) && days.includes(row.to),
);
知识图谱对脏数据容错率极低。错误的关联关系、虚假实体一旦入库,会持续污染图遍历、路径查询、关联分析等所有后续业务。
数据清洗完成后,就进入 Neo4j 入库环节。
js
import neo4j from "neo4j-driver";
const driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
const session = driver.session();
try {
await session.executeWrite(async (tx) => {
// 1. 幂等:删除当前文档旧图谱数据
await tx.run(`MATCH (n {source: $source}) DETACH DELETE n`, {
source: graph.source,
});
// 2. 批量写入行程日节点
await tx.run(
`UNWIND $days AS name
MERGE (d:DayPlan {name: name})
SET d.source = $source`,
{ days: graph.days, source: graph.source },
);
// 3. 批量写入景点节点
await tx.run(
`UNWIND $names AS name
MERGE (a:Attraction {name: name})
SET a.source = $source`,
{ names: graph.attractions, source: graph.source },
);
// 4. 写入 行程日-包含-景点
await tx.run(
`UNWIND $rows AS row
MATCH (d:DayPlan {name: row.day})
MATCH (a:Attraction {name: row.attraction})
MERGE (d)-[:包含]->(a)`,
{ rows: graph.contains },
);
// 5. 写入 景点先后顺序
await tx.run(
`UNWIND $rows AS row
MATCH (a:Attraction {name: row.from})
MATCH (b:Attraction {name: row.to})
MERGE (a)-[:随后]->(b)`,
{ rows: graph.attractionNext },
);
// 6. 写入 行程日先后顺序
await tx.run(
`UNWIND $rows AS row
MATCH (a:DayPlan {name: row.from})
MATCH (b:DayPlan {name: row.to})
MERGE (a)-[:随后]->(b)`,
{ rows: graph.dayNext },
);
});
} catch (error) {
// 图写入失败不阻塞主业务,仅日志记录
console.log(`图写入失败(不影响正文):${error.message}`);
}
写入完成后,效果如下:

图谱检索
前面入库已经把行程图写进 Neo4j。检索阶段要做的,是把这张图接到现有的 Agentic RAG 里。
Agentic RAG 整体主流程保持不变,仅对 retrieve 模块内部逻辑进行改造:原检索环节并行调用 pgvector 与 Elasticsearch,在此基础上新增 Neo4j 检索支路,三路检索在同一个 retrieveNode 节点内并发执行。
检索完成后执行分支处理: pgvector、Elasticsearch 输出文档片段,对结果做合并去重后存入 retrievalCandidates,送入 rerank 模块完成精排,最终聚合至 documents。
Neo4j 返回实体与关系的图谱描述信息,封装为图谱召回结果存入 graphContext;该部分无文档相似度得分,不参与 rerank 重排序,直接随状态透传至 plan_next、evaluate、generate 环节供后续使用。 若图谱服务异常或检索无命中,则返回空字符串,文档检索主链路不受影响,继续正常流转。

因此 state 里要多一个字段,用来在多轮检索之间累计图谱召回结果:
js
const GraphState = Annotation.Root({
// ...原有字段
retrievalCandidates: Annotation, // PG + ES 合并后的文档候选,只进 rerank
documents: Annotation, // 精排后累计的本地文档
graphContext: Annotation, // Neo4j 图谱召回结果,不进 rerank
});
先看 retrieveNode。每一轮取出当前子问题后,三路一起查:
js
async function retrieveNode(state) {
const query = state.subQuestions[state.nextSubIdx].trim();
const [vectorDocuments, keywordDocuments, graphHit] = await Promise.all([
// pg
retrieveRelevantContent(vectorStore, query, 20),
// es
keywordSearch(elasticsearchClient, query, { candidateK: 20 }),
// neo4j
retrieveGraphContext(neo4jDriver, query, { llm }),
]);
return {
// 文档:合并去重后交给 rerank
retrievalCandidates: mergeDocumentsById(vectorDocuments, keywordDocuments),
// 图:按行去重,累计进 graphContext
graphContext: mergeGraphContext(state.graphContext, graphHit),
};
}
图召回的核心入口是 retrieveGraphContext。它不直接写一条大 Cypher 扫全图,而是固定三步:
js
export async function retrieveGraphContext(driver, query, options = {}) {
if (!driver || !query.trim()) return "";
// 1. 从图里拉出全部候选名,作为白名单
const candidates = await listGraphCandidates(driver);
if (!candidates.attractions.length && !candidates.days.length) return "";
// 2. 让模型只从白名单里选出本题提到的实体
const linked = await linkGraphEntities(options.llm, query, candidates);
if (!linked.attractions.length && !linked.days.length) return "";
// 3. 按命中实体沿边展开一跳,拼成文本证据
return expandGraphNeighborhood(driver, linked);
}
第一步,listGraphCandidates 预先从 Neo4j 图谱中拉取全部景点、行程日实体名称,生成实体白名单。后续子问题实体识别阶段,模型只能在该白名单范围内做实体抽取,禁止自由生成不存在的实体名称,避免出现图谱内不存在的虚构实体。
js
export async function listGraphCandidates(driver) {
const session = driver.session();
try {
const [attrRes, dayRes] = await Promise.all([
session.run(`MATCH (a:Attraction) RETURN a.name AS name`),
session.run(`MATCH (d:DayPlan) RETURN d.name AS name`),
]);
return {
attractions: attrRes.records.map((r) => r.get("name")),
days: dayRes.records.map((r) => r.get("name")),
};
} finally {
await session.close();
}
}
第二步,linkGraphEntities 才调用大模型。这里模型只做一件事:判断子问题提到了哪些候选实体。它不负责回答问题,也不负责猜「前面是谁」。
通过 Zod 强约束输出 Schema,强制输出实体必须匹配预加载的候选集合:
js
const GraphEntitySchema = z.object({
attractions: z
.array(z.string())
.describe("子问题里提到的景点,必须来自候选景点名单"),
days: z
.array(z.string())
.describe("子问题里提到的行程日,必须来自候选行程日名单"),
reason: z.string().describe("简短说明为何选中这些实体"),
});
然后,将实体白名单注入 Prompt,进一步约束输出名称必须与图谱候选实体完全匹配:
js
export async function linkGraphEntities(llm, query, candidates) {
const linker = llm.withStructuredOutput(GraphEntitySchema);
const out = await linker.invoke(`你负责从用户子问题里,识别图中已经有的实体:景点名和行程日名。
用户子问题:
${query}
候选景点(只能从这里选):
${candidates.attractions.map((name) => `- ${name}`).join("\n")}
候选行程日(只能从这里选):
${candidates.days.map((name) => `- ${name}`).join("\n")}
规则:
1. 只输出问题里真正提到或明确指代到的实体
2. 名称必须与候选名单完全一致,禁止改写
3. 与行程图无关时,两个数组都返回空
4. 不要推断关系,不要回答问题`);
// 代码层再滤一遍,防止名单外幻觉混进来
const allowAttr = new Set(candidates.attractions);
const allowDay = new Set(candidates.days);
return {
attractions: out.attractions.filter((name) => allowAttr.has(name)),
days: out.days.filter((name) => allowDay.has(name)),
reason: out.reason,
};
}
结构化输出仅为第一层约束,模型返回结果后仍必须执行后置校验清洗,保障逻辑严谨性,校验实体名称是否存在于图谱候选白名单中。
第三步,expandGraphNeighborhood 将实体识别产出的实体集合送入 Neo4j,做邻域查询:
- 景点:获取归属行程日、前后相邻景点
- 行程日:获取所辖景点、前后相邻行程日
对查询结果过滤空值,格式化输出可读的图谱上下文文本,透传给后续环节。
js
export async function expandGraphNeighborhood(driver, entities) {
const session = driver.session();
try {
const attrRes = await session.run(
`
MATCH (a:Attraction)
WHERE a.name IN $names
OPTIONAL MATCH (d:DayPlan)-[:包含]->(a)
OPTIONAL MATCH (prev:Attraction)-[:随后]->(a)
OPTIONAL MATCH (a)-[:随后]->(next:Attraction)
RETURN a.name AS name,
collect(DISTINCT d.name) AS days,
collect(DISTINCT prev.name) AS prevs,
collect(DISTINCT next.name) AS nexts
`,
{ names: entities.attractions },
);
// 行程日同理:WHERE d.name IN $names,再取包含 / 前一天 / 后一天
return attrRes.records
.map((record) => {
const name = record.get("name");
const days = record.get("days").filter(Boolean);
const prevs = record.get("prevs").filter(Boolean);
const nexts = record.get("nexts").filter(Boolean);
const parts = [`景点「${name}」`];
if (days.length) parts.push(`属于:${days.join("、")}`);
if (prevs.length) parts.push(`前面是:${prevs.join("、")}`);
if (nexts.length) parts.push(`后面是:${nexts.join("、")}`);
return `- ${parts.join(";")}`;
})
.join("\n");
} finally {
await session.close();
}
}
查出来的不是文档对象,而是几行可直接塞进 Prompt 的图谱召回结果,例如:
text
- 景点「洪崖洞」;属于:第一天;前面是:解放碑
多轮检索场景下,通过 mergeGraphContext 按行粒度执行去重合并,避免同一条图谱证据被重复写入上下文。
js
export function mergeGraphContext(existing, incoming) {
const prev = String(existing ?? "").trim();
const next = String(incoming ?? "").trim();
if (!next) return prev;
if (!prev) return next;
const seen = new Set(
prev
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
);
for (const line of next.split("\n")) {
const text = line.trim();
if (text) seen.add(text);
}
return Array.from(seen).join("\n");
}
图谱召回结果写入 graphContext 后,由下游各节点按需独立消费: rerankNode 仅处理文档片段,不会读取图谱上下文。 plan_next、evaluate、generate 三个节点都会读取 graphContext,将它与文档上下文分开,各自注入 Prompt:
plan_next:依据图谱信息判断是否需要继续检索;evaluate:结合图谱信息判断现有证据是否足以作答;generate:把图谱信息整合进最终回答。
三个节点遵循同一逻辑:从 state.graphContext 提取文本,封装成一段独立的上下文,提交给大模型。
下面是 generate 节点 Prompt 的拼接:
js
async function generateNode(state) {
return {
generation: await streamRagAnswer(llm, {
question: state.question,
localContext: formatLocalContext(state.documents ?? []),
graphContext: state.graphContext,
webContext: state.webContext,
}),
};
}
export async function streamRagAnswer(
model,
{ question, localContext, graphContext, webContext },
) {
const parts = [];
if (localContext?.trim()) {
parts.push(`===== 本地文档 =====\n${localContext.trim()}`);
}
if (graphContext?.trim()) {
parts.push(`===== 图关系(Neo4j) =====\n${graphContext.trim()}`);
}
if (webContext?.trim()) {
parts.push(`===== 联网补充 =====\n${webContext.trim()}`);
}
return streamText(
model,
`你是旅游助手。优先依据上下文作答,不要编造。
上下文:
${parts.join("\n\n") || "(空)"}
用户问题:${question}
回答要求:
1. 行程从属/先后优先看图关系,介绍类内容看文档片段。
2. 上下文仍不足时,明确说明不确定。`,
);
}
最后,完整的图谱检索执行流程如下:

总结
Neo4j 属于属性图数据库:节点代表实体,标签用于实体分类,关系为有向边,属性与节点、关系一同存储。 文档检索擅长筛选哪些片段是有效材料 ;图谱擅长表达实体之间如何关联 。 比如查询洪崖洞适合白天还是晚上游玩,依靠文档片段;查询它属于第几天行程、前一个景点是谁,则可以直接沿着包含、随后关系查边,无需让模型从大段文本里推导关系。
三者各司其职,Neo4j 并不替代 pgvector 与 ES:
- pgvector:匹配语义相似的文档片段
- ES:实现关键词命中检索
- Neo4j:解析实体之间的关联关系
在 Agentic RAG 链路之上,整体流程(路由、问题拆解、检索、规划、评估、生成)保持不变。Neo4j 检索逻辑内嵌在 retrieve 节点内部,与 pgvector、ES 三路并行执行。文档结果送入 rerank 链路;图谱召回结果存入 graphContext,供给下游 plan_next、evaluate、generate 读取并注入 Prompt。图库故障或查询无结果时返回空字符串,文档主链路不受影响,正常运行。