GraphRAG实战:知识图谱让RAG检索精度提升50%
GraphRAG实战:知识图谱让RAG检索精度提升50%
法律AI的崩溃现场
小陈是某律所AI平台的核心开发,负责一套法律智能问答系统。项目上线三个月,律师们的投诉从没停过。
不是界面丑,也不是响应慢——是答案根本不可信。
那天下午,高级律师老周把他叫进办公室,打开电脑,输入了一个问题:
"《公司法》第166条关于利润分配的规定,在2023年修订后,对我们手头的那批股权纠纷案有什么影响?"
系统返回了一段话,洋洋洒洒三百字,看起来很专业。但老周指着屏幕说:"你看这里,它引用的是修订前的旧条文。《公司法》2023年12月29日已经颁布新版,第166条改成了第212条,内容也有实质性变化。这要是在法庭上用了,败诉都是轻的。"
小陈回去复盘,发现问题在哪:系统用的是标准RAG,把所有法律文本切成512 token的小块,塞进向量数据库。当律师问"第166条修订影响案件"时,向量检索找到了"第166条"相关的文本块,但完全没有捕获到:
- 第166条已经被废止
- 新版本对应的是第212条
- 两者之间的条文内容变化
- 该变化对"股权纠纷"这个案件类型的具体影响
这四个信息分布在四份不同的文档里,用向量相似度根本串不起来。每个文本块孤立存在,没有任何连接。
这就是普通RAG的死穴:它只能找"相似的文字",却不能理解"相关的概念"。
小陈花了两周研究GraphRAG,把知识图谱引入系统。上线后,这类关联型查询的准确率从41%提升到了89%。老周再也没叫过他去办公室"喝茶"。
这篇文章,我就来讲清楚GraphRAG是怎么做到的。
先说结论(TL;DR)
| 对比维度 | 普通RAG | GraphRAG |
|---|---|---|
| 检索方式 | 向量相似度 | 图遍历 + 向量混合 |
| 实体关系 | 不理解 | 明确建模 |
| 跨文档推理 | 基本不能 | 支持多跳推理 |
| 建设成本 | 低 | 中高(需要Neo4j + NER) |
| 查询速度 | 快(<100ms) | 较快(200-500ms) |
| 适用场景 | 通用文档问答 | 有强实体关系的领域 |
| 准确率提升 | 基准 | 关联查询+40%~60% |
什么时候用GraphRAG?
- 法律、医疗、金融等强关系领域
- 知识之间有明确依赖关系(A依据B,B影响C)
- 需要多跳推理(A的关系是B,B的属性影响C)
- 不适合:通用问答、新闻检索、简单FAQ
普通RAG为什么处理不了关联查询
在讲GraphRAG之前,我们先把普通RAG的局限说清楚。这样你才能理解知识图谱解决的是什么核心问题。
向量检索的本质
向量检索做的事情是:把文本转成一个高维空间中的点,然后找离查询点最近的那些点。
这个机制天然适合语义相似度匹配,比如:
- "如何申请病假" → 找"请假流程"相关文本 ✓
- "Java空指针怎么解决" → 找"NullPointerException处理"相关文本 ✓
但它根本没有"关系"的概念。两个文本块在向量空间里的距离,反映的是语义相似性,而不是逻辑关联性。
"第166条"和"第212条"在向量空间里可能相距很远(数字不同,内容不同),但它们之间有明确的替代关系。向量检索感知不到这一点。
文本块的孤立问题
标准的RAG流程是这样的:
原始文档 → 切块(Chunking) → 嵌入(Embedding) → 向量数据库每个chunk是一个独立的文本片段。切块的时候,上下文关系被物理分割了:
- Chunk A:描述了"公司法第166条内容"
- Chunk B:描述了"2023年公司法修订概况"
- Chunk C:描述了"新公司法第212条内容"
- Chunk D:描述了"股权纠纷适用法条"
这四个chunk在向量数据库里是四个独立的点。当用户问"166条修订后对股权纠纷的影响"时,检索可能找到A(包含166条),可能找到D(包含股权纠纷),但很难同时找到B和C,更无法理解A→B→C→D这条推理链。
缺失的三种能力
普通RAG缺失的能力:
- 实体识别:不知道"公司法"、"第166条"、"股权纠纷"是不同类型的实体
- 关系建模:不知道"第166条"和"第212条"之间是替代关系
- 多跳推理:无法沿着关系链推导出间接影响
知识图谱恰好补齐了这三点。
GraphRAG架构原理
让我们先看整体架构,再逐层分析。
整个流程分两个阶段:
离线构建阶段(Index Time):
- 文档预处理:清洗、分段
- 实体抽取:用LLM识别文本中的命名实体(人、地、法规、概念等)
- 关系抽取:识别实体之间的关系(A修订了B、B替代了C)
- 图存储:把实体和关系写入Neo4j
- 向量存储:把文本块写入向量数据库
在线查询阶段(Query Time):
- 查询解析:从用户问题中识别关键实体
- 图检索:在Neo4j中做图遍历,找到相关实体及其邻居
- 向量检索:同时做传统向量相似度搜索
- 结果融合:合并两路结果,去重排序
- 生成答案:把融合后的上下文传给LLM
环境准备与依赖配置
Neo4j安装
# Docker方式启动Neo4j(推荐开发环境)
docker run -d \
--name neo4j \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/password123 \
-e NEO4J_PLUGINS='["apoc", "graph-data-science"]' \
-v neo4j_data:/data \
neo4j:5.15.0启动后访问 http://localhost:7474,用 neo4j/password123 登录。
Maven依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.laozhang.ai</groupId>
<artifactId>graphrag-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M6</spring-ai.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI Core -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring AI Vector Store - Qdrant -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qdrant-store-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring Data Neo4j -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
<!-- Spring Data JPA (for metadata storage) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Jackson JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Micrometer for metrics -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Spring Boot Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Apache Commons Lang -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Resilience4j for circuit breaker -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
</project>application.yml配置
spring:
application:
name: graphrag-demo
# Neo4j配置
neo4j:
uri: bolt://localhost:7687
authentication:
username: neo4j
password: password123
pool:
max-connection-pool-size: 50
connection-acquisition-timeout: 60s
# AI配置
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: ${OPENAI_BASE_URL:https://api.openai.com}
chat:
options:
model: gpt-4o
temperature: 0.1
max-tokens: 4096
embedding:
options:
model: text-embedding-3-small
# Qdrant向量数据库
vectorstore:
qdrant:
host: localhost
port: 6334
collection-name: legal-documents
initialize-schema: true
# PostgreSQL
datasource:
url: jdbc:postgresql://localhost:5432/graphrag
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
show-sql: false
# 自定义配置
graphrag:
ner:
# NER批处理大小
batch-size: 10
# 实体类型
entity-types:
- LAW
- REGULATION
- ARTICLE
- CASE
- CONCEPT
- ORGANIZATION
- PERSON
graph:
# 图遍历最大深度
max-hop: 3
# 图检索返回的最大节点数
max-nodes: 50
hybrid:
# 图检索结果权重
graph-weight: 0.4
# 向量检索结果权重
vector-weight: 0.6
# 最终返回的top-k
top-k: 10
# Actuator
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
logging:
level:
com.laozhang.ai: DEBUG
org.springframework.ai: INFO
org.neo4j: WARN核心数据模型设计
Neo4j节点和关系定义
package com.laozhang.ai.graphrag.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.springframework.data.neo4j.core.schema.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
/**
* 知识图谱节点 - 代表一个实体
* 实体可以是:法律、法规、条款、案例、概念等
*/
@Node("KnowledgeEntity")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class KnowledgeEntityNode {
@Id
@GeneratedValue
private Long id;
/**
* 实体唯一标识(业务ID,不是Neo4j内部ID)
*/
@Property("entityId")
private String entityId;
/**
* 实体名称
*/
@Property("name")
private String name;
/**
* 实体类型:LAW/REGULATION/ARTICLE/CASE/CONCEPT/ORGANIZATION/PERSON
*/
@Property("entityType")
private String entityType;
/**
* 实体的文本描述(用于显示)
*/
@Property("description")
private String description;
/**
* 实体的向量ID(指向向量数据库中对应的chunk)
*/
@Property("vectorId")
private String vectorId;
/**
* 来源文档ID
*/
@Property("sourceDocumentId")
private String sourceDocumentId;
/**
* 实体的原始文本位置
*/
@Property("textOffset")
private Integer textOffset;
/**
* 是否有效(软删除)
*/
@Property("active")
private boolean active = true;
/**
* 创建时间
*/
@Property("createdAt")
private LocalDateTime createdAt;
/**
* 更新时间
*/
@Property("updatedAt")
private LocalDateTime updatedAt;
/**
* 与其他实体的关系
*/
@Relationship(type = "RELATES_TO", direction = Relationship.Direction.OUTGOING)
private List<EntityRelationship> relationships = new ArrayList<>();
}package com.laozhang.ai.graphrag.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.springframework.data.neo4j.core.schema.*;
import java.time.LocalDateTime;
/**
* 实体之间的关系
* 关系类型示例:AMENDS(修订), REPLACES(替代), REFERENCES(引用),
* APPLIES_TO(适用于), CONTRADICTS(矛盾), SUPPORTS(支持)
*/
@RelationshipProperties
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EntityRelationship {
@RelationshipId
private Long id;
/**
* 关系类型
*/
@Property("relationshipType")
private String relationshipType;
/**
* 关系描述
*/
@Property("description")
private String description;
/**
* 置信度(0-1,由LLM抽取时评估)
*/
@Property("confidence")
private Double confidence;
/**
* 关系来源(从哪个文本片段抽取的)
*/
@Property("sourceChunkId")
private String sourceChunkId;
/**
* 创建时间
*/
@Property("createdAt")
private LocalDateTime createdAt;
/**
* 目标节点
*/
@TargetNode
private KnowledgeEntityNode target;
}package com.laozhang.ai.graphrag.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.util.List;
import java.util.Map;
/**
* NER抽取结果DTO
* LLM返回的实体和关系数据结构
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NerExtractionResult {
/**
* 抽取到的实体列表
*/
private List<ExtractedEntity> entities;
/**
* 抽取到的关系列表
*/
private List<ExtractedRelation> relations;
/**
* 抽取的文本ID
*/
private String chunkId;
/**
* 处理耗时(毫秒)
*/
private long processingTimeMs;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ExtractedEntity {
private String name;
private String type;
private String description;
private Integer startOffset;
private Integer endOffset;
private Double confidence;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ExtractedRelation {
private String sourceEntityName;
private String targetEntityName;
private String relationshipType;
private String description;
private Double confidence;
}
}实体抽取:用LLM做NER
这是GraphRAG最关键的一步。我们用LLM从文本中自动识别实体和关系,然后写入Neo4j。
package com.laozhang.ai.graphrag.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.laozhang.ai.graphrag.domain.NerExtractionResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 基于LLM的命名实体识别(NER)服务
* 从文本中抽取实体和关系,用于构建知识图谱
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LlmNerService {
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
/**
* NER系统提示词
* 这个提示词的质量直接决定知识图谱的质量,需要反复调试
*/
private static final String NER_SYSTEM_PROMPT = """
你是一个专业的命名实体识别(NER)系统,专门处理法律文本。
你的任务是从给定文本中抽取:
1. **实体(Entities)**:具体的命名实体
2. **关系(Relations)**:实体之间的逻辑关系
## 支持的实体类型
- LAW:法律名称(如"公司法"、"合同法")
- REGULATION:法规规章(如"公司注册管理条例")
- ARTICLE:法律条款(如"第166条"、"第212条")
- CASE:案件或判例(如"某某公司诉某某案")
- CONCEPT:法律概念(如"股权分红"、"利润分配")
- ORGANIZATION:机构组织(如"最高人民法院")
- PERSON:人名(如"张三")
## 支持的关系类型
- AMENDS:A修订了B(B是旧版,A是新版说明)
- REPLACES:A替代了B(A是新条款,B是被替代的旧条款)
- REFERENCES:A引用了B(A依据B作出规定)
- APPLIES_TO:A适用于B(规定A适用于情况B)
- CONTRADICTS:A与B矛盾
- SUPPORTS:A支持B的观点
- PART_OF:A是B的一部分(条款A属于法律B)
- PRECEDES:A在时间上先于B(历史版本关系)
## 输出格式
必须输出合法的JSON,格式如下:
```json
{
"entities": [
{
"name": "实体名称",
"type": "实体类型",
"description": "简短描述(30字以内)",
"startOffset": 在文本中的起始位置,
"endOffset": 在文本中的结束位置,
"confidence": 置信度0到1
}
],
"relations": [
{
"sourceEntityName": "源实体名称",
"targetEntityName": "目标实体名称",
"relationshipType": "关系类型",
"description": "关系描述(30字以内)",
"confidence": 置信度0到1
}
]
}
```
## 注意事项
- 只抽取文本中明确存在的实体,不要推断不在文本中的实体
- 置信度根据文本的明确程度来判断:明确提及为0.9+,间接提及为0.6-0.8
- 如果文本中没有实体,返回空数组
- 不要包含任何JSON以外的文字
""";
/**
* 从单个文本块中抽取实体和关系
*
* @param chunkId 文本块ID
* @param text 文本内容
* @return NER抽取结果
*/
public NerExtractionResult extractEntitiesAndRelations(String chunkId, String text) {
long startTime = System.currentTimeMillis();
log.debug("开始NER抽取,chunkId={}, 文本长度={}", chunkId, text.length());
try {
// 构建提示词
String userPrompt = "请从以下文本中抽取实体和关系:\n\n" + text;
// 调用LLM
String response = chatClient.prompt()
.system(NER_SYSTEM_PROMPT)
.user(userPrompt)
.call()
.content();
// 解析JSON响应
NerExtractionResult result = parseNerResponse(response, chunkId);
result.setProcessingTimeMs(System.currentTimeMillis() - startTime);
log.info("NER抽取完成,chunkId={},抽取实体{}个,关系{}个,耗时{}ms",
chunkId,
result.getEntities() != null ? result.getEntities().size() : 0,
result.getRelations() != null ? result.getRelations().size() : 0,
result.getProcessingTimeMs());
return result;
} catch (Exception e) {
log.error("NER抽取失败,chunkId={}", chunkId, e);
// 返回空结果,不中断流程
return NerExtractionResult.builder()
.chunkId(chunkId)
.entities(List.of())
.relations(List.of())
.processingTimeMs(System.currentTimeMillis() - startTime)
.build();
}
}
/**
* 批量抽取实体和关系
* 对大文档分批处理,避免单次请求过长
*
* @param chunks 文本块列表(chunkId -> text)
* @return 抽取结果列表
*/
public List<NerExtractionResult> batchExtract(List<TextChunk> chunks) {
log.info("批量NER抽取开始,共{}个文本块", chunks.size());
return chunks.parallelStream()
.map(chunk -> {
try {
return extractEntitiesAndRelations(chunk.getId(), chunk.getText());
} catch (Exception e) {
log.error("批量NER抽取失败,chunkId={}", chunk.getId(), e);
return NerExtractionResult.builder()
.chunkId(chunk.getId())
.entities(List.of())
.relations(List.of())
.build();
}
})
.toList();
}
/**
* 解析LLM返回的NER JSON结果
*/
private NerExtractionResult parseNerResponse(String response, String chunkId) {
try {
// 清理响应中可能的markdown代码块标记
String cleanedResponse = cleanJsonResponse(response);
NerExtractionResult result = objectMapper.readValue(cleanedResponse, NerExtractionResult.class);
result.setChunkId(chunkId);
return result;
} catch (JsonProcessingException e) {
log.warn("NER响应JSON解析失败,chunkId={},尝试修复...", chunkId);
// 尝试提取JSON部分
String extractedJson = extractJsonFromText(response);
if (extractedJson != null) {
try {
NerExtractionResult result = objectMapper.readValue(extractedJson, NerExtractionResult.class);
result.setChunkId(chunkId);
return result;
} catch (JsonProcessingException ex) {
log.error("NER响应修复失败,chunkId={}", chunkId, ex);
}
}
return NerExtractionResult.builder()
.chunkId(chunkId)
.entities(List.of())
.relations(List.of())
.build();
}
}
/**
* 清理LLM响应中的markdown代码块标记
*/
private String cleanJsonResponse(String response) {
if (response == null) return "{}";
return response
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
}
/**
* 从混合文本中提取JSON部分
*/
private String extractJsonFromText(String text) {
if (text == null) return null;
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
return null;
}
/**
* 文本块内部类
*/
public record TextChunk(String id, String text) {}
}构建知识图谱:写入Neo4j
package com.laozhang.ai.graphrag.service;
import com.laozhang.ai.graphrag.domain.EntityRelationship;
import com.laozhang.ai.graphrag.domain.KnowledgeEntityNode;
import com.laozhang.ai.graphrag.domain.NerExtractionResult;
import com.laozhang.ai.graphrag.repository.KnowledgeEntityRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 知识图谱构建服务
* 将NER抽取结果写入Neo4j,建立实体和关系
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KnowledgeGraphBuilderService {
private final KnowledgeEntityRepository entityRepository;
private final LlmNerService nerService;
/**
* 处理单个文档,构建知识图谱
*
* @param documentId 文档ID
* @param chunks 文档的文本块列表
*/
@Transactional
public void buildGraphFromDocument(String documentId, List<LlmNerService.TextChunk> chunks) {
log.info("开始构建知识图谱,documentId={}, 文本块数量={}", documentId, chunks.size());
long startTime = System.currentTimeMillis();
// 1. 批量NER抽取
List<NerExtractionResult> nerResults = nerService.batchExtract(chunks);
// 2. 合并所有实体(去重)
Map<String, KnowledgeEntityNode> entityMap = new HashMap<>();
for (NerExtractionResult result : nerResults) {
if (result.getEntities() == null) continue;
for (NerExtractionResult.ExtractedEntity extracted : result.getEntities()) {
// 使用实体名称作为去重key(实际项目中可以考虑更复杂的去重逻辑)
String entityKey = extracted.getName().toLowerCase().trim();
if (!entityMap.containsKey(entityKey)) {
KnowledgeEntityNode node = createOrUpdateEntity(
extracted, documentId, result.getChunkId()
);
entityMap.put(entityKey, node);
}
}
}
// 3. 保存所有实体节点到Neo4j
List<KnowledgeEntityNode> savedEntities = entityRepository.saveAll(entityMap.values());
log.info("保存实体节点完成,数量={}", savedEntities.size());
// 重建entityMap(使用保存后的对象,含有Neo4j分配的ID)
Map<String, KnowledgeEntityNode> savedEntityMap = savedEntities.stream()
.collect(Collectors.toMap(
e -> e.getName().toLowerCase().trim(),
e -> e,
(e1, e2) -> e1 // 重复key取第一个
));
// 4. 建立关系
int relationCount = 0;
for (NerExtractionResult result : nerResults) {
if (result.getRelations() == null) continue;
for (NerExtractionResult.ExtractedRelation extracted : result.getRelations()) {
try {
boolean created = createRelationship(
savedEntityMap,
extracted,
result.getChunkId()
);
if (created) relationCount++;
} catch (Exception e) {
log.warn("创建关系失败,source={}, target={}, type={}",
extracted.getSourceEntityName(),
extracted.getTargetEntityName(),
extracted.getRelationshipType(), e);
}
}
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("知识图谱构建完成,documentId={},实体{}个,关系{}个,耗时{}ms",
documentId, entityMap.size(), relationCount, elapsed);
}
/**
* 创建或更新实体节点
*/
private KnowledgeEntityNode createOrUpdateEntity(
NerExtractionResult.ExtractedEntity extracted,
String documentId,
String chunkId) {
// 先查询是否已存在(基于名称的去重)
Optional<KnowledgeEntityNode> existing = entityRepository
.findByNameIgnoreCase(extracted.getName());
if (existing.isPresent()) {
// 更新已有节点
KnowledgeEntityNode node = existing.get();
node.setUpdatedAt(LocalDateTime.now());
return node;
}
// 创建新节点
KnowledgeEntityNode node = new KnowledgeEntityNode();
node.setEntityId(UUID.randomUUID().toString());
node.setName(extracted.getName());
node.setEntityType(extracted.getType());
node.setDescription(extracted.getDescription());
node.setSourceDocumentId(documentId);
node.setTextOffset(extracted.getStartOffset());
node.setActive(true);
node.setCreatedAt(LocalDateTime.now());
node.setUpdatedAt(LocalDateTime.now());
node.setRelationships(new ArrayList<>());
return node;
}
/**
* 创建实体关系
*
* @return 是否成功创建
*/
private boolean createRelationship(
Map<String, KnowledgeEntityNode> entityMap,
NerExtractionResult.ExtractedRelation extracted,
String sourceChunkId) {
String sourceKey = extracted.getSourceEntityName().toLowerCase().trim();
String targetKey = extracted.getTargetEntityName().toLowerCase().trim();
KnowledgeEntityNode sourceNode = entityMap.get(sourceKey);
KnowledgeEntityNode targetNode = entityMap.get(targetKey);
if (sourceNode == null) {
log.debug("源实体未找到:{}", extracted.getSourceEntityName());
return false;
}
if (targetNode == null) {
log.debug("目标实体未找到:{}", extracted.getTargetEntityName());
return false;
}
// 检查是否已存在相同关系(避免重复)
boolean alreadyExists = sourceNode.getRelationships().stream()
.anyMatch(rel ->
rel.getTarget().getName().equalsIgnoreCase(extracted.getTargetEntityName()) &&
rel.getRelationshipType().equals(extracted.getRelationshipType())
);
if (alreadyExists) {
log.debug("关系已存在,跳过:{} -[{}]-> {}",
extracted.getSourceEntityName(),
extracted.getRelationshipType(),
extracted.getTargetEntityName());
return false;
}
// 创建关系
EntityRelationship relationship = new EntityRelationship();
relationship.setRelationshipType(extracted.getRelationshipType());
relationship.setDescription(extracted.getDescription());
relationship.setConfidence(extracted.getConfidence());
relationship.setSourceChunkId(sourceChunkId);
relationship.setCreatedAt(LocalDateTime.now());
relationship.setTarget(targetNode);
sourceNode.getRelationships().add(relationship);
entityRepository.save(sourceNode);
log.debug("创建关系:{} -[{}]-> {}",
extracted.getSourceEntityName(),
extracted.getRelationshipType(),
extracted.getTargetEntityName());
return true;
}
}Neo4j Repository与Cypher查询
package com.laozhang.ai.graphrag.repository;
import com.laozhang.ai.graphrag.domain.KnowledgeEntityNode;
import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* Neo4j知识图谱查询Repository
*/
@Repository
public interface KnowledgeEntityRepository extends Neo4jRepository<KnowledgeEntityNode, Long> {
/**
* 按名称查找(忽略大小写)
*/
Optional<KnowledgeEntityNode> findByNameIgnoreCase(String name);
/**
* 按实体类型查找
*/
List<KnowledgeEntityNode> findByEntityTypeAndActiveTrue(String entityType);
/**
* 从指定实体出发,进行N跳图遍历
* 返回所有在N跳范围内的相关实体
*/
@Query("""
MATCH (start:KnowledgeEntity {name: $entityName})
CALL apoc.path.subgraphNodes(start, {
maxLevel: $maxHops,
relationshipFilter: $relationshipTypes
}) YIELD node
WHERE node.active = true
RETURN node
ORDER BY node.entityType
LIMIT $limit
""")
List<KnowledgeEntityNode> findRelatedEntitiesWithHops(
@Param("entityName") String entityName,
@Param("maxHops") int maxHops,
@Param("relationshipTypes") String relationshipTypes,
@Param("limit") int limit
);
/**
* 查找两个实体之间的最短路径
*/
@Query("""
MATCH (start:KnowledgeEntity {name: $startEntity}),
(end:KnowledgeEntity {name: $endEntity})
CALL apoc.algo.dijkstra(start, end, 'RELATES_TO', 'confidence')
YIELD path, weight
RETURN path
LIMIT 1
""")
Object findShortestPath(
@Param("startEntity") String startEntity,
@Param("endEntity") String endEntity
);
/**
* 查找与多个实体相关的公共邻居
* 用于找出多个实体之间的共同关联
*/
@Query("""
MATCH (e:KnowledgeEntity)
WHERE e.name IN $entityNames AND e.active = true
WITH collect(e) AS entities
UNWIND entities AS entity
MATCH (entity)-[r:RELATES_TO]-(neighbor:KnowledgeEntity)
WHERE neighbor.active = true
WITH neighbor, count(DISTINCT entity) AS connectionCount
WHERE connectionCount >= $minConnections
RETURN neighbor
ORDER BY connectionCount DESC
LIMIT $limit
""")
List<KnowledgeEntityNode> findCommonNeighbors(
@Param("entityNames") List<String> entityNames,
@Param("minConnections") int minConnections,
@Param("limit") int limit
);
/**
* 查找特定关系类型的实体链
* 例如:找出所有"替代"关系链,追踪法律条款的历史演变
*/
@Query("""
MATCH path = (start:KnowledgeEntity {name: $entityName})-
[:RELATES_TO* {relationshipType: $relationshipType}]->
(end:KnowledgeEntity)
WHERE end.active = true
RETURN nodes(path) AS pathNodes, length(path) AS pathLength
ORDER BY pathLength
LIMIT $limit
""")
List<KnowledgeEntityNode> findRelationChain(
@Param("entityName") String entityName,
@Param("relationshipType") String relationshipType,
@Param("limit") int limit
);
/**
* 全文搜索实体名称(用于查询解析阶段的实体识别)
*/
@Query("""
CALL db.index.fulltext.queryNodes('entity_name_index', $searchText)
YIELD node, score
WHERE node.active = true
RETURN node
ORDER BY score DESC
LIMIT $limit
""")
List<KnowledgeEntityNode> fullTextSearchEntities(
@Param("searchText") String searchText,
@Param("limit") int limit
);
}混合检索:图遍历 + 向量相似度
这是GraphRAG的核心查询引擎。我们同时运行两路检索,然后融合结果。
package com.laozhang.ai.graphrag.service;
import com.laozhang.ai.graphrag.domain.KnowledgeEntityNode;
import com.laozhang.ai.graphrag.repository.KnowledgeEntityRepository;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* 混合检索服务
* 融合图遍历检索和向量相似度检索的结果
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HybridRetrievalService {
private final KnowledgeEntityRepository entityRepository;
private final VectorStore vectorStore;
private final LlmNerService nerService;
private final MeterRegistry meterRegistry;
@Value("${graphrag.hybrid.graph-weight:0.4}")
private double graphWeight;
@Value("${graphrag.hybrid.vector-weight:0.6}")
private double vectorWeight;
@Value("${graphrag.hybrid.top-k:10}")
private int topK;
@Value("${graphrag.graph.max-hop:3}")
private int maxHop;
@Value("${graphrag.graph.max-nodes:50}")
private int maxNodes;
// 并行检索的线程池
private final ExecutorService retrievalExecutor = Executors.newFixedThreadPool(4);
/**
* 混合检索入口
*
* @param query 用户查询
* @return 检索到的文档列表(融合了图检索和向量检索结果)
*/
public List<RetrievalResult> hybridSearch(String query) {
Timer.Sample sample = Timer.start(meterRegistry);
log.info("开始混合检索,query={}", query);
try {
// 1. 从查询中提取实体(并行执行)
CompletableFuture<List<String>> entityExtractionFuture = CompletableFuture.supplyAsync(
() -> extractQueryEntities(query), retrievalExecutor
);
// 2. 向量检索(同时启动,不等待实体抽取)
CompletableFuture<List<Document>> vectorSearchFuture = CompletableFuture.supplyAsync(
() -> vectorSearch(query), retrievalExecutor
);
// 3. 等待实体抽取完成,然后做图检索
CompletableFuture<List<KnowledgeEntityNode>> graphSearchFuture =
entityExtractionFuture.thenApplyAsync(
entities -> graphSearch(entities, query), retrievalExecutor
);
// 4. 等待两路检索都完成
List<Document> vectorResults = vectorSearchFuture.join();
List<KnowledgeEntityNode> graphResults = graphSearchFuture.join();
log.info("检索完成:向量结果{}个,图检索结果{}个",
vectorResults.size(), graphResults.size());
// 5. 结果融合
List<RetrievalResult> fusedResults = fuseResults(vectorResults, graphResults, query);
// 6. 记录指标
meterRegistry.counter("graphrag.hybrid.search.total").increment();
meterRegistry.gauge("graphrag.hybrid.vector.results",
vectorResults, List::size);
meterRegistry.gauge("graphrag.hybrid.graph.results",
graphResults, List::size);
return fusedResults;
} catch (Exception e) {
log.error("混合检索失败,query={}", query, e);
// 降级到纯向量检索
log.warn("降级到纯向量检索");
return vectorSearch(query).stream()
.map(doc -> RetrievalResult.fromDocument(doc, vectorWeight))
.collect(Collectors.toList());
} finally {
sample.stop(meterRegistry.timer("graphrag.hybrid.search.duration"));
}
}
/**
* 从查询中抽取实体
* 用于图检索的入口点
*/
private List<String> extractQueryEntities(String query) {
try {
NerExtractionResult result = nerService.extractEntitiesAndRelations("query", query);
if (result.getEntities() == null || result.getEntities().isEmpty()) {
log.debug("查询中未识别到实体,将使用全文搜索");
return List.of();
}
List<String> entityNames = result.getEntities().stream()
.filter(e -> e.getConfidence() != null && e.getConfidence() > 0.6)
.map(NerExtractionResult.ExtractedEntity::getName)
.collect(Collectors.toList());
log.debug("从查询中识别到实体:{}", entityNames);
return entityNames;
} catch (Exception e) {
log.warn("查询实体抽取失败", e);
return List.of();
}
}
/**
* 图检索:从实体出发做图遍历
*/
private List<KnowledgeEntityNode> graphSearch(List<String> entityNames, String query) {
if (entityNames.isEmpty()) {
// 如果没有识别到实体,用全文搜索找入口
return fullTextFallbackSearch(query);
}
Set<KnowledgeEntityNode> resultSet = new HashSet<>();
for (String entityName : entityNames) {
try {
// 多跳图遍历
List<KnowledgeEntityNode> related = entityRepository
.findRelatedEntitiesWithHops(
entityName,
maxHop,
"RELATES_TO",
maxNodes
);
resultSet.addAll(related);
log.debug("实体{}的图遍历结果:{}个相关节点", entityName, related.size());
} catch (Exception e) {
log.warn("实体{}的图遍历失败", entityName, e);
}
}
// 如果有多个实体,找它们的公共邻居(这些往往是最相关的)
if (entityNames.size() > 1) {
try {
List<KnowledgeEntityNode> commonNeighbors = entityRepository
.findCommonNeighbors(entityNames, 2, 20);
// 公共邻居权重加倍(通过添加两次实现)
resultSet.addAll(commonNeighbors);
log.debug("公共邻居:{}个", commonNeighbors.size());
} catch (Exception e) {
log.warn("公共邻居查询失败", e);
}
}
return new ArrayList<>(resultSet);
}
/**
* 全文搜索兜底(当NER无法识别实体时)
*/
private List<KnowledgeEntityNode> fullTextFallbackSearch(String query) {
try {
return entityRepository.fullTextSearchEntities(query, 10);
} catch (Exception e) {
log.warn("全文搜索失败", e);
return List.of();
}
}
/**
* 向量检索
*/
private List<Document> vectorSearch(String query) {
try {
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(0.5)
.build();
return vectorStore.similaritySearch(request);
} catch (Exception e) {
log.error("向量检索失败", e);
return List.of();
}
}
/**
* 结果融合算法
* 将图检索和向量检索的结果合并,基于权重打分后排序
*/
private List<RetrievalResult> fuseResults(
List<Document> vectorResults,
List<KnowledgeEntityNode> graphResults,
String query) {
Map<String, RetrievalResult> resultMap = new LinkedHashMap<>();
// 处理向量检索结果
for (int i = 0; i < vectorResults.size(); i++) {
Document doc = vectorResults.get(i);
String docId = doc.getId();
// 向量检索的排名分数:1/(rank+1) * weight
double score = (1.0 / (i + 1)) * vectorWeight;
RetrievalResult result = resultMap.computeIfAbsent(docId,
k -> RetrievalResult.fromDocument(doc, 0.0));
result.setScore(result.getScore() + score);
result.setFromVector(true);
}
// 处理图检索结果
// 图检索节点需要根据vectorId找到对应的文档
for (int i = 0; i < graphResults.size(); i++) {
KnowledgeEntityNode node = graphResults.get(i);
if (node.getVectorId() == null) continue;
String vectorId = node.getVectorId();
double score = (1.0 / (i + 1)) * graphWeight;
if (resultMap.containsKey(vectorId)) {
// 两路都检索到了,分数叠加
RetrievalResult existing = resultMap.get(vectorId);
existing.setScore(existing.getScore() + score);
existing.setFromGraph(true);
existing.setRelatedEntities(
existing.getRelatedEntities() != null
? existing.getRelatedEntities() + ", " + node.getName()
: node.getName()
);
} else {
// 只有图检索找到的
RetrievalResult result = RetrievalResult.builder()
.documentId(vectorId)
.content(node.getDescription())
.score(score)
.fromGraph(true)
.fromVector(false)
.relatedEntities(node.getName())
.entityType(node.getEntityType())
.build();
resultMap.put(vectorId, result);
}
}
// 按分数排序,返回top-k
return resultMap.values().stream()
.sorted(Comparator.comparingDouble(RetrievalResult::getScore).reversed())
.limit(topK)
.collect(Collectors.toList());
}
/**
* 检索结果封装
*/
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public static class RetrievalResult {
private String documentId;
private String content;
private double score;
private boolean fromVector;
private boolean fromGraph;
private String relatedEntities;
private String entityType;
private Map<String, Object> metadata;
public static RetrievalResult fromDocument(Document doc, double score) {
return RetrievalResult.builder()
.documentId(doc.getId())
.content(doc.getFormattedContent())
.score(score)
.fromVector(true)
.fromGraph(false)
.metadata(doc.getMetadata())
.build();
}
}
}GraphRAG问答完整流程
package com.laozhang.ai.graphrag.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* GraphRAG问答服务
* 整合混合检索 + LLM生成,提供完整的问答能力
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class GraphRagQaService {
private final HybridRetrievalService hybridRetrievalService;
private final ChatClient chatClient;
private static final String QA_SYSTEM_PROMPT = """
你是一个专业的法律智能助手,具有丰富的法律知识。
回答规则:
1. 只基于提供的上下文信息回答,不要凭空推断
2. 如果上下文信息不足以回答,明确说明"根据现有资料,无法确认..."
3. 引用具体条款时,注明条款编号和法律名称
4. 如果法律条款有新旧版本差异,必须明确指出版本差异
5. 回答需要清晰、准确、有条理
上下文信息:
{context}
""";
/**
* 问答接口
*
* @param question 用户问题
* @return 答案
*/
public QaResponse answer(String question) {
log.info("GraphRAG问答开始,question={}", question);
long startTime = System.currentTimeMillis();
// 1. 混合检索
List<HybridRetrievalService.RetrievalResult> retrievalResults =
hybridRetrievalService.hybridSearch(question);
if (retrievalResults.isEmpty()) {
return QaResponse.builder()
.answer("抱歉,未找到与您问题相关的信息。请尝试换一种表达方式。")
.retrievalCount(0)
.processingTimeMs(System.currentTimeMillis() - startTime)
.build();
}
// 2. 构建上下文
String context = buildContext(retrievalResults);
// 3. 生成答案
String answer = chatClient.prompt()
.system(QA_SYSTEM_PROMPT.replace("{context}", context))
.user(question)
.call()
.content();
long processingTime = System.currentTimeMillis() - startTime;
log.info("GraphRAG问答完成,耗时={}ms,检索结果数={}", processingTime, retrievalResults.size());
return QaResponse.builder()
.answer(answer)
.retrievalCount(retrievalResults.size())
.graphHitCount((int) retrievalResults.stream().filter(r -> r.isFromGraph()).count())
.vectorHitCount((int) retrievalResults.stream().filter(r -> r.isFromVector()).count())
.processingTimeMs(processingTime)
.build();
}
/**
* 构建LLM上下文
*/
private String buildContext(List<HybridRetrievalService.RetrievalResult> results) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < results.size(); i++) {
HybridRetrievalService.RetrievalResult result = results.get(i);
sb.append("--- 参考资料 ").append(i + 1).append(" ---\n");
// 说明来源(图检索还是向量检索)
List<String> sources = new java.util.ArrayList<>();
if (result.isFromGraph()) sources.add("知识图谱");
if (result.isFromVector()) sources.add("语义搜索");
sb.append("来源:").append(String.join("+", sources)).append("\n");
if (result.getRelatedEntities() != null) {
sb.append("相关实体:").append(result.getRelatedEntities()).append("\n");
}
sb.append("内容:").append(result.getContent()).append("\n\n");
}
return sb.toString();
}
@lombok.Data
@lombok.Builder
@lombok.NoArgsConstructor
@lombok.AllArgsConstructor
public static class QaResponse {
private String answer;
private int retrievalCount;
private int graphHitCount;
private int vectorHitCount;
private long processingTimeMs;
}
}Cypher查询优化:索引与性能
在生产环境中,图查询的性能优化非常重要。
-- 创建全文索引(支持全文搜索)
CREATE FULLTEXT INDEX entity_name_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON EACH [n.name, n.description];
-- 创建属性索引(加速精确匹配)
CREATE INDEX entity_id_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON (n.entityId);
CREATE INDEX entity_type_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON (n.entityType);
CREATE INDEX entity_active_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON (n.active);
-- 复合索引(加速按类型+状态筛选)
CREATE INDEX entity_type_active_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON (n.entityType, n.active);package com.laozhang.ai.graphrag.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
/**
* Neo4j索引初始化服务
* 应用启动时确保所有必要的索引都已创建
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class Neo4jIndexInitService {
private final Driver neo4jDriver;
@PostConstruct
public void initializeIndexes() {
log.info("初始化Neo4j索引...");
try (Session session = neo4jDriver.session()) {
// 全文索引
session.run("""
CREATE FULLTEXT INDEX entity_name_index IF NOT EXISTS
FOR (n:KnowledgeEntity)
ON EACH [n.name, n.description]
""");
// 属性索引
session.run("""
CREATE INDEX entity_id_index IF NOT EXISTS
FOR (n:KnowledgeEntity) ON (n.entityId)
""");
session.run("""
CREATE INDEX entity_type_active_index IF NOT EXISTS
FOR (n:KnowledgeEntity) ON (n.entityType, n.active)
""");
log.info("Neo4j索引初始化完成");
} catch (Exception e) {
log.error("Neo4j索引初始化失败(不影响启动)", e);
}
}
/**
* 分析查询计划(用于调试慢查询)
*/
public String explainQuery(String cypher) {
try (Session session = neo4jDriver.session()) {
Result result = session.run("EXPLAIN " + cypher);
return result.consume().toString();
}
}
}实际效果对比数据
这是小陈在法律AI系统上做的对比测试,测试集包含200个真实律师查询:
| 查询类型 | 普通RAG | GraphRAG | 提升 |
|---|---|---|---|
| 简单事实查询 | 78% | 81% | +3% |
| 单实体属性查询 | 72% | 79% | +7% |
| 实体关系查询 | 41% | 73% | +32% |
| 多跳推理查询 | 23% | 69% | +46% |
| 法条演变追踪 | 18% | 71% | +53% |
| 综合平均 | 46% | 75% | +29% |
性能数据:
| 指标 | 普通RAG | GraphRAG |
|---|---|---|
| P50延迟 | 45ms | 180ms |
| P95延迟 | 120ms | 420ms |
| P99延迟 | 280ms | 850ms |
| 内存占用 | 512MB | 1.2GB |
| 构建时间(万条文档) | 2h | 8h |
GraphRAG的检索延迟是普通RAG的4倍左右,但在关联型查询上的精度提升非常显著。
适用场景与成本分析
什么时候值得用GraphRAG
强烈推荐:
- 法律、医疗、金融等强规则领域
- 知识有明确的依赖链(条款→法律→案例)
- 经常需要多跳推理(A的修订影响B,B影响C)
- 知识更新频繁,需要追踪演变
谨慎考虑:
- 文档量超过百万,NER构建成本很高
- 实时性要求极高(需要缓存优化)
- 团队没有Neo4j经验
不推荐:
- 通用FAQ、客服问答
- 知识之间关联性弱
- 预算有限(Neo4j企业版+额外LLM调用成本)
成本估算(以10万条文档为例)
| 成本项 | 普通RAG | GraphRAG |
|---|---|---|
| NER抽取LLM费用 | 无 | ~$200-500 |
| 向量存储 | ~$50/月 | ~$50/月 |
| Neo4j(社区版) | 无 | 免费 |
| Neo4j(企业版) | 无 | $1000+/月 |
| 运维复杂度 | 低 | 中高 |
| 总计(首年) | ~$600 | ~$3000+ |
生产注意事项
1. NER质量是关键
知识图谱的质量完全取决于NER的质量。要定期抽样检查NER结果:
// 定期运行NER质量检查
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void runNerQualityCheck() {
// 随机抽取100个节点检查
List<KnowledgeEntityNode> sample = entityRepository.findRandomSample(100);
// 记录低置信度的实体
long lowConfidenceCount = sample.stream()
.filter(e -> e.getRelationships().stream()
.anyMatch(r -> r.getConfidence() < 0.7))
.count();
log.info("NER质量检查:低置信度实体占比={}%", lowConfidenceCount);
}2. 图数据的一致性维护
当原始文档更新时,需要同步更新知识图谱:
@EventListener
public void onDocumentUpdated(DocumentUpdatedEvent event) {
// 软删除旧的实体和关系
entityRepository.deactivateByDocumentId(event.getDocumentId());
// 重新构建
buildGraphFromDocument(event.getDocumentId(), event.getNewChunks());
}3. Neo4j内存配置
生产环境Neo4j的JVM配置:
# neo4j.conf
server.memory.heap.initial_size=2g
server.memory.heap.max_size=4g
server.memory.pagecache.size=2g
dbms.transaction.timeout=60s常见问题解答
Q1:Neo4j社区版够用吗?
对于百万节点以下的图谱,社区版完全够用。企业版的核心优势是集群HA和热备,如果不需要高可用,社区版即可。社区版免费,企业版年费较高。
Q2:NER抽取的LLM调用量很大,成本怎么控制?
三个策略:一是批量处理,用较大的batch_size合并请求;二是对变化频率低的文档做缓存,避免重复抽取;三是先用小模型(如GPT-4o-mini)做初步抽取,再用大模型验证高置信度不确定的结果。实践中可以把NER成本控制在整体AI成本的15%以内。
Q3:如何处理实体歧义?(比如"公司法"可以指不同年份的版本)
在实体节点上增加版本属性,建立PRECEDES/SUPERSEDES关系链。查询时优先选择最新版本的实体,同时保留历史版本供追踪。
Q4:图遍历深度设置多少合适?
通常2-3跳足够。跳数越多,结果越多但相关性越低,同时查询耗时指数级增长。3跳在大多数业务场景下能覆盖足够的关联信息。
Q5:向量检索和图检索的权重如何调整?
这需要根据你的查询类型分布来调。如果用户查询以精确实体查询为主,适当提高图检索权重(0.5-0.6);如果以语义模糊查询为主,提高向量权重(0.7+)。建议建立评估集,通过A/B测试来确定最佳权重。
Q6:Neo4j的APOC插件是必须的吗?
apoc.path.subgraphNodes这个函数需要APOC。如果不想依赖APOC,可以用纯Cypher的变长路径查询替代:MATCH (start)-[:RELATES_TO*1..3]-(end) WHERE end.active = true RETURN end,但性能可能略差。
总结
GraphRAG不是万能药,但在处理强关系型知识的场景下,它的提升效果是实实在在的。
可操作行动清单:
