Neo4j + Graph RAG 医疗知识图谱工程实践:患者教育问答真正需要的是“关系可追溯”

做医疗患者教育问答时,很多团队第一反应是把科普文章、指南摘要、院内宣教材料全部切片,然后丢进向量库。用户问"高血压要看哪个科""为什么建议做血压监测""糖尿病患者教育材料应该关联哪些检查",系统就从文档里召回几段相似文本,再让大模型总结。

这条路能跑通,但很快会遇到一个问题:患者教育知识不是只有"文本相似",还有大量"实体关系"。

比如"高血压"不是孤立的一段说明,它可能关联到常见表现、风险器官、建议就诊科室、常见检查、生活方式教育材料、随访提醒。用户真正想问的也不一定是"高血压是什么",而是:

  • 高血压相关的患者教育材料有哪些?
  • 哪些疾病主题会关联到血压监测?
  • 某个症状常被哪些健康主题提到?
  • 为什么这个主题会被分到心内科患者教育?
  • 一个健康主题、一个检查项目、一份宣教材料之间是什么关系?

这些问题如果只靠向量检索,答案往往依赖大模型在多段文本之间临时拼关系;如果关系没有被同一段文本完整写出来,模型就容易漏掉、猜测或者答得含糊。Graph RAG 的价值就在这里:它不是替代医生,也不是让模型做诊断,而是把患者教育知识里的"疾病主题、症状、检查、科室、材料"建成一张可查询的关系网络,让 RAG 的检索结果从"相似文本"升级为"可追溯关系"。

这篇文章的主结论是:

医疗患者教育场景中的 Graph RAG,核心不是让大模型懂医学,而是把医学科普材料中的实体和关系结构化,让系统能回答"谁关联谁、为什么关联、从哪里追溯"的问题。工程上真正要做稳的是图谱 schema、关系方向、Cypher 生成约束、只读安全、证据来源和医疗边界。

本文只讨论患者教育和知识库检索,不讨论自动诊断、处方推荐、治疗决策。文中的疾病、检查、科室关系是工程建模示例,不能替代医生判断。

为什么医疗患者教育问答不只是"找相似文档"

普通 RAG 的基本流程是:文档切分、生成 Embedding、向量检索、把召回片段塞给大模型生成答案。它适合回答"这份材料里怎么解释某个概念"这类问题。

但医疗患者教育里常见的问题经常不是单点解释,而是关系型查询。

举个例子,用户问:

高血压患者教育应该覆盖哪些检查和宣教材料?

如果你的知识库里有很多文章,向量检索可能召回"高血压介绍""家庭血压监测""低盐饮食建议""心血管风险管理"等片段。问题是,系统并不知道这些片段之间的结构关系。它只能把几个片段拼给模型,让模型自己判断哪些属于检查、哪些属于宣教材料、哪些只是背景说明。

如果改成图谱建模,知识可以表达为:

  • 高血压 关联 血压测量
  • 高血压 归属 心内科
  • 高血压 关联 家庭血压监测教育
  • 高血压 关联 生活方式管理教育
  • 血压测量检查项目
  • 家庭血压监测教育患者教育材料

这时用户问"覆盖哪些检查和宣教材料",系统可以沿着明确关系查,而不是让模型在文本里猜。

flowchart LR Q[用户问题] --> V[向量检索] Q --> G[图谱检索] V --> V1[返回语义相似段落] V1 --> V2[模型从段落中归纳关系] G --> G1[查询疾病主题相关实体] G1 --> G2[返回检查 科室 材料等结构化关系] V2 --> A[答案依赖上下文完整性] G2 --> B[答案可追溯到图谱路径]

这不是说向量检索没用。医疗知识库里有大量长文、解释性文本、注意事项、患者问答,向量检索仍然是基础能力。Graph RAG 解决的是另一类问题:当用户问的是关系、路径、层级、依赖和解释链路时,图谱比文本片段更可靠。

先把边界说清楚:这不是临床决策系统

医疗场景最容易犯的错误,是把"患者教育问答"做着做着变成"自动诊断和用药建议"。这条边界必须从架构设计开始就写清楚。

本文示例系统只做三类事情:

  1. 帮用户理解患者教育材料里的概念关系。
  2. 帮医院、健康管理机构或知识库团队组织宣教内容。
  3. 根据已有知识图谱回答"某主题关联哪些症状、检查、科室、材料"这类检索问题。

