Neo4j:给 RAG 补上关系检索

在前面一篇 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 :节点和关系上的键值对,例如 namesuitableTime

可以先和关系型数据库做一个对照:

Neo4j 关系型数据库 示例
节点 一行记录 洪崖洞、解放碑、第一天
标签 表名 / 类型 AttractionDayPlan
关系 外键或中间表 包含随后
属性 namesuitableTime

关系型数据库将实体关联通过外键分散存储,多跳关联查询需要执行多次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 避免这个问题。

执行后,左侧面板里应能看到 DayPlanAttraction 两个标签,以及 包含随后 两类关系。

查询使用 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 DayPlanAttraction 行程日、景点
Rel Type 包含随后 从属、先后
属性 namesource 合并主键、文档溯源

边写成白名单,不在名单里的关系直接丢掉:

  • 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_nextevaluategenerate 三个节点都会读取 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_nextevaluategenerate 读取并注入 Prompt。图库故障或查询无结果时返回空字符串,文档主链路不受影响,正常运行。

相关推荐
whcyhhh2 小时前
CTF‑MISC 隐写术完整学习笔记|图片隐写全题型 + 工具 + 实战例题
python·网络安全·ctf·misc·信息隐藏
码视野2 小时前
多宠 RFID 颈圈识别与湿粮半导体制冷保鲜分餐喂食器解决方案(软硬件一体化)
大数据·人工智能·python
未若君雅裁2 小时前
让模型按契约返回数据,LangChain 结构化输出实战
python·langchain
学习中.........3 小时前
Transformer 训练资源估算:以 CS336 GPT-2 XL 配置为例
人工智能·python·算法·机器学习·自然语言处理
Setsuna_F_Seiei8 小时前
前端的 AI 学习之路 02 之 Provider 与 Structured Output - 规范化模型输入输出
人工智能·agent·ai编程
小陈的进阶之路9 小时前
Claude Code辅助测试:导入篇skills
python·自动化
whcyhhh10 小时前
头歌实践教学平台:大数据存储2023(六)
大数据·数据库·python
for_ever_love__11 小时前
python基础语法学习: 闭包
开发语言·python·学习·闭包
ly768911 小时前
Python 全面入门:从核心语法到工程实践
开发语言·python