第2369篇:GraphRAG的工程实现——用知识图谱增强RAG的检索准确率
第2369篇:GraphRAG的工程实现——用知识图谱增强RAG的检索准确率
适读人群:有RAG基础、想突破检索准确率瓶颈的AI工程师 | 阅读时长:约22分钟 | 核心价值:掌握知识图谱与向量检索的融合工程方案,解决实体关系问题的检索失准难题
上个季度我们给一家保险公司做内部知识库,需求听起来很简单:员工输入问题,系统返回相关条款和解读。
前两周效果还行,但有一类问题让产品经理一直皱眉:
"张三是A公司的法定代表人,A公司参股了B公司,B公司有一起正在审理的保险纠纷案件,请问我们应该对张三做什么风险评级?"
传统RAG检索到的是一堆散碎的片段——关于A公司的一段、关于法定代表人定义的一段、关于风险评级的通用规定。但这些片段之间的关联关系全丢了。LLM拿着这些上下文,根本无法回答一个需要多跳推理的问题。
这就是普通向量RAG的硬伤——它检索的是语义相似的片段,但无法理解实体之间的关联。GraphRAG就是为了解决这个问题而生的。
什么是GraphRAG,它解决什么问题
先说清楚GraphRAG不是什么:它不是一个特定的框架,而是一种将知识图谱引入RAG检索流程的架构思路。
普通RAG的检索路径:
用户问题 → 向量化 → 向量相似度搜索 → 返回Top-K文本片段 → LLM生成GraphRAG的检索路径:
用户问题 → 实体识别 → 图谱查询(实体+关系) → 子图提取 → 结合向量检索 → LLM生成核心差异在于:图谱查询返回的不是"相似的文本",而是"与问题相关的实体网络"。
/**
* 用一个简单的例子理解区别
*
* 文档:
* doc1: "张三是A公司的CEO,A公司成立于2018年"
* doc2: "A公司是B公司的控股股东,持股比例60%"
* doc3: "B公司2023年发生一起保险理赔纠纷"
*
* 问题:"张三与B公司的保险纠纷有什么关联?"
*
* 普通向量检索结果:
* - 检索"张三"相关 → doc1(相似度高)
* - 检索"B公司纠纷"相关 → doc3(相似度高)
* - doc2可能排不进来(和问题语义距离远)
* → 结果:缺少A公司是桥接的关键信息
*
* GraphRAG结果:
* - 识别实体:张三、B公司
* - 图谱路径查询:张三 → [是CEO] → A公司 → [控股] → B公司
* - 返回完整路径上的所有实体和关系
* → 结果:完整的关联链路都在上下文里
*/知识图谱的构建:从文档到图谱
这是GraphRAG最重的工程工作。需要从文档中自动抽取实体和关系。
实体关系抽取
@Service
public class KnowledgeGraphBuilder {
private final ChatClient chatClient;
private final Neo4jDriver neo4jDriver;
/**
* 从文本中抽取实体和关系
* 使用LLM做信息抽取,比规则方法泛化性更好
*/
public ExtractionResult extractEntitiesAndRelations(String text, String docId) {
String prompt = """
请从以下文本中抽取实体和关系,输出JSON格式:
文本:%s
要求:
1. 实体类型包括:Person(人物)、Company(公司)、Contract(合同)、
Insurance(保险产品)、Regulation(法规)
2. 关系类型包括:IS_CEO_OF、IS_SHAREHOLDER_OF、SIGNED_CONTRACT、
SUBJECT_TO_REGULATION、HAS_DISPUTE
3. 每个实体需要有:id(英文唯一标识)、type、name、properties(附加属性)
4. 每条关系需要有:from_id、to_id、type、properties
输出格式:
{
"entities": [...],
"relations": [...]
}
只输出JSON,不要其他内容。
""".formatted(text);
String response = chatClient.prompt(prompt).call().content();
try {
return parseExtractionResult(response, docId);
} catch (Exception e) {
log.warn("Entity extraction failed for doc {}: {}", docId, e.getMessage());
return ExtractionResult.empty(docId);
}
}
/**
* 将抽取结果写入Neo4j
*/
public void persistToGraph(ExtractionResult result) {
try (Session session = neo4jDriver.session()) {
// 批量写入实体
for (Entity entity : result.getEntities()) {
session.run("""
MERGE (n:%s {id: $id})
SET n.name = $name,
n.docId = $docId,
n += $properties
""".formatted(entity.getType()),
Map.of(
"id", entity.getId(),
"name", entity.getName(),
"docId", result.getDocId(),
"properties", entity.getProperties()
)
);
}
// 批量写入关系
for (Relation relation : result.getRelations()) {
session.run("""
MATCH (from {id: $fromId}), (to {id: $toId})
MERGE (from)-[r:%s]->(to)
SET r += $properties
""".formatted(relation.getType()),
Map.of(
"fromId", relation.getFromId(),
"toId", relation.getToId(),
"properties", relation.getProperties()
)
);
}
}
}
}实体消歧:同一实体的不同表述
这是最容易忽略的问题。文档里"中国平安"、"平安保险"、"平安集团"可能都是同一个实体。
@Service
public class EntityDisambiguationService {
private final EmbeddingModel embeddingModel;
private final Neo4jDriver neo4jDriver;
/**
* 实体消歧:检查新实体是否是已有实体的别名
*
* 思路:
* 1. 先用名称做精确匹配
* 2. 再用向量相似度做模糊匹配
* 3. 相似度超过阈值,认为是同一实体
*/
public String resolveEntityId(String entityName, String entityType) {
// 第一步:精确匹配
String exactMatch = findExactMatch(entityName, entityType);
if (exactMatch != null) {
return exactMatch;
}
// 第二步:向量相似度匹配
float[] nameEmbedding = embeddingModel.embed(entityName);
List<EntityCandidate> candidates = findCandidatesByType(entityType);
for (EntityCandidate candidate : candidates) {
float similarity = cosineSimilarity(nameEmbedding, candidate.getNameEmbedding());
if (similarity > 0.92f) {
// 高置信度认为是同一实体,记录别名
addAlias(candidate.getId(), entityName);
return candidate.getId();
}
}
// 找不到,生成新ID
return generateNewEntityId(entityName, entityType);
}
private String findExactMatch(String name, String type) {
try (Session session = neo4jDriver.session()) {
Result result = session.run("""
MATCH (n:%s)
WHERE n.name = $name OR $name IN n.aliases
RETURN n.id as id
LIMIT 1
""".formatted(type),
Map.of("name", name)
);
return result.hasNext() ? result.next().get("id").asString() : null;
}
}
}检索层:图谱查询与向量检索的融合
构建好图谱后,关键是如何在检索时利用它。
问题解析:识别问题中的实体
@Service
public class GraphRAGRetriever {
private final ChatClient chatClient;
private final Neo4jDriver neo4jDriver;
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
/**
* GraphRAG核心检索流程
*/
public RetrievalResult retrieve(String question) {
// 第一步:从问题中识别实体
List<String> questionEntities = extractEntitiesFromQuestion(question);
// 第二步:图谱子图检索
SubGraph subGraph = null;
if (!questionEntities.isEmpty()) {
subGraph = querySubGraph(questionEntities);
}
// 第三步:向量检索(并行执行)
List<Document> vectorDocs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(5)
);
// 第四步:融合结果
return mergeResults(subGraph, vectorDocs, question);
}
/**
* 从问题中识别实体
*/
private List<String> extractEntitiesFromQuestion(String question) {
String prompt = """
从以下问题中识别出人名、公司名、合同名等具体实体,
以JSON数组格式返回实体名称列表,如:["张三", "A公司"]
如果没有具体实体,返回空数组:[]
问题:%s
""".formatted(question);
String response = chatClient.prompt(prompt).call().content();
try {
// 解析JSON数组
return objectMapper.readValue(
response.trim(),
new TypeReference<List<String>>() {}
);
} catch (Exception e) {
log.warn("Failed to extract entities from question: {}", question);
return Collections.emptyList();
}
}
/**
* 查询以识别到的实体为起点的子图
* 支持多跳关系查询
*/
private SubGraph querySubGraph(List<String> entityNames) {
try (Session session = neo4jDriver.session()) {
// 查询从这些实体出发,2跳以内的所有关系
Result result = session.run("""
MATCH (start)
WHERE start.name IN $names OR
ANY(alias IN start.aliases WHERE alias IN $names)
MATCH path = (start)-[*1..2]-(related)
RETURN path
LIMIT 50
""",
Map.of("names", entityNames)
);
return buildSubGraphFromPaths(result);
}
}
/**
* 把子图转换成自然语言描述,放入LLM上下文
*/
private String subGraphToContext(SubGraph subGraph) {
if (subGraph == null || subGraph.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("【知识图谱关联信息】\n");
for (GraphPath path : subGraph.getPaths()) {
sb.append("- ");
for (int i = 0; i < path.getNodes().size(); i++) {
sb.append(path.getNodes().get(i).getName());
if (i < path.getRelations().size()) {
sb.append(" --[").append(path.getRelations().get(i).getType()).append("]--> ");
}
}
sb.append("\n");
}
return sb.toString();
}
}上下文构建:图谱信息 + 向量文档
/**
* 融合图谱上下文和向量文档,构建最终prompt
*/
private String buildFinalPrompt(String question, SubGraph subGraph, List<Document> vectorDocs) {
StringBuilder context = new StringBuilder();
// 1. 图谱关系上下文(放最前面,权重更高)
String graphContext = subGraphToContext(subGraph);
if (!graphContext.isEmpty()) {
context.append(graphContext).append("\n\n");
}
// 2. 向量检索的文档片段
context.append("【相关文档内容】\n");
for (int i = 0; i < vectorDocs.size(); i++) {
context.append(String.format("文档%d:%s\n\n", i + 1, vectorDocs.get(i).getContent()));
}
return """
请基于以下信息回答问题。如果信息中有实体之间的关联关系,请充分利用这些关系进行推理。
%s
问题:%s
请给出详细的分析和回答:
""".formatted(context.toString(), question);
}增量更新:文档变更时的图谱维护
这是GraphRAG最头疼的工程问题。文档更新了,图谱也要跟着更新。
@Service
public class GraphUpdateService {
/**
* 文档更新时的图谱同步
*
* 策略:标记-删除-重建
* 不做细粒度diff,直接删除旧文档的所有节点和关系,重新抽取
*
* 原因:
* 1. 细粒度diff复杂度高,容易有遗漏
* 2. 实体消歧确保相同实体不会重复创建
* 3. 大多数场景下文档整体变化不频繁
*/
public void onDocumentUpdated(String docId, String newContent) {
// 第一步:删除旧文档相关的关系(保留实体节点,可能被其他文档引用)
deleteDocumentRelations(docId);
// 第二步:重新抽取并写入
ExtractionResult newResult = knowledgeGraphBuilder
.extractEntitiesAndRelations(newContent, docId);
knowledgeGraphBuilder.persistToGraph(newResult);
// 第三步:清理孤立节点(只被这个文档引用、现在没有关系的实体)
cleanOrphanNodes(docId);
log.info("Graph updated for document: {}", docId);
}
private void deleteDocumentRelations(String docId) {
try (Session session = neo4jDriver.session()) {
// 删除该文档抽取的所有关系
session.run("""
MATCH ()-[r {docId: $docId}]-()
DELETE r
""",
Map.of("docId", docId)
);
}
}
private void cleanOrphanNodes(String docId) {
try (Session session = neo4jDriver.session()) {
// 删除没有任何关系且只来自该文档的孤立节点
session.run("""
MATCH (n {docId: $docId})
WHERE NOT (n)--()
DELETE n
""",
Map.of("docId", docId)
);
}
}
}性能优化:图谱查询不能成为瓶颈
实际测试下来,Neo4j的图谱查询在数据量大时可能比向量检索还慢。几个优化点:
@Configuration
public class GraphRAGConfig {
/**
* 优化1:对高频查询实体做缓存
* 保险场景下,头部公司(平安、太保等)被查询频率极高
*/
@Bean
public SubGraphCache subGraphCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();
}
/**
* 优化2:图谱查询和向量检索并行执行
* 两者没有依赖关系,可以用CompletableFuture并行
*/
public RetrievalResult parallelRetrieve(String question, List<String> entities) {
CompletableFuture<SubGraph> graphFuture = CompletableFuture.supplyAsync(
() -> querySubGraph(entities), graphQueryExecutor
);
CompletableFuture<List<Document>> vectorFuture = CompletableFuture.supplyAsync(
() -> vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(5)
), vectorSearchExecutor
);
try {
SubGraph subGraph = graphFuture.get(3, TimeUnit.SECONDS);
List<Document> vectorDocs = vectorFuture.get(3, TimeUnit.SECONDS);
return mergeResults(subGraph, vectorDocs, question);
} catch (TimeoutException e) {
// 降级:只用向量检索结果
log.warn("Graph query timeout, falling back to vector search only");
try {
return RetrievalResult.vectorOnly(vectorFuture.get());
} catch (Exception ex) {
return RetrievalResult.empty();
}
}
}
}实际效果和适用场景
在保险知识库项目上,引入GraphRAG后:
- 多跳关联问题(如股权穿透类问题)的准确率从37%提升到81%
- 普通问题(不涉及实体关系)准确率变化不大(略有提升,因为上下文更丰富了)
- 整体延迟增加了约200-400ms(主要是图谱查询),可以通过缓存优化
适合引入GraphRAG的场景:
- 知识库里有大量实体关系(人事、股权、合同等)
- 用户问题经常涉及多跳推理
- 问题答案高度依赖实体之间的关联
不适合的场景:
- 纯文档问答(技术手册、FAQ)
- 问题类型简单,都是直接查找类
- 文档更新非常频繁(图谱维护成本高)
GraphRAG的工程复杂度是普通RAG的3-5倍,引入前要想清楚它解决的问题是否值得这个代价。