它不做这些事情:

  • 不根据个人症状给诊断结论。
  • 不推荐药物、剂量或治疗方案。
  • 不判断检查结果是否正常。
  • 不替代医生、护士、药师或其他专业人员。

例如,CDC 介绍高血压时明确提醒高血压通常没有明显症状,但可能影响心脏、大脑、肾脏和眼睛等器官;CDC 的糖尿病患者教育材料也把症状、检测和自我管理教育分成不同主题。我们可以把这些公开患者教育资料作为知识来源,但系统输出时必须保持"教育和检索"定位,而不是做临床判断。

在工程上,这意味着答案生成 prompt 里必须有类似约束:

text 复制代码
你是患者教育知识库助手,只能基于检索结果解释知识关系。
不要给出诊断结论、治疗方案、药物剂量或个体化医疗建议。
如果问题涉及症状判断、检查解读或治疗决策,应建议用户咨询合格医疗专业人员。

这个约束不是免责声明装饰,而是产品边界。Graph RAG 能让知识关系更清晰,但不能把普通问答系统变成医疗器械级应用。

图谱 schema:先设计"能回答的问题",再设计节点

很多人做知识图谱会犯一个错误:看到名词就建节点,看到两个词同段出现就连边。这样做出来的图很快变成一团噪声。

医疗患者教育知识图谱应该反过来设计:先确定要回答的问题,再设计实体和关系。

本文的示例目标是回答这类问题:

  • 某个健康主题有哪些常见表现?
  • 某个健康主题通常归属哪个科室宣教范围?
  • 某个健康主题关联哪些检查项目?
  • 某个健康主题有哪些患者教育材料?
  • 某个检查项目被哪些健康主题引用?

因此我们只需要一个小而清晰的 schema:

节点类型 含义 示例
Disease 疾病或健康主题 高血压、2 型糖尿病
Symptom 患者教育中提到的常见表现 口渴、多尿、头痛
Exam 检查或监测项目 血压测量、糖化血红蛋白
Department 科室或宣教归口 心内科、内分泌科
EducationMaterial 患者教育材料 家庭血压监测指南、饮食运动教育

关系可以先控制在四类:

关系 方向 含义
HAS_SYMPTOM Disease -> Symptom 主题材料中提到的常见表现
RECOMMENDS_EXAM Disease -> Exam 主题材料中关联的检查或监测
BELONGS_TO_DEPARTMENT Disease -> Department 宣教内容归口科室
HAS_EDUCATION Disease -> EducationMaterial 主题关联的患者教育材料

这张图可以表示成:

graph LR H[Disease 高血压] -->|BELONGS_TO_DEPARTMENT| C[Department 心内科] H -->|RECOMMENDS_EXAM| BP[Exam 血压测量] H -->|HAS_EDUCATION| E1[EducationMaterial 家庭血压监测教育] H -->|HAS_EDUCATION| E2[EducationMaterial 生活方式管理教育] D[Disease 2型糖尿病] -->|BELONGS_TO_DEPARTMENT| EN[Department 内分泌科] D -->|HAS_SYMPTOM| S1[Symptom 口渴] D -->|HAS_SYMPTOM| S2[Symptom 多尿] D -->|RECOMMENDS_EXAM| A1[Exam 糖化血红蛋白] D -->|HAS_EDUCATION| E3[EducationMaterial 血糖监测教育]

这里有两个关键判断。

第一,Disease 在这个系统里更准确地说是"患者教育健康主题",不等同于临床诊断对象。它只代表知识库组织内容的主题。

第二,关系方向必须统一。比如所有主题都从 Disease 指向 Exam,不要一部分写成 (Exam)-[:USED_FOR]->(Disease),另一部分写成 (Disease)-[:RECOMMENDS_EXAM]->(Exam)。关系方向混乱会直接导致 LLM 生成 Cypher 不稳定。

用 Cypher 初始化一张小型医疗患者教育图谱

下面用 Neo4j Cypher 建一个最小可运行图谱。为了避免重复执行产生重复节点,工程里推荐使用 MERGE,而不是教学 demo 常见的 CREATE

cypher 复制代码
CREATE CONSTRAINT disease_name IF NOT EXISTS
FOR (d:Disease)
REQUIRE d.name IS UNIQUE;

CREATE CONSTRAINT exam_name IF NOT EXISTS
FOR (e:Exam)
REQUIRE e.name IS UNIQUE;

