第1663篇:知识图谱与RAG结合——Graph RAG的架构设计与实现
第1663篇:知识图谱与RAG结合——Graph RAG的架构设计与实现
去年某个项目里有个场景让我印象很深:用户问"我们公司里谁负责过A产品,而且同时对B技术有经验?"
这是个典型的多实体关联查询,传统RAG完全抓瞎——向量检索擅长找语义相似的文本段,但它处理不了"A和B同时成立"这种关系型问题。你搜A产品找到一堆文档,搜B技术又找到一堆文档,但两者的交集需要在关系层面做推理,纯向量检索根本做不到。
这就是Graph RAG(知识图谱增强的RAG)要解决的核心问题。
一、为什么普通RAG在关系型问题上表现差?
先把问题说清楚。
传统RAG的检索粒度是"文本块"(Chunk)。每个Chunk是一段相对独立的文本,通过向量相似度找到最相关的几块,塞给LLM。
这个设计的根本假设是:一个问题所需的信息,大概率集中在少数几个相邻的文本段里。对于"什么是变压器架构"、"Spring Boot如何配置Redis"这类问题,这个假设成立。
但对于这些场景,假设就不成立了:
关系查询:"X和Y之间是什么关系?"——需要跨越多个文档的实体关系信息
多跳推理:"A的主管的主管是谁?"——需要两步以上的图遍历
汇聚查询:"所有参与过项目X的人都有哪些技能?"——需要收集分散在各处的信息并聚合
比较查询:"产品A和产品B有什么区别?"——需要同时检索两个实体的属性,再做对比
知识图谱天然适合这些场景,因为它把信息组织成"实体-关系-实体"的三元组形式,关系查询可以直接在图上遍历,效率高、准确率高。
二、Graph RAG的两种主流架构
根据知识图谱在系统中的角色,Graph RAG有两种主流架构:
架构一:图谱作为检索增强
知识图谱作为辅助检索手段,与向量检索并行,最终融合结果。
架构二:图谱作为知识结构化层
先把所有文档构建成知识图谱,检索时通过图谱索引,不直接用原始文本向量。这是微软GraphRAG论文里的方案。
在实际工程中,我们用的更多是架构一,因为它可以在现有RAG基础上渐进式引入图谱,改造成本低;架构二需要重建整个索引管道,适合新建系统。
三、从零搭一个Graph RAG系统
下面我一步步讲实现。技术栈:Java + Spring AI + Neo4j + Elasticsearch。
3.1 知识图谱的Schema设计
图谱设计是整个系统的基础,Schema不合理后面很难改。
我们项目是企业内部知识库,图谱Schema如下:
// Neo4j Cypher:创建约束和索引
CREATE CONSTRAINT person_id ON (p:Person) ASSERT p.id IS UNIQUE;
CREATE CONSTRAINT document_id ON (d:Document) ASSERT d.id IS UNIQUE;
CREATE CONSTRAINT concept_id ON (c:Concept) ASSERT c.id IS UNIQUE;
CREATE CONSTRAINT product_id ON (p:Product) ASSERT p.id IS UNIQUE;
// 创建全文搜索索引
CREATE FULLTEXT INDEX entityNameIndex FOR (n:Person|Concept|Product) ON EACH [n.name];
// 典型的图谱结构:
// (Person)-[:WORKS_ON]->(Product)
// (Person)-[:HAS_SKILL]->(Concept)
// (Person)-[:MANAGES]->(Person)
// (Document)-[:MENTIONS]->(Person)
// (Document)-[:DISCUSSES]->(Concept)
// (Product)-[:DEPENDS_ON]->(Product)对应的Java实体:
// 使用Spring Data Neo4j
@Node("Person")
public class PersonNode {
@Id
private String id;
@Property("name")
private String name;
@Property("department")
private String department;
@Property("title")
private String title;
// 工作过的产品
@Relationship(type = "WORKS_ON", direction = Relationship.Direction.OUTGOING)
private List<WorksOnRelationship> worksOn;
// 技能
@Relationship(type = "HAS_SKILL", direction = Relationship.Direction.OUTGOING)
private List<ConceptNode> skills;
// 管理关系
@Relationship(type = "MANAGES", direction = Relationship.Direction.OUTGOING)
private List<PersonNode> subordinates;
}
@RelationshipProperties
public class WorksOnRelationship {
@RelationshipId
private Long id;
@TargetNode
private ProductNode product;
@Property("role")
private String role; // 产品经理/研发/测试
@Property("startDate")
private LocalDate startDate;
@Property("endDate")
private LocalDate endDate;
}3.2 知识抽取管道
从文档中自动抽取实体和关系,这是Graph RAG的核心难点之一。
@Service
public class KnowledgeExtractionService {
@Autowired
private LLMClient llmClient;
@Autowired
private Neo4jTemplate neo4jTemplate;
private static final String EXTRACTION_PROMPT = """
从以下文本中抽取实体和关系,以JSON格式输出。
抽取规则:
1. 实体类型:Person(人名)、Product(产品名)、Concept(技术概念)、Organization(组织)
2. 关系类型:WORKS_ON(参与产品)、HAS_SKILL(具备技能)、MANAGES(管理)、DISCUSSES(讨论)、DEPENDS_ON(依赖)
3. 只抽取文本中明确提到的信息,不要推断
输出格式:
{
"entities": [
{"id": "唯一标识", "type": "实体类型", "name": "实体名称", "properties": {}}
],
"relations": [
{"from": "实体id", "to": "实体id", "type": "关系类型", "properties": {}}
]
}
文本内容:
%s
""";
/**
* 从文档提取知识三元组
*/
public ExtractionResult extractKnowledge(Document document) {
String prompt = String.format(EXTRACTION_PROMPT, document.getContent());
String rawJson = llmClient.chat(prompt);
try {
ExtractionResult result = parseExtractionResult(rawJson);
result.setSourceDocId(document.getId());
// 置信度过滤:低置信度的三元组不入库
result.filterByConfidence(0.7);
return result;
} catch (Exception e) {
log.error("知识抽取解析失败,docId: {}", document.getId(), e);
return ExtractionResult.empty();
}
}
/**
* 批量抽取并写入图谱
*/
@Transactional
public void extractAndPersist(List<Document> documents) {
for (Document doc : documents) {
ExtractionResult result = extractKnowledge(doc);
if (result.isEmpty()) continue;
// 实体去重:如果实体已存在则合并属性
result.getEntities().forEach(entity -> {
upsertEntity(entity);
});
// 写入关系
result.getRelations().forEach(relation -> {
createRelationship(relation);
});
// 关联原始文档
linkDocumentToEntities(doc.getId(), result.getEntities());
log.info("文档 {} 抽取完成,实体: {},关系: {}",
doc.getId(),
result.getEntities().size(),
result.getRelations().size());
}
}
private void upsertEntity(ExtractedEntity entity) {
String cypher = """
MERGE (n:%s {name: $name})
ON CREATE SET n.id = $id, n.createdAt = datetime()
ON MATCH SET n.updatedAt = datetime()
SET n += $properties
""".formatted(entity.getType());
neo4jTemplate.execute(cypher, Map.of(
"id", entity.getId(),
"name", entity.getName(),
"properties", entity.getProperties()
));
}
private void createRelationship(ExtractedRelation relation) {
String cypher = """
MATCH (from {id: $fromId})
MATCH (to {id: $toId})
MERGE (from)-[r:%s]->(to)
SET r += $properties
""".formatted(relation.getType());
neo4jTemplate.execute(cypher, Map.of(
"fromId", relation.getFromId(),
"toId", relation.getToId(),
"properties", relation.getProperties()
));
}
}3.3 查询理解与图谱检索
用户的自然语言查询需要转换成图谱遍历查询(Cypher)。这一步用LLM来做。
@Service
public class GraphQueryService {
@Autowired
private LLMClient llmClient;
@Autowired
private Neo4jClient neo4jClient;
private static final String CYPHER_GEN_PROMPT = """
你是一个Neo4j Cypher查询专家。根据用户问题生成Cypher查询语句。
图谱Schema:
节点类型:Person(name, department, title)、Product(name, description)、Concept(name, category)
关系类型:
- (Person)-[:WORKS_ON {role, startDate, endDate}]->(Product)
- (Person)-[:HAS_SKILL]->(Concept)
- (Person)-[:MANAGES]->(Person)
- (Document)-[:DISCUSSES]->(Concept)
规则:
1. 只生成SELECT类查询(MATCH...RETURN),不生成写操作
2. 使用参数化查询,参数用$前缀
3. 限制返回数量,最多LIMIT 20
4. 输出格式:{"cypher": "...", "parameters": {}}
用户问题:%s
""";
/**
* 将自然语言转换为Cypher并执行
*/
public GraphQueryResult queryByNaturalLanguage(String question) {
// 1. 生成Cypher
String prompt = String.format(CYPHER_GEN_PROMPT, question);
String rawResponse = llmClient.chat(prompt);
CypherQuery cypherQuery = parseCypherQuery(rawResponse);
if (cypherQuery == null || cypherQuery.getCypher().isBlank()) {
return GraphQueryResult.empty();
}
log.debug("生成Cypher:{}", cypherQuery.getCypher());
// 2. 执行查询(带超时保护)
try {
List<Map<String, Object>> rawResults = neo4jClient
.query(cypherQuery.getCypher())
.bindAll(cypherQuery.getParameters())
.fetch()
.all()
.stream()
.collect(Collectors.toList());
// 3. 将图谱结果转换为自然语言描述
String naturalDescription = convertToNaturalLanguage(rawResults, question);
return GraphQueryResult.builder()
.cypher(cypherQuery.getCypher())
.rawResults(rawResults)
.naturalDescription(naturalDescription)
.entityCount(rawResults.size())
.build();
} catch (Exception e) {
log.error("Cypher执行失败:{}", cypherQuery.getCypher(), e);
return GraphQueryResult.empty();
}
}
/**
* 直接的图遍历查询(用于特定模式)
*/
public List<PersonNode> findPersonsBySkillAndProduct(String skill, String product) {
String cypher = """
MATCH (p:Person)-[:HAS_SKILL]->(c:Concept {name: $skill})
MATCH (p)-[:WORKS_ON]->(pr:Product {name: $product})
RETURN p
LIMIT 20
""";
return neo4jClient.query(cypher)
.bind(skill).to("skill")
.bind(product).to("product")
.fetchAs(PersonNode.class)
.all();
}
/**
* 将图查询结果转换为可读文本
*/
private String convertToNaturalLanguage(List<Map<String, Object>> results,
String question) {
if (results.isEmpty()) return "知识图谱中未找到相关信息。";
StringBuilder sb = new StringBuilder();
sb.append("从知识图谱中找到以下相关信息:\n");
for (Map<String, Object> row : results) {
sb.append("- ");
row.forEach((key, value) -> {
sb.append(key).append(": ").append(value).append(" ");
});
sb.append("\n");
}
return sb.toString();
}
}3.4 混合检索融合
把图谱检索结果和向量检索结果融合起来,一起送给LLM:
@Service
public class HybridGraphRAGService {
@Autowired
private GraphQueryService graphQueryService;
@Autowired
private VectorSearchService vectorSearchService;
@Autowired
private LLMClient llmClient;
/**
* Graph RAG主流程
*/
public RAGResponse query(String question) {
// 1. 查询类型分析
QueryType queryType = analyzeQueryType(question);
String graphContext = "";
List<Document> vectorDocs = Collections.emptyList();
// 2. 根据查询类型选择检索策略
switch (queryType) {
case RELATIONSHIP_QUERY:
// 关系型问题:主要走图谱,辅助向量
GraphQueryResult graphResult = graphQueryService.queryByNaturalLanguage(question);
graphContext = graphResult.getNaturalDescription();
vectorDocs = vectorSearchService.search(question, 3);
break;
case FACTUAL_QUERY:
// 事实型问题:主要走向量,辅助图谱做实体增强
vectorDocs = vectorSearchService.search(question, 5);
graphContext = enrichWithEntityContext(question, vectorDocs);
break;
case AGGREGATION_QUERY:
// 汇聚型问题:主要走图谱
graphContext = graphQueryService.queryByNaturalLanguage(question)
.getNaturalDescription();
break;
default:
vectorDocs = vectorSearchService.search(question, 5);
}
// 3. 构建上下文,生成答案
String finalAnswer = generateWithHybridContext(question, graphContext, vectorDocs);
return RAGResponse.builder()
.answer(finalAnswer)
.queryType(queryType)
.graphContextUsed(!graphContext.isEmpty())
.vectorDocsUsed(!vectorDocs.isEmpty())
.build();
}
/**
* 实体上下文增强:从向量结果里提取实体,在图谱里查询相关信息
*/
private String enrichWithEntityContext(String question, List<Document> docs) {
// 提取文档中的实体名
List<String> entities = extractMentionedEntities(docs);
if (entities.isEmpty()) return "";
StringBuilder graphContext = new StringBuilder();
for (String entityName : entities) {
// 查询实体的邻居关系
String neighborCypher = """
MATCH (n {name: $name})-[r]-(m)
RETURN type(r) as relType, labels(m)[0] as nodeType, m.name as neighborName
LIMIT 10
""";
List<Map<String, Object>> neighbors = neo4jClient.query(neighborCypher)
.bind(entityName).to("name")
.fetch().all()
.stream().collect(Collectors.toList());
if (!neighbors.isEmpty()) {
graphContext.append("关于【").append(entityName).append("】的关联信息:\n");
neighbors.forEach(n -> {
graphContext.append(String.format(" - 与%s的关系:%s(%s)\n",
n.get("neighborName"), n.get("relType"), n.get("nodeType")));
});
}
}
return graphContext.toString();
}
private String generateWithHybridContext(String question,
String graphContext,
List<Document> vectorDocs) {
StringBuilder contextBuilder = new StringBuilder();
if (!graphContext.isEmpty()) {
contextBuilder.append("【知识图谱信息】\n").append(graphContext).append("\n\n");
}
if (!vectorDocs.isEmpty()) {
contextBuilder.append("【相关文档】\n");
for (int i = 0; i < vectorDocs.size(); i++) {
contextBuilder.append(String.format("[文档%d] %s\n",
i + 1, vectorDocs.get(i).getContent()));
}
}
String systemPrompt = """
你是一个知识助手。请基于以下参考信息(包含知识图谱数据和相关文档)回答用户问题。
优先使用知识图谱中的结构化信息,文档作为补充。
如果无法从参考信息中找到答案,请明确说明。
""";
String userMessage = contextBuilder + "\n\n用户问题:" + question;
return llmClient.chat(systemPrompt, userMessage);
}
/**
* 简单的查询类型分析
*/
private QueryType analyzeQueryType(String question) {
// 关系查询的特征词
List<String> relationKeywords = Arrays.asList(
"什么关系", "如何关联", "之间", "谁负责", "谁参与", "谁管理"
);
// 汇聚查询的特征词
List<String> aggregationKeywords = Arrays.asList(
"所有", "所有人", "哪些人", "有多少", "列出所有", "统计"
);
for (String kw : relationKeywords) {
if (question.contains(kw)) return QueryType.RELATIONSHIP_QUERY;
}
for (String kw : aggregationKeywords) {
if (question.contains(kw)) return QueryType.AGGREGATION_QUERY;
}
return QueryType.FACTUAL_QUERY;
}
private List<String> extractMentionedEntities(List<Document> docs) {
// 简化实现:从文档元数据里取实体
return docs.stream()
.flatMap(doc -> {
List<String> entities = (List<String>) doc.getMetadata().get("entities");
return entities != null ? entities.stream() : Stream.empty();
})
.distinct()
.limit(5)
.collect(Collectors.toList());
}
}四、图谱构建的工程挑战
4.1 实体歧义消解
"张三"在不同文档里可能是不同的人,需要做实体链接(Entity Linking)。
@Service
public class EntityResolutionService {
@Autowired
private Neo4jClient neo4jClient;
@Autowired
private EmbeddingService embeddingService;
/**
* 实体歧义消解:判断新提取的实体是否与已有实体是同一个
*/
public Optional<String> resolveEntity(ExtractedEntity newEntity) {
// 策略1:精确名称匹配
Optional<String> exactMatch = findExactMatch(newEntity);
if (exactMatch.isPresent()) return exactMatch;
// 策略2:别名匹配(如"张三"和"张三丰"不是一个人,但"张三"和"小张"可能是)
// 这里简化处理,实际中需要更复杂的规则
// 策略3:语义相似度(同类型实体,名称相似度高)
return findSimilarEntity(newEntity);
}
private Optional<String> findExactMatch(ExtractedEntity entity) {
String cypher = """
MATCH (n:%s {name: $name})
RETURN n.id as id
LIMIT 1
""".formatted(entity.getType());
return neo4jClient.query(cypher)
.bind(entity.getName()).to("name")
.fetchAs(String.class)
.one();
}
private Optional<String> findSimilarEntity(ExtractedEntity entity) {
// 找同类型的候选实体
String cypher = """
MATCH (n:%s)
RETURN n.id as id, n.name as name
""".formatted(entity.getType());
List<Map<String, Object>> candidates = neo4jClient.query(cypher)
.fetch().all().stream().collect(Collectors.toList());
// 计算名称相似度
Float[] newEmbedding = embeddingService.embed(entity.getName());
return candidates.stream()
.filter(c -> {
Float[] candidateEmbedding = embeddingService.embed((String) c.get("name"));
double similarity = cosineSimilarity(newEmbedding, candidateEmbedding);
return similarity > 0.95; // 高相似度阈值
})
.findFirst()
.map(c -> (String) c.get("id"));
}
private double cosineSimilarity(Float[] a, Float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
}4.2 图谱增量更新
知识图谱不是一次性构建的,文档更新时需要增量更新图谱。
@Service
public class GraphIncrementalUpdateService {
@Autowired
private KnowledgeExtractionService extractionService;
@Autowired
private Neo4jTemplate neo4jTemplate;
/**
* 文档更新时,同步更新图谱
* 策略:删除旧关系,保留节点,添加新关系
*/
@Transactional
public void updateGraphForDocument(String docId, Document newDocument) {
// 1. 删除该文档之前抽取的关系(软删除标记为过时)
String markObsoleteCypher = """
MATCH (d:Document {id: $docId})-[r:EXTRACTED_FROM]-(n)
SET r.obsolete = true, r.updatedAt = datetime()
""";
neo4jTemplate.execute(markObsoleteCypher, Map.of("docId", docId));
// 2. 重新抽取知识
ExtractionResult newResult = extractionService.extractKnowledge(newDocument);
// 3. 写入新知识
extractionService.extractAndPersist(List.of(newDocument));
// 4. 清理孤立节点(没有任何非过时关系的节点)
cleanOrphanNodes();
log.info("文档 {} 图谱更新完成,新增实体: {},新增关系: {}",
docId, newResult.getEntities().size(), newResult.getRelations().size());
}
private void cleanOrphanNodes() {
// 清理没有关系的叶节点(保留核心实体)
String cleanupCypher = """
MATCH (n)
WHERE NOT (n)--() AND NOT n:Document
DELETE n
""";
neo4jTemplate.execute(cleanupCypher, Collections.emptyMap());
}
}五、性能优化:图谱查询的坑
5.1 避免全图扫描
LLM生成的Cypher有时候会写出没有索引的查询,导致全图扫描,在大图上极慢。
@Component
public class CypherSafetyValidator {
// 危险查询模式(没有索引支撑的查询)
private static final List<Pattern> DANGEROUS_PATTERNS = Arrays.asList(
Pattern.compile("MATCH \\(n\\)\\s+WHERE", Pattern.CASE_INSENSITIVE),
Pattern.compile("MATCH \\([a-z]\\)--", Pattern.CASE_INSENSITIVE)
);
/**
* 验证Cypher是否安全(有索引支撑)
*/
public ValidationResult validate(String cypher) {
List<String> warnings = new ArrayList<>();
for (Pattern pattern : DANGEROUS_PATTERNS) {
if (pattern.matcher(cypher).find()) {
warnings.add("检测到可能的全图扫描:" + cypher);
}
}
// 检查是否有LIMIT
if (!cypher.toUpperCase().contains("LIMIT")) {
warnings.add("缺少LIMIT子句,可能返回大量数据");
}
return ValidationResult.builder()
.safe(warnings.isEmpty())
.warnings(warnings)
.build();
}
}5.2 图谱结果缓存
频繁查询的图谱路径缓存起来:
@Service
public class GraphQueryCache {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
public Optional<GraphQueryResult> getFromCache(String questionHash) {
String key = "graph:query:" + questionHash;
Object cached = redisTemplate.opsForValue().get(key);
return Optional.ofNullable((GraphQueryResult) cached);
}
public void putToCache(String questionHash, GraphQueryResult result) {
String key = "graph:query:" + questionHash;
redisTemplate.opsForValue().set(key, result, CACHE_TTL);
}
}六、实际效果与适用场景
在我们的企业知识库项目中,引入Graph RAG之后:
- 关系型问题的准确率从 42% 提升到 81%
- 多跳推理问题准确率从 28% 提升到 74%
- 普通事实查询准确率基本持平(±2%)
但也有明显的代价:
- 系统复杂度大幅上升(多了Neo4j运维、知识抽取管道)
- 图谱构建时间较长(10万文档大约需要2-3天的批量抽取)
- LLM生成Cypher的稳定性不够好,需要重试机制和安全校验
我的建议:如果你的场景里关系型查询占比低于20%,暂时不值得引入Graph RAG,向量RAG优化得好就够了。关系型查询超过30%,Graph RAG的收益就很明显了。
下一篇讲自适应RAG,根据查询复杂度动态决定用什么策略,是一个非常实用的工程优化思路。
