知识图谱+LLM实战:Neo4j增强智能问答系统完整实现
知识图谱+LLM实战:Neo4j增强智能问答系统完整实现
适读人群:RAG系统效果遇到瓶颈、想引入结构化知识增强LLM推理的Java工程师 阅读时长:约20分钟 文章价值:完整实现Neo4j知识图谱+LLM的GraphRAG方案,解决纯向量检索的关系推理盲区
RAG答不对的那类问题
我有个同事小周,做医疗辅助问答系统,已经上线跑了半年,用的标准RAG架构,效果还不错,用户满意度7分(满10分)。
但他发现有一类问题一直答不好,比如:
"阿莫西林和布洛芬可以同时服用吗?"
"李某某患有2型糖尿病和高血压,哪些药物需要避免?"
"A药的禁忌症里有没有包含B疾病的患者?"
这类问题的特点是:需要理解实体之间的关系,而不只是检索相关文本。
纯向量检索的问题是它只能找"和这段话相似的片段",但"药物A的禁忌"和"疾病B的并发症"可能分散在两个完全不同的文档里,向量检索无法把这两个知识点联系起来推理。
这就是知识图谱+LLM(GraphRAG)要解决的问题。
GraphRAG的核心思路
GraphRAG = 知识图谱的结构化关系 + 向量检索的语义相似 + LLM的推理生成
三者各负责不同部分:
- 图谱负责"A和B有什么关系"这类关系问题
- 向量负责"有哪些关于A的描述"这类相似度问题
- LLM负责把上面两类信息综合成流畅的回答
知识图谱建模
以药物知识图谱为例,设计图模型:
在Neo4j中建立这个图谱:
-- 创建药物节点
CREATE (amoxicillin:Drug {
name: '阿莫西林',
genericName: 'Amoxicillin',
category: '抗生素',
halfLife: '1-1.5小时'
})
CREATE (ibuprofen:Drug {
name: '布洛芬',
genericName: 'Ibuprofen',
category: 'NSAID',
halfLife: '2小时'
})
-- 创建疾病节点
CREATE (pepticUlcer:Disease {name: '消化性溃疡'})
CREATE (renal:Disease {name: '肾功能不全'})
-- 创建关系
CREATE (ibuprofen)-[:CONTRAINDICATED_FOR {reason: '可能加重胃肠道出血'}]->(pepticUlcer)
CREATE (ibuprofen)-[:CAUTION_IN {reason: '经肾脏代谢,肾功能不全时清除率降低'}]->(renal)
-- 药物相互作用
CREATE (amoxicillin)-[:MAY_INTERACT {severity: 'low', mechanism: '竞争血浆蛋白结合'}]->(ibuprofen)Neo4j + Spring Boot集成
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
</dependencies>节点实体定义:
@Node("Drug")
@Data
@Builder
public class DrugNode {
@Id
@GeneratedValue
private Long id;
@Property("name")
private String name;
@Property("genericName")
private String genericName;
@Property("category")
private String category;
// 关系:禁忌疾病
@Relationship(type = "CONTRAINDICATED_FOR", direction = Relationship.Direction.OUTGOING)
private List<ContraindicatedRelationship> contraindicatedFor;
// 关系:药物相互作用
@Relationship(type = "MAY_INTERACT", direction = Relationship.Direction.OUTGOING)
private List<DrugInteraction> interactions;
@RelationshipProperties
@Data
public static class ContraindicatedRelationship {
@RelationshipId
private Long id;
private String reason;
@TargetNode
private DiseaseNode disease;
}
@RelationshipProperties
@Data
public static class DrugInteraction {
@RelationshipId
private Long id;
private String severity; // high/medium/low
private String mechanism;
@TargetNode
private DrugNode targetDrug;
}
}@Node("Disease")
@Data
@Builder
public class DiseaseNode {
@Id
@GeneratedValue
private Long id;
@Property("name")
private String name;
@Property("icd10Code")
private String icd10Code;
}图查询服务
@Repository
public interface DrugGraphRepository extends Neo4jRepository<DrugNode, Long> {
Optional<DrugNode> findByName(String name);
@Query("MATCH (d:Drug) WHERE d.name CONTAINS $keyword RETURN d LIMIT 10")
List<DrugNode> searchByKeyword(@Param("keyword") String keyword);
}@Service
@Slf4j
public class KnowledgeGraphService {
private final Neo4jClient neo4jClient;
private final DrugGraphRepository drugRepository;
/**
* 查询药物相互作用
*/
public List<DrugInteractionResult> queryDrugInteractions(List<String> drugNames) {
String cypher = """
MATCH (d1:Drug)-[r:MAY_INTERACT]->(d2:Drug)
WHERE d1.name IN $drugNames AND d2.name IN $drugNames
RETURN d1.name AS drug1, d2.name AS drug2,
r.severity AS severity, r.mechanism AS mechanism
""";
return neo4jClient.query(cypher)
.bindAll(Map.of("drugNames", drugNames))
.fetchAs(DrugInteractionResult.class)
.mappedBy((typeSystem, record) -> DrugInteractionResult.builder()
.drug1(record.get("drug1").asString())
.drug2(record.get("drug2").asString())
.severity(record.get("severity").asString())
.mechanism(record.get("mechanism").asString())
.build())
.all()
.stream()
.collect(Collectors.toList());
}
/**
* 查询某患者的药物禁忌(基于患者疾病)
*/
public List<DrugContraindicationResult> queryPatientContraindications(
String patientId, List<String> intendedDrugs) {
String cypher = """
MATCH (p:Patient {id: $patientId})-[:HAS_CONDITION]->(disease:Disease)
MATCH (drug:Drug)-[:CONTRAINDICATED_FOR {reason: reason}]->(disease)
WHERE drug.name IN $intendedDrugs
RETURN drug.name AS drug, disease.name AS disease, reason
""";
return neo4jClient.query(cypher)
.bindAll(Map.of(
"patientId", patientId,
"intendedDrugs", intendedDrugs
))
.fetchAs(DrugContraindicationResult.class)
.mappedBy((typeSystem, record) -> DrugContraindicationResult.builder()
.drug(record.get("drug").asString())
.contraindicatedDisease(record.get("disease").asString())
.reason(record.get("reason").asString())
.build())
.all()
.stream()
.collect(Collectors.toList());
}
/**
* 查询知识路径:从症状推到可能的疾病和用药方向
*/
public String queryKnowledgePath(String symptom) {
String cypher = """
MATCH path = (s:Symptom {name: $symptom})<-[:HAS_SYMPTOM]-(d:Disease)<-[:TREATS]-(drug:Drug)
RETURN d.name AS disease,
collect(drug.name) AS drugs,
length(path) AS pathLength
ORDER BY pathLength
LIMIT 5
""";
Collection<Map<String, Object>> results = neo4jClient.query(cypher)
.bind(symptom).to("symptom")
.fetch()
.all();
return formatKnowledgePath(results);
}
private String formatKnowledgePath(Collection<Map<String, Object>> results) {
if (results.isEmpty()) return "未找到相关知识路径";
StringBuilder sb = new StringBuilder();
for (Map<String, Object> row : results) {
sb.append(String.format("疾病:%s → 可用药物:%s\n",
row.get("disease"),
row.get("drugs")
));
}
return sb.toString();
}
}GraphRAG核心:自动Cypher生成
让LLM把自然语言问题转成Cypher查询,是GraphRAG的关键步骤:
@Service
@Slf4j
public class Text2CypherService {
private final ChatClient chatClient;
private final Neo4jClient neo4jClient;
private static final String SCHEMA_DESCRIPTION = """
图数据库Schema:
节点类型:
- Drug: 药物 {name, genericName, category, halfLife}
- Disease: 疾病 {name, icd10Code}
- Symptom: 症状 {name}
- Patient: 患者 {id, name, age}
关系类型:
- (Drug)-[:TREATS]->(Disease): 药物治疗疾病
- (Drug)-[:CONTRAINDICATED_FOR {reason}]->(Disease): 药物禁忌
- (Drug)-[:MAY_INTERACT {severity, mechanism}]->(Drug): 药物相互作用
- (Drug)-[:CAUSES]->(Symptom): 药物副作用
- (Disease)-[:HAS_SYMPTOM]->(Symptom): 疾病症状
- (Patient)-[:HAS_CONDITION]->(Disease): 患者疾病
- (Patient)-[:TAKES]->(Drug): 患者用药
""";
/**
* 自然语言 → Cypher查询 → 执行 → 返回结果
*/
public String queryByNaturalLanguage(String question) {
// 第一步:生成Cypher
String cypher = generateCypher(question);
log.info("生成Cypher: {}", cypher);
// 第二步:执行查询
String queryResult;
try {
Collection<Map<String, Object>> results = neo4jClient.query(cypher)
.fetch()
.all();
queryResult = formatResults(results);
} catch (Exception e) {
log.warn("Cypher执行失败: cypher={}, error={}", cypher, e.getMessage());
queryResult = "图查询执行失败: " + e.getMessage();
}
// 第三步:LLM生成最终回答
return chatClient.prompt()
.user(String.format("""
用户问题:%s
从知识图谱查询到的结构化信息:
%s
请基于以上信息给出清晰、专业的回答。
如果查询结果为空,请说明可能的原因。
""", question, queryResult))
.call()
.content();
}
private String generateCypher(String question) {
return chatClient.prompt()
.system("""
你是一个Neo4j Cypher查询专家。
根据用户问题生成准确的Cypher查询语句。
只返回Cypher语句,不要任何解释。
注意:
1. 使用OPTIONAL MATCH避免因缺失关系导致无结果
2. 使用LIMIT限制结果数量
3. 节点属性名称区分大小写
""")
.user(String.format("""
Schema信息:
%s
用户问题:%s
生成Cypher查询:
""", SCHEMA_DESCRIPTION, question))
.call()
.content()
.replaceAll("```cypher", "")
.replaceAll("```", "")
.trim();
}
private String formatResults(Collection<Map<String, Object>> results) {
if (results.isEmpty()) return "查询结果为空";
return results.stream()
.map(row -> row.entrySet().stream()
.map(e -> e.getKey() + ": " + e.getValue())
.collect(Collectors.joining(", ")))
.collect(Collectors.joining("\n"));
}
}完整GraphRAG集成
把图查询和向量检索结合起来:
@Service
@Slf4j
public class GraphRagService {
private final ChatClient chatClient;
private final KnowledgeGraphService graphService;
private final VectorStore vectorStore;
private final Text2CypherService text2CypherService;
/**
* GraphRAG完整流程
*/
public String answer(String question) {
log.info("GraphRAG处理问题: {}", question);
// 阶段1:实体识别
List<String> entities = extractEntities(question);
log.info("识别实体: {}", entities);
// 阶段2:图查询(获取关系知识)
String graphContext = "";
if (!entities.isEmpty()) {
graphContext = text2CypherService.queryByNaturalLanguage(question);
}
// 阶段3:向量检索(获取描述性知识)
List<Document> vectorDocs = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(3)
.withSimilarityThreshold(0.7)
);
String vectorContext = vectorDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
// 阶段4:综合推理
return chatClient.prompt()
.system("""
你是一个专业的医疗知识问答助手。
请综合结构化知识(图谱关系)和文档知识(向量检索)回答问题。
优先以图谱关系信息为准,因为它更精确。
回答时注意:不要做医疗决策建议,建议用户咨询专业医生。
""")
.user(String.format("""
用户问题:%s
【结构化关系知识(知识图谱)】
%s
【文档描述性知识(向量检索)】
%s
请综合以上信息给出专业、准确的回答:
""", question,
graphContext.isEmpty() ? "(未查询到相关关系信息)" : graphContext,
vectorContext.isEmpty() ? "(未检索到相关文档)" : vectorContext))
.call()
.content();
}
/**
* 实体识别:从问题中提取药物名、疾病名等关键实体
*/
private List<String> extractEntities(String question) {
String entityJson = chatClient.prompt()
.user(String.format("""
从以下医疗问题中提取所有药物名称和疾病名称,以JSON数组格式返回。
只返回JSON,不要其他内容。示例:["阿莫西林", "布洛芬", "消化性溃疡"]
问题:%s
""", question))
.call()
.content();
try {
return objectMapper.readValue(entityJson.trim(), List.class);
} catch (Exception e) {
log.warn("实体识别结果解析失败: {}", entityJson);
return List.of();
}
}
}效果对比测试
问题:"患有消化性溃疡的患者,能同时服用布洛芬和阿莫西林吗?"
纯RAG回答(依赖向量检索找到的片段):
"布洛芬和阿莫西林是两种不同类型的药物,通常可以配合使用。但如有任何不适请咨询医生。"
(回答不够准确,没有考虑患者的消化性溃疡病史)
GraphRAG回答(图谱关系 + 向量检索):
"对于消化性溃疡患者,不建议使用布洛芬。布洛芬属于非甾体抗炎药(NSAID),在知识库中标注了对消化性溃疡患者的禁忌——原因是它会抑制前列腺素合成,损伤胃黏膜保护层,可能加重溃疡或导致消化道出血。
阿莫西林对消化性溃疡患者通常是安全的,且在治疗幽门螺杆菌相关溃疡时常被使用。
建议您告知医生消化性溃疡病史,由医生决定是否需要替换为其他止痛药物。"
差异很明显:图谱帮助系统"知道"了药物-疾病之间的禁忌关系,回答准确度大幅提升。
小结
GraphRAG不是替代RAG,而是增强RAG。纯向量检索擅长"找相似",知识图谱擅长"推关系"。两者组合,覆盖的问题类型更全面。
适合引入知识图谱的场景:
- 实体关系密集:药物、法律条文、企业组织架构
- 多跳推理:A影响B,B影响C,问"A对C有什么影响"
- 精确性要求高:不能接受幻觉,关系必须有明确来源
如果你的RAG系统满意度在7-8分但无法突破,很可能就是卡在了关系推理这个瓶颈上。