CREATE CONSTRAINT material_title IF NOT EXISTS
FOR (m:EducationMaterial)
REQUIRE m.title IS UNIQUE;

约束的作用不是"性能优化"这么简单。它首先保证同名实体不会被反复创建。知识图谱最怕实体重复,因为重复节点会让关系断裂。例如一个地方叫"2 型糖尿病",另一个地方叫"2型糖尿病",如果不做归一化,系统查询时会漏掉一半关系。

接着写入示例节点:

cypher 复制代码
MERGE (h:Disease {name: "高血压"})
SET h.category = "患者教育主题"

MERGE (d:Disease {name: "2型糖尿病"})
SET d.category = "患者教育主题"

MERGE (cardiology:Department {name: "心内科"})
MERGE (endocrine:Department {name: "内分泌科"})

MERGE (bp:Exam {name: "血压测量"})
MERGE (a1c:Exam {name: "糖化血红蛋白"})
MERGE (glucose:Exam {name: "血糖检测"})

MERGE (thirst:Symptom {name: "口渴"})
MERGE (urination:Symptom {name: "多尿"})

MERGE (bpGuide:EducationMaterial {
  title: "家庭血压监测教育",
  type: "患者宣教"
})

MERGE (lifestyle:EducationMaterial {
  title: "生活方式管理教育",
  type: "患者宣教"
})

MERGE (glucoseGuide:EducationMaterial {
  title: "血糖监测教育",
  type: "患者宣教"
})

然后写入关系:

cypher 复制代码
MATCH (h:Disease {name: "高血压"})
MATCH (cardiology:Department {name: "心内科"})
MATCH (bp:Exam {name: "血压测量"})
MATCH (bpGuide:EducationMaterial {title: "家庭血压监测教育"})
MATCH (lifestyle:EducationMaterial {title: "生活方式管理教育"})
MERGE (h)-[:BELONGS_TO_DEPARTMENT]->(cardiology)
MERGE (h)-[:RECOMMENDS_EXAM]->(bp)
MERGE (h)-[:HAS_EDUCATION]->(bpGuide)
MERGE (h)-[:HAS_EDUCATION]->(lifestyle);

MATCH (d:Disease {name: "2型糖尿病"})
MATCH (endocrine:Department {name: "内分泌科"})
MATCH (a1c:Exam {name: "糖化血红蛋白"})
MATCH (glucose:Exam {name: "血糖检测"})
MATCH (thirst:Symptom {name: "口渴"})
MATCH (urination:Symptom {name: "多尿"})
MATCH (glucoseGuide:EducationMaterial {title: "血糖监测教育"})
MERGE (d)-[:BELONGS_TO_DEPARTMENT]->(endocrine)
MERGE (d)-[:RECOMMENDS_EXAM]->(a1c)
MERGE (d)-[:RECOMMENDS_EXAM]->(glucose)
MERGE (d)-[:HAS_SYMPTOM]->(thirst)
MERGE (d)-[:HAS_SYMPTOM]->(urination)
MERGE (d)-[:HAS_EDUCATION]->(glucoseGuide);

这段数据不是为了建立完整医学知识库,而是为了让 Graph RAG 的关系检索跑起来。真实系统里的每条边都应该带上来源,例如 source_urlsource_doc_idsource_paragraphreview_statusupdated_at。尤其是医疗内容,不能只存"关系存在",还要能追溯"关系来自哪里、谁审核过、什么时候更新"。

一个更工程化的关系可以这样写:

cypher 复制代码
MATCH (h:Disease {name: "高血压"})
MATCH (bp:Exam {name: "血压测量"})
MERGE (h)-[r:RECOMMENDS_EXAM]->(bp)
SET r.source = "CDC patient education",
    r.review_status = "example_only",
    r.updated_at = datetime();

这会让后续答案生成更可信:系统不只是说"图谱里有这条关系",还可以展示来源和审核状态。

Graph RAG 系统架构:让大模型生成查询,而不是生成事实

医疗患者教育 Graph RAG 的查询链路可以拆成四步:

  1. 用户提出自然语言问题。
  2. 大模型根据 schema 生成只读 Cypher。
  3. Neo4j 执行图查询,返回结构化关系结果。
  4. 大模型基于查询结果生成患者教育口径的回答。
flowchart TD U[用户问题] --> P[问题理解] P --> C[LLM 生成只读 Cypher] C --> S[Cypher 安全校验] S --> N[Neo4j 图查询] N --> R[实体关系结果] R --> A[LLM 基于证据生成回答] A --> O[患者教育口径输出] subgraph KG[医疗患者教育知识图谱] D[Disease] Sym[Symptom] Exam[Exam] Dept[Department] Mat[EducationMaterial] end N --> KG

注意这句话:让大模型生成查询,而不是生成事实。

在传统问答里,模型经常直接回答"你应该做什么"。在 Graph RAG 里,模型第一阶段只负责把自然语言问题转换成检索计划;事实来自 Neo4j 查询结果。第二阶段模型再把结构化结果组织成自然语言。这样做可以显著降低模型自由发挥的空间。

当然,"让模型生成 Cypher"本身也有风险,所以必须有安全校验和权限限制。后面会单独讲。

用 JavaScript 连接 Neo4j:先把数据库层跑稳

在接入 LangGraph 之前,建议先用 neo4j-driver 写一个最小查询脚本。这样可以把问题拆开:先确认数据和查询正确,再调 LLM。

js 复制代码
import neo4j from "neo4j-driver"

const driver = neo4j.driver(
  process.env.NEO4J_URI ?? "bolt://localhost:7687",
  neo4j.auth.basic(
    process.env.NEO4J_USERNAME ?? "neo4j",
    process.env.NEO4J_PASSWORD ?? "12345678"
  )
)

export async function queryDiseaseEducation(topicName) {
  const session = driver.session({ defaultAccessMode: neo4j.session.READ })

  try {
    const result = await session.run(
      `
      MATCH (d:Disease {name: $topicName})
      OPTIONAL MATCH (d)-[:RECOMMENDS_EXAM]->(exam:Exam)
      OPTIONAL MATCH (d)-[:HAS_EDUCATION]->(material:EducationMaterial)
      OPTIONAL MATCH (d)-[:BELONGS_TO_DEPARTMENT]->(department:Department)
      RETURN d.name AS topic,
             collect(DISTINCT exam.name) AS exams,
             collect(DISTINCT material.title) AS materials,
             collect(DISTINCT department.name) AS departments
      `,
      { topicName }
    )

    return result.records.map(record => ({
      topic: record.get("topic"),
      exams: record.get("exams"),
      materials: record.get("materials"),
      departments: record.get("departments"),
    }))
  } finally {
    await session.close()
  }
}

这段代码在系统里的位置很底层:它不做自然语言理解,只负责执行明确的参数化 Cypher。

为什么这里要用 $topicName 参数,而不是字符串拼接?

因为用户输入不能直接拼到查询语句里。即使 Cypher 注入风险和 SQL 注入表现不完全一样,原则也是一致的:用户输入应该作为参数传入,而不是参与构造语法结构。Graph RAG 里如果让模型和用户输入共同影响查询语句,就更需要加安全边界。

为什么 session 用 READ

患者教育问答阶段只需要读图谱,不应该有写权限。即使代码里误执行了写入语句,数据库权限也应该尽量拦住。这是医疗场景里非常重要的一层防线。

用 LangGraph 编排 Graph RAG 查询链路

接下来把自然语言问题接进来。技术栈可以沿用 @langchain/communityNeo4jGraph@langchain/openaiChatOpenAI,以及 @langchain/langgraphStateGraph

js 复制代码
import "dotenv/config"
import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph"
import { ChatOpenAI } from "@langchain/openai"
import { StateGraph, START, END } from "@langchain/langgraph"
import { HumanMessage } from "@langchain/core/messages"

const graph = new Neo4jGraph({
  url: process.env.NEO4J_URI ?? "bolt://localhost:7687",
  username: process.env.NEO4J_USERNAME ?? "neo4j",
  password: process.env.NEO4J_PASSWORD ?? "12345678",
})

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

function userQuery(state) {
  return state.messages[state.messages.length - 1].content
}

状态里只保留三个核心中间产物:cyphercontextanswer。这对应 Graph RAG 的三段式链路:生成查询、执行检索、生成回答。

生成 Cypher:必须把 schema 明确写给模型

生成 Cypher 的 prompt 不能写得太空。模型不知道你的图谱结构,除非你明确告诉它节点、关系和方向。

js 复制代码
async function generateCypher(state) {
  const prompt = `
你是医疗患者教育知识图谱的 Neo4j Cypher 生成器。
你只能生成只读查询,只返回 Cypher,不要解释,不要 markdown。

节点:
- Disease: 疾病或患者教育健康主题
- Symptom: 患者教育材料中提到的常见表现
- Exam: 检查或监测项目
- Department: 科室或宣教归口
- EducationMaterial: 患者教育材料

关系方向:
- (Disease)-[:HAS_SYMPTOM]->(Symptom)
- (Disease)-[:RECOMMENDS_EXAM]->(Exam)
- (Disease)-[:BELONGS_TO_DEPARTMENT]->(Department)
- (Disease)-[:HAS_EDUCATION]->(EducationMaterial)

规则:
1. 只能使用 MATCH、OPTIONAL MATCH、WHERE、RETURN、WITH、LIMIT。
2. 不允许 CREATE、MERGE、SET、DELETE、DETACH DELETE、CALL。
3. 不要生成诊断、治疗或用药建议相关查询。
4. 如果用户问某个主题关联哪些检查或材料,优先从 Disease 出发查询。

用户问题:${userQuery(state)}
`

  const res = await llm.invoke([new HumanMessage(prompt)])
  return { cypher: res.content.trim() }
}

这段 prompt 有三个目的。

第一,限定模型只能在已知 schema 里工作。否则模型可能生成 MedicationTreatmentDoctor 之类你图谱里根本没有的节点。

第二,强调关系方向。比如"哪些检查关联到高血压"可以写成从 DiseaseExam,也可以反向匹配,但关系真实方向不能错。

第三,明确医疗边界。用户如果问"我有这些症状是不是糖尿病",这个系统不应该生成一条看似聪明的诊断查询,而应该在答案阶段提示咨询专业人员。

执行前先校验:不要无条件执行 LLM 生成的 Cypher

Graph RAG 最容易被忽视的风险是:你让大模型生成了数据库查询,然后直接执行。

本地 demo 可以这么写,生产环境不能这么写。尤其是医疗知识库,图谱数据可能包含院内材料、流程、审核状态、版本信息,不能让模型有写入或调用高危 procedure 的机会。

可以先加一个简单校验:

js 复制代码
function assertReadOnlyCypher(cypher) {
  const normalized = cypher
    .replace(/```cypher|```/gi, "")
    .trim()
    .toLowerCase()

  const forbidden = [
    "create",
    "merge",
    "set",
    "delete",
    "detach",
    "remove",
    "drop",
    "call",
    "load csv",
  ]

  if (!normalized.startsWith("match") && !normalized.startsWith("optional match")) {
    throw new Error("Only read-only MATCH queries are allowed")
  }

  if (forbidden.some(keyword => normalized.includes(keyword))) {
    throw new Error("Unsafe Cypher keyword detected")
  }
}

这不是完整安全方案,但它体现了工程原则:LLM 输出只能被当作候选查询,不能被当作可信指令。

更严谨的做法还包括:

  • 使用只读 Neo4j 用户。
  • 对节点标签和关系类型做白名单。
  • 限制返回行数和路径深度。
  • 设置查询超时。
  • 记录生成 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" }
  }
}

这里不要把所有异常都说成"未查询到知识"。空结果、语法错误、连接失败、权限错误是不同状态。真实系统至少要在日志里区分它们,否则后续排查会非常痛苦。

答案生成函数要更谨慎:

js 复制代码
async function generateAnswer(state) {
  const prompt = `
你是患者教育知识库助手。请只根据「图谱检索结果」回答用户问题。

边界:
- 只能解释健康主题、症状、检查、科室、患者教育材料之间的知识关系。
- 不要给出诊断结论。
- 不要给出治疗方案、药物名称、剂量或个体化医疗建议。
- 如果检索结果为空或失败,说明当前知识图谱没有足够证据。
- 如果用户问题涉及症状判断、检查解读或治疗决策,建议咨询合格医疗专业人员。

图谱检索结果:${state.context}
用户问题:${userQuery(state)}
`

  const res = await llm.invoke([new HumanMessage(prompt)])
  return { answer: res.content }
}

这个 prompt 的核心不是"语气温和",而是把生成边界写死。Graph RAG 返回的是关系证据,不是个体化医疗判断。模型可以说"图谱显示高血压主题关联血压测量和家庭血压监测教育",但不能说"你应该如何治疗高血压"。

最后编排工作流:

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

export async function runGraphRAG(question) {
  return app.invoke({
    messages: [new HumanMessage(question)],
  })
}

这个工作流看起来是线性的,但它适合继续扩展。比如后续可以在 generateCypherexecuteGraph 之间加入 validateCypher 节点,在查询失败后加入 repairCypher 节点,在图谱结果为空时走向量检索兜底。

一个完整查询例子:从自然语言到图谱路径

假设用户问:

高血压患者教育应该覆盖哪些检查和材料?

模型在 schema 约束下应该生成类似查询:

cypher 复制代码
MATCH (d:Disease {name: "高血压"})
OPTIONAL MATCH (d)-[:RECOMMENDS_EXAM]->(exam:Exam)
OPTIONAL MATCH (d)-[:HAS_EDUCATION]->(material:EducationMaterial)
OPTIONAL MATCH (d)-[:BELONGS_TO_DEPARTMENT]->(department:Department)
RETURN d.name AS topic,
       collect(DISTINCT exam.name) AS exams,
       collect(DISTINCT material.title) AS materials,
       collect(DISTINCT department.name) AS departments
LIMIT 10

Neo4j 返回结构化结果后,模型可以回答:

text 复制代码
根据当前患者教育知识图谱,高血压主题关联的检查/监测项目包括:血压测量。
关联的患者教育材料包括:家庭血压监测教育、生活方式管理教育。
该主题当前归口科室为:心内科。

以上内容仅来自当前知识图谱,用于患者教育材料检索,不构成诊断或治疗建议。

这个答案有几个特点:

  • 它没有扩写药物治疗。
  • 它没有判断用户是否患病。
  • 它没有把常识补成图谱事实。
  • 它明确说明来自当前知识图谱。

这就是 Graph RAG 在医疗患者教育场景里的正确姿势:用结构化关系支持知识检索,用边界控制避免越权回答。

为什么不是只用向量库:三种检索应该组合

医疗知识库的检索对象很复杂。既有长篇患者教育文章,也有科室流程、检查说明、问答材料、术语解释、宣教视频脚本。单一检索方式很难覆盖全部问题。

检索方式 适合问题 不适合问题 医疗知识库里的位置
向量检索 "这段材料怎么解释""有没有相似问答" 稳定关系、多跳路径、强约束查询 召回长文本和语义相近材料
BM25/关键词检索 检查名、编码、材料标题、科室名称 模糊表达、跨文档关系 精确命中术语和标题
Neo4j Graph RAG 主题关联哪些症状/检查/科室/材料 大段原文解释、开放式总结 回答关系型问题和提供可追溯路径

更实际的架构通常是混合检索:

flowchart TD Q[用户问题] --> Router[问题路由] Router -->|关系型问题| Graph[Neo4j 图谱检索] Router -->|语义解释问题| Vector[向量检索] Router -->|术语标题问题| Keyword[BM25 关键词检索] Graph --> Evidence[证据整理] Vector --> Evidence Keyword --> Evidence Evidence --> Guard[医疗边界检查] Guard --> LLM[基于证据回答] LLM --> User[患者教育口径输出]

比如:

  • "高血压关联哪些患者教育材料?"优先走图谱。
  • "家庭血压监测这份材料讲了什么?"优先走向量检索。
  • "HbA1c 这个检查在哪些材料里出现?"优先走关键词检索,再结合图谱查关系。

Graph RAG 不应该被神化。它补的是"关系可查询"这一层,不是替代所有检索。

从文档到图谱:真实项目里更难的是数据治理

上面的 Cypher 是手写示例。真实医疗知识库通常要从文档中抽取实体和关系。

一个较完整的导入链路是:

flowchart TD Doc[患者教育文档] --> Parse[解析与清洗] Parse --> Chunk[按主题切分] Chunk --> Extract[实体关系抽取] Extract --> Normalize[实体归一化] Normalize --> Review[人工或规则审核] Review --> Neo4j[写入 Neo4j] Chunk --> Embed[生成 Embedding] Embed --> VectorDB[写入向量库] Neo4j --> QA[Graph RAG 问答] VectorDB --> QA

这里最难的不是"怎么写入 Neo4j",而是四件事。

第一,实体归一化。比如"2 型糖尿病""2型糖尿病""成人糖尿病""T2D"可能在材料里混用。是否合并,怎么合并,需要业务规则。

第二,关系审核。医学患者教育材料里,关系不是普通网页标签。一个"疾病 -> 检查"的关系,如果后续用于回答用户问题,就应该知道它来自哪个材料、是否经过审核、是否适用于当前机构口径。

第三,版本管理。患者教育材料会更新,图谱边也要更新。不能只追加不删除,否则旧关系会残留。

第四,证据回溯。最终答案最好能追溯到文档来源,而不是只返回图谱节点。否则用户或审核人员无法判断答案依据。

因此真实落地时,图谱边至少应该具备这些属性:

cypher 复制代码
MATCH (d:Disease {name: "2型糖尿病"})
MATCH (exam:Exam {name: "糖化血红蛋白"})
MERGE (d)-[r:RECOMMENDS_EXAM]->(exam)
SET r.source_doc_id = "patient-edu-diabetes-001",
    r.source_title = "糖尿病患者教育材料",
    r.review_status = "reviewed",
    r.version = "2026-05",
    r.updated_at = datetime();

有了这些属性,Graph RAG 才能从"会回答"升级为"能审计"。

参数与配置:哪些设置会影响稳定性

第一是模型温度。Cypher 生成建议使用 temperature: 0。这是结构化生成任务,不需要创意。温度高会让输出不稳定,尤其容易出现解释文字、错误标签或自创关系。

第二是 schema prompt。Graph RAG 的 prompt 不是普通问答 prompt,而是数据库 schema 的自然语言接口。节点、关系、方向、禁止事项都要写清楚。schema 一变,prompt 必须同步更新。

第三是查询限制。生产系统里建议给每次图查询设置:

  • 最大返回行数。
  • 最大路径跳数。
  • 查询超时。
  • 允许的节点标签和关系类型。
  • 只读权限。

第四是上下文格式。小 demo 可以直接 JSON.stringify(rows),但真实系统里结果可能很长。更好的做法是把结果整理成紧凑证据:

json 复制代码
{
  "topic": "高血压",
  "departments": ["心内科"],
  "exams": ["血压测量"],
  "materials": ["家庭血压监测教育", "生活方式管理教育"],
  "source": ["patient-edu-hypertension-001"]
}

这样模型更容易按事实回答,也更容易控制 token 成本。

第五是 fallback 策略。图谱查不到时,不要立刻编答案。可以有三种策略:

  • 明确告诉用户当前图谱没有足够证据。
  • 转向向量检索查找相关宣教材料。
  • 把问题转给人工或专业渠道。

医疗患者教育场景里,宁可少答,也不要无证据扩写。

常见误区

第一个误区:把 Graph RAG 当成"更强大模型"。Graph RAG 强的是关系检索,不是医学推理能力。模型仍然可能生成错误 Cypher、误读结果或越界回答,所以必须有校验和边界。

第二个误区:把所有医学名词都抽成节点。图谱不是词典。节点应该服务于可回答的问题。患者教育问答里,常用的节点可能是主题、症状、检查、材料、科室,而不是材料里出现的每一个名词。

第三个误区:忽略关系方向。Disease -> ExamExam -> Disease 都能表达关联,但系统里必须统一。关系方向一乱,Cypher 生成就会变成概率游戏。

第四个误区:图谱没有来源。医疗知识库的每条关系都应该能追溯到材料和审核状态。没有来源的图谱很难进入严肃业务流程。

第五个误区:让模型直接回答诊断问题。用户问"我口渴多尿是不是糖尿病",患者教育系统最多能说"这些表现可出现在相关健康教育材料中,是否患病需要由专业人员结合检查判断",不能给诊断。

第六个误区:用 Graph RAG 替代向量检索。患者教育材料大量是解释性长文,图谱适合关系,向量适合原文召回,两者应该组合。

工程建议:医疗场景要先稳,再追求智能

第一,先从少量高频主题做起。比如高血压、糖尿病、慢病随访、检查说明、术前宣教。不要一开始就试图覆盖所有疾病。

第二,schema 要保守。先定义少数稳定关系,例如关联检查、归口科室、关联教育材料。不要急着建复杂治疗路径。

第三,把医学内容来源和审核状态作为一等公民。Graph RAG 的答案最好能展示"来自当前知识图谱中的哪类材料",内部日志要能追溯到具体文档。

第四,LLM 只做语言接口和表达整理,不做最终医学判断。所有医学事实都应该来自图谱、文档或经过审核的知识源。

第五,做评测时分三层看:

  • Cypher 是否生成正确。
  • 图谱结果是否召回正确关系。
  • 最终回答是否忠实于查询结果并遵守医疗边界。

第六,保留人工审核入口。医疗患者教育不是普通闲聊,内容改动、关系新增、材料下线都应该有审核流程。

第七,记录完整链路。至少记录用户问题、生成 Cypher、图查询结果摘要、答案、是否触发医疗边界提醒。没有可观测性,Graph RAG 出错时很难定位到底是抽取错、查询错,还是模型总结错。

一个更接近生产的最小骨架

如果把上面的思路压缩成一个工程骨架,可以是这样:

js 复制代码
async function answerPatientEducationQuestion(question) {
  const cypher = await generateCypher(question)
  const normalizedCypher = stripMarkdownFence(cypher)

  assertReadOnlyCypher(normalizedCypher)
  assertSchemaAllowlist(normalizedCypher, {
    labels: ["Disease", "Symptom", "Exam", "Department", "EducationMaterial"],
    relationships: [
      "HAS_SYMPTOM",
      "RECOMMENDS_EXAM",
      "BELONGS_TO_DEPARTMENT",
      "HAS_EDUCATION",
    ],
  })

  const graphRows = await queryNeo4j(normalizedCypher, {
    timeoutMs: 3000,
    maxRows: 50,
  })

  if (graphRows.length === 0) {
    return answerWithNoEvidence(question)
  }

  const evidence = compactGraphRows(graphRows)
  return generatePatientEducationAnswer(question, evidence)
}

这段代码表达了几个工程原则:

  • generateCypher 可以用 LLM,但不能信任它。
  • assertReadOnlyCypherassertSchemaAllowlist 是执行前防线。
  • queryNeo4j 要有超时和返回行数限制。
  • compactGraphRows 要把数据库结果整理成可控证据。
  • generatePatientEducationAnswer 只能基于证据回答。

如果系统以后要加向量检索,可以在 graphRows.length === 0 时进入向量检索兜底,也可以由问题路由器提前判断走哪条检索链路。

总结:医疗 Graph RAG 的价值是把关系变成证据

医疗患者教育知识库不是普通 FAQ。它里面既有长文本解释,也有大量实体关系:疾病主题关联症状、检查、科室、宣教材料、来源文档和审核状态。传统 RAG 能找到相似文本,但不一定能稳定回答关系型问题。

Neo4j + Graph RAG 的价值,是把这些关系变成可查询的工程资产:

  • 用户问关系,系统查图谱。
  • 图谱返回结构化证据。
  • 大模型基于证据组织患者教育口径的回答。
  • 答案能追溯到节点、关系和来源材料。

但这条路也有明确边界。它不应该做诊断,不应该推荐治疗,不应该绕过专业审核。医疗场景里的 Graph RAG 要先稳,再智能;先可追溯,再追求覆盖率;先控制边界,再提高表达能力。

如果把这套思路落到真实项目里,我会把默认方案定为"混合检索":向量库负责语义召回,BM25 负责精确命中,Neo4j 负责关系查询,LLM 负责把证据整理成用户能理解的语言。Graph RAG 不是万能答案,但它补上了传统 RAG 最缺的一块:关系可执行、路径可追溯、证据可审计。

参考资料

相关推荐
源码宝2 小时前
MES系统源码:Java8 + SpringBoot2.7 + MySQL8 + Redis,后端源码清爽易扩展
java·后端·源码·springboot·mes系统·源码二开·mes源码
CC大煊2 小时前
一个Javaer的AI转型笔记(1):入坑LangChain,我的第一个hello world
笔记·langchain
金銀銅鐵2 小时前
[Java] 如何理解 class 文件中方法的 descriptor?
java·后端
村口张大爷2 小时前
05 — 分层架构与依赖倒置
后端·架构·系统架构
Jasonakeke4 小时前
SpringBoot自动配置原理揭秘
java·spring boot·后端
沐自礼4 小时前
DeepSeekMoE 原理
人工智能·llm
IT_陈寒5 小时前
Vite热更新失灵?你可能漏了这个配置
前端·人工智能·后端
Mr.Daozhi5 小时前
RAG 进阶实战:跑通 Demo 后我连续翻了 6 次车,逐一修复才真正可用(含 Gradio Web 版)
前端·数据库·langchain·大模型·gradio·rag·科研工具
uzong5 小时前
面试官:如何做好架构设计
后端·架构