Spring AI RAG实战:从文档入库到智能问答全流程详解
Spring AI RAG实战:从文档入库到智能问答全流程详解
适读人群:Java开发者,想用Spring AI构建企业知识库问答系统 阅读时长:约25分钟 文章价值:
- RAG完整实现代码(文档入库→检索→生成)
- 向量数据库选型决策树
- 分块策略、Reranker、多轮对话完整实现
- 生产环境踩坑经验
那个用RAG"救火"的下午
老陈是我认识了好几年的朋友,在一家制造业公司做Java架构师。
今年3月,公司决定搞内部知识库系统——把几千份产品手册、工艺规范、历史工单全部接入AI,让员工能直接问问题。老陈接了这个活,拿了两周时间,用最朴素的方案上线了:把所有文档存到PostgreSQL,用户提问时用LIKE模糊匹配,再把匹配到的文本丢给GPT回答。
上线第一天,他们老板问了一个问题:"这台设备的维护周期是多久?"
系统给出的答案,拼错了设备型号,还把两份不同文档的内容混在一起说了一通废话。
老陈给我发微信:"这玩意根本不能用,用户问啥它胡说啥。"
我回他:"你这不是RAG,这是硬拼字符串。RAG要做向量化,不是关键词匹配。"
然后我花了一个下午,给他讲了今天这篇文章里的全套方案。三周后,他们的知识库系统正式上线,第一个月用户好评率87%。
今天把这套方案完整写出来。
RAG核心原理:为什么要用向量?
先把问题说清楚。老陈的方案失败在哪里?
LIKE '%维护周期%' 这种匹配,只能找到包含这几个字的文档。但用户实际的问题是"多久保养一次",这句话里一个"维护"都没有,但意思完全相同。关键词匹配,天生对语义变换无效。
向量化的逻辑完全不同:把文本变成一个高维空间里的向量,语义相近的文本,向量距离也近。"维护周期"和"多久保养"在向量空间里的距离,比"维护周期"和"天气预报"近得多。
RAG就建立在这个基础上:
具体流程分两条线:
文档入库线(离线,一次性):文档 → 解析文本 → 切块 → 向量化 → 存入向量库
查询线(在线,实时):问题 → 向量化 → 向量库检索 → 召回相关块 → 重排序 → LLM生成答案
整体架构设计
项目依赖配置
<!-- pom.xml -->
<dependencies>
<!-- Spring AI OpenAI(含Embedding模型) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- PGVector向量数据库(PostgreSQL插件) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- Apache Tika:支持PDF/Word/PPT等格式解析 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!-- PostgreSQL驱动 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Redis(对话历史缓存) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies># application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
temperature: 0.1 # RAG场景低temperature,减少幻觉
embedding:
options:
model: text-embedding-3-small
dimensions: 1536
vectorstore:
pgvector:
index-type: HNSW # HNSW索引,高性能近似搜索
distance-type: COSINE_DISTANCE
dimensions: 1536
datasource:
url: jdbc:postgresql://localhost:5432/rag_db
username: postgres
password: ${DB_PASSWORD}
rag:
chunk-size: 800 # 每块Token数
chunk-overlap: 100 # 相邻块重叠Token数
top-k: 20 # 向量检索召回数量
rerank-top-k: 5 # Reranker精排后保留数量
similarity-threshold: 0.5第一关:文档入库
多格式文档加载器
/**
* 根据文件类型自动选择解析器
* 支持:PDF、Word、Markdown、TXT
*/
@Component
@Slf4j
public class DocumentLoaderFactory {
public List<Document> load(MultipartFile file) throws IOException {
String filename = Objects.requireNonNull(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(filename).toLowerCase();
log.info("加载文档:{},类型:{}", filename, ext);
// 将MultipartFile转为临时Resource
Resource resource = new InputStreamResource(file.getInputStream()) {
@Override
public String getFilename() { return filename; }
};
return switch (ext) {
case "pdf" -> {
// PDF按页解析,保留页码信息
PagePdfDocumentReader reader = new PagePdfDocumentReader(
resource,
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPagesPerDocument(1) // 每页一个Document,保留页码
.build()
);
yield reader.get();
}
case "docx", "doc", "pptx", "xlsx" -> {
// Office格式统一用Tika解析
TikaDocumentReader reader = new TikaDocumentReader(resource);
yield reader.get();
}
case "md", "txt" -> {
TextReader reader = new TextReader(resource);
yield reader.get();
}
default -> throw new UnsupportedOperationException(
"不支持的文件格式:" + ext + ",支持:pdf/docx/md/txt");
};
}
}智能分块策略(这里很多人踩坑)
分块是RAG质量的第一道关。块太大,检索到的内容冗余,LLM上下文浪费;块太小,上下文不完整,LLM答不准。
不同文档类型需要不同的分块参数:
/**
* 自适应文档分块服务
* 根据文档类型选择最优分块参数
*/
@Service
@Slf4j
public class AdaptiveTextSplitterService {
/**
* 文档类型对应的最优分块配置
*/
private static final Map<String, ChunkConfig> CHUNK_CONFIGS = Map.of(
"technical_doc", new ChunkConfig(800, 100), // 技术文档:块大,上下文丰富
"faq", new ChunkConfig(300, 30), // FAQ:块小,一问一答
"contract", new ChunkConfig(600, 80), // 合同:中等,保留条款完整性
"news", new ChunkConfig(500, 50), // 新闻:段落为单位
"default", new ChunkConfig(600, 80)
);
public List<Document> split(List<Document> rawDocs, String docType) {
ChunkConfig config = CHUNK_CONFIGS.getOrDefault(docType,
CHUNK_CONFIGS.get("default"));
log.info("文档分块:类型={},chunkSize={},overlap={}",
docType, config.chunkSize(), config.overlap());
TokenTextSplitter splitter = new TokenTextSplitter(
config.chunkSize(),
config.overlap(),
5, // 最小chunk Token数
10000, // 最大chunk Token数
true // 保留分隔符
);
List<Document> chunks = splitter.apply(rawDocs);
log.info("分块完成:原始{}个文档 → {}个Chunks", rawDocs.size(), chunks.size());
return chunks;
}
record ChunkConfig(int chunkSize, int overlap) {}
}文档入库主流程
/**
* 文档入库服务
* 完成:加载→分块→元数据增强→向量化→存储
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class DocumentIngestionService {
private final DocumentLoaderFactory loaderFactory;
private final AdaptiveTextSplitterService splitterService;
private final VectorStore vectorStore;
private final DocumentMetadataRepository metadataRepo;
/**
* 文档入库主入口
*
* @param file 上传的文档文件
* @param docType 文档类型(technical_doc/faq/contract等)
* @param tenantId 租户ID(权限隔离)
* @param extraMeta 额外元数据(如业务分类、密级等)
*/
@Transactional
public IngestionResult ingest(MultipartFile file,
String docType,
String tenantId,
Map<String, Object> extraMeta) throws IOException {
String filename = file.getOriginalFilename();
String docId = UUID.randomUUID().toString();
log.info("开始入库:file={}, docId={}, tenant={}", filename, docId, tenantId);
// 1. 解析文档
List<Document> rawDocs = loaderFactory.load(file);
// 2. 按类型分块
List<Document> chunks = splitterService.split(rawDocs, docType);
// 3. 增强元数据(每个chunk都携带这些信息,检索时可以过滤)
chunks.forEach(chunk -> {
Map<String, Object> meta = chunk.getMetadata();
meta.put("doc_id", docId);
meta.put("filename", filename);
meta.put("doc_type", docType);
meta.put("tenant_id", tenantId); // 租户隔离关键字段
meta.put("upload_time", Instant.now().toString());
meta.put("file_size", file.getSize());
// 合并业务自定义元数据
if (extraMeta != null) meta.putAll(extraMeta);
});
// 4. 写入向量库(自动完成向量化)
vectorStore.add(chunks);
// 5. 记录文档级别的元数据(用于管理:列表/删除/更新)
DocumentMetadata docMeta = DocumentMetadata.builder()
.docId(docId)
.filename(filename)
.docType(docType)
.tenantId(tenantId)
.chunkCount(chunks.size())
.uploadTime(Instant.now())
.fileSize(file.getSize())
.build();
metadataRepo.save(docMeta);
log.info("入库完成:docId={}, chunks={}", docId, chunks.size());
return IngestionResult.builder()
.docId(docId)
.filename(filename)
.chunkCount(chunks.size())
.status("SUCCESS")
.build();
}
/**
* 删除文档(向量库+元数据全部清除)
* PGVector支持按元数据过滤删除
*/
@Transactional
public void deleteDocument(String docId) {
// 通过FilterExpression按doc_id删除所有chunks
vectorStore.delete(List.of(docId));
metadataRepo.deleteByDocId(docId);
log.info("文档已删除:docId={}", docId);
}
}第二关:查询与检索
为什么要加Reranker?
向量检索召回的20个文档,相似度分数只代表"方向接近",不代表"最能回答问题"。Reranker(交叉编码器)会把查询和每个文档块放在一起重新打分,精度比余弦相似度高很多。
代价是速度:Reranker需要把query和每个候选块配对推理,20个候选就要推理20次。所以标准做法是:向量检索召回Top-20,Reranker精排取Top-5。
Reranker服务实现
/**
* Reranker 精排服务
* 调用 Cohere Rerank API(也可以部署本地BGE-Reranker)
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RerankerService {
private final WebClient cohereWebClient;
@Value("${cohere.api-key}")
private String cohereApiKey;
/**
* 对候选文档精排,返回相关性最高的topK个
*
* @param query 用户查询
* @param candidates 向量检索召回的候选文档
* @param topK 保留数量
*/
public List<Document> rerank(String query, List<Document> candidates, int topK) {
if (candidates.isEmpty()) return candidates;
if (candidates.size() <= topK) return candidates;
log.debug("Reranker精排:query={}, candidates={}, topK={}",
query, candidates.size(), topK);
// 构建 Cohere Rerank 请求
Map<String, Object> requestBody = Map.of(
"model", "rerank-multilingual-v3.0", // 支持中文
"query", query,
"documents", candidates.stream()
.map(Document::getContent)
.collect(Collectors.toList()),
"top_n", topK,
"return_documents", false
);
try {
RerankResponse response = cohereWebClient.post()
.uri("/rerank")
.header("Authorization", "Bearer " + cohereApiKey)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(RerankResponse.class)
.timeout(Duration.ofSeconds(10))
.block();
// 按Reranker返回的索引重排
assert response != null;
return response.getResults().stream()
.map(r -> candidates.get(r.getIndex()))
.collect(Collectors.toList());
} catch (Exception e) {
log.warn("Reranker调用失败,降级为向量检索原始排序:{}", e.getMessage());
// 降级:直接返回向量检索前topK结果
return candidates.subList(0, Math.min(topK, candidates.size()));
}
}
@Data
static class RerankResponse {
private List<RerankResult> results;
}
@Data
static class RerankResult {
private int index;
@JsonProperty("relevance_score")
private double relevanceScore;
}
}核心RAG查询服务
/**
* RAG 核心查询服务
* 整合:向量检索→Reranker精排→Prompt构建→LLM生成
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RagQueryService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final RerankerService reranker;
private final ChatHistoryService chatHistory;
private final RedisTemplate<String, Object> redisTemplate;
@Value("${rag.top-k:20}")
private int topK;
@Value("${rag.rerank-top-k:5}")
private int rerankTopK;
@Value("${rag.similarity-threshold:0.5}")
private double similarityThreshold;
private static final String CACHE_PREFIX = "rag:answer:";
private static final int CACHE_TTL_MINUTES = 60;
private static final String SYSTEM_PROMPT = """
你是一位专业的知识库问答助手。
回答规范:
1. 严格基于提供的【参考资料】回答,不要使用训练数据中的知识
2. 如果参考资料中没有相关信息,直接回答"根据现有资料无法回答此问题"
3. 引用具体内容时,在句末标注来源:[来自:文档名 第X页]
4. 回答要结构化,关键信息用列表呈现
5. 不要编造、推断或添加未经资料证实的内容
""";
/**
* 单轮问答(无对话历史)
*/
public RagResponse query(String question, String tenantId) {
// 1. 检查缓存(完全相同的问题直接返回)
String cacheKey = CACHE_PREFIX + DigestUtils.md5Hex(tenantId + ":" + question);
RagResponse cached = (RagResponse) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.debug("缓存命中:{}", question);
return cached.withCacheHit(true);
}
// 2. 向量检索(带租户过滤,权限隔离)
SearchRequest searchRequest = SearchRequest.query(question)
.withTopK(topK)
.withSimilarityThreshold(similarityThreshold)
.withFilterExpression("tenant_id == '" + tenantId + "'"); // 租户隔离
List<Document> candidates = vectorStore.similaritySearch(searchRequest);
if (candidates.isEmpty()) {
return RagResponse.noContext("未检索到相关文档,请换一种提问方式");
}
// 3. Reranker精排
List<Document> topDocs = reranker.rerank(question, candidates, rerankTopK);
// 4. 构建Prompt并生成答案
String context = buildContext(topDocs);
String answer = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(u -> u.text("""
【参考资料】
{context}
【用户问题】
{question}
""")
.param("context", context)
.param("question", question))
.call()
.content();
// 5. 构建响应
RagResponse response = RagResponse.builder()
.answer(answer)
.sources(extractSources(topDocs))
.retrievedCount(candidates.size())
.rerankCount(topDocs.size())
.cacheHit(false)
.build();
// 6. 写入缓存
redisTemplate.opsForValue().set(cacheKey, response,
Duration.ofMinutes(CACHE_TTL_MINUTES));
return response;
}
/**
* 多轮对话问答(携带历史上下文)
*/
public RagResponse queryWithHistory(String question,
String sessionId,
String tenantId) {
// 1. 获取最近5轮对话历史
List<ChatTurn> history = chatHistory.getHistory(sessionId, 5);
// 2. 如果有历史,用历史+当前问题改写查询(提升检索准确性)
String enrichedQuery = history.isEmpty()
? question
: rewriteQueryWithHistory(question, history);
// 3. 向量检索(用改写后的查询)
SearchRequest searchRequest = SearchRequest.query(enrichedQuery)
.withTopK(topK)
.withSimilarityThreshold(similarityThreshold)
.withFilterExpression("tenant_id == '" + tenantId + "'");
List<Document> candidates = vectorStore.similaritySearch(searchRequest);
List<Document> topDocs = reranker.rerank(enrichedQuery, candidates, rerankTopK);
// 4. 构建含历史的Prompt
String context = buildContext(topDocs);
String historyText = formatHistory(history);
String answer = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(u -> u.text("""
【参考资料】
{context}
【对话历史】
{history}
【当前问题】
{question}
""")
.param("context", context)
.param("history", historyText)
.param("question", question))
.call()
.content();
// 5. 更新对话历史
chatHistory.addTurn(sessionId, question, answer);
return RagResponse.builder()
.answer(answer)
.sources(extractSources(topDocs))
.sessionId(sessionId)
.retrievedCount(candidates.size())
.cacheHit(false)
.build();
}
/**
* 流式输出版本(边生成边推送,改善用户体验)
*/
public Flux<String> queryStream(String question, String tenantId) {
SearchRequest searchRequest = SearchRequest.query(question)
.withTopK(topK)
.withSimilarityThreshold(similarityThreshold)
.withFilterExpression("tenant_id == '" + tenantId + "'");
List<Document> candidates = vectorStore.similaritySearch(searchRequest);
List<Document> topDocs = reranker.rerank(question, candidates, rerankTopK);
String context = buildContext(topDocs);
return chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(u -> u.text("【参考资料】\n{context}\n\n【问题】\n{question}")
.param("context", context)
.param("question", question))
.stream()
.content();
}
/**
* 用对话历史改写查询,让向量检索更准确
* 示例:历史"上次说的那个设备"+ 当前"它的维护周期" → "XX设备的维护周期"
*/
private String rewriteQueryWithHistory(String question, List<ChatTurn> history) {
String historyText = formatHistory(history);
try {
return chatClient.prompt()
.user(u -> u.text("""
根据对话历史,将用户的当前问题改写为独立、完整、可直接检索的查询。
只输出改写后的查询,不要任何解释。
对话历史:
{history}
当前问题:{question}
""")
.param("history", historyText)
.param("question", question))
.call()
.content();
} catch (Exception e) {
log.warn("查询改写失败,使用原始查询:{}", e.getMessage());
return question;
}
}
private String buildContext(List<Document> docs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document doc = docs.get(i);
String filename = (String) doc.getMetadata().getOrDefault("filename", "未知文档");
Object pageNum = doc.getMetadata().get("page_number");
String location = pageNum != null ? " 第" + pageNum + "页" : "";
sb.append(String.format("[%d] 来自《%s》%s\n%s\n\n",
i + 1, filename, location, doc.getContent()));
}
return sb.toString();
}
private List<SourceReference> extractSources(List<Document> docs) {
return docs.stream()
.map(doc -> SourceReference.builder()
.filename((String) doc.getMetadata().getOrDefault("filename", "未知"))
.pageNumber(String.valueOf(doc.getMetadata().getOrDefault("page_number", "")))
.excerpt(doc.getContent().substring(0,
Math.min(80, doc.getContent().length())) + "...")
.build())
.collect(Collectors.toList());
}
private String formatHistory(List<ChatTurn> history) {
if (history.isEmpty()) return "(无)";
StringBuilder sb = new StringBuilder();
for (ChatTurn turn : history) {
sb.append("用户:").append(turn.getQuestion()).append("\n");
sb.append("助手:").append(turn.getAnswer()).append("\n");
}
return sb.toString();
}
}对话历史管理
多轮对话需要持久化对话历史,Redis是最合适的选择:活跃会话放内存,过期自动清理。
/**
* 对话历史服务
* 使用Redis List存储,最多保留N轮
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatHistoryService {
private final RedisTemplate<String, Object> redisTemplate;
private static final String HISTORY_PREFIX = "rag:history:";
private static final int MAX_TURNS = 10; // 每个会话最多保留10轮
private static final int TTL_HOURS = 24; // 24小时过期
public void addTurn(String sessionId, String question, String answer) {
String key = HISTORY_PREFIX + sessionId;
ChatTurn turn = new ChatTurn(question, answer, Instant.now());
// 追加到列表末尾
redisTemplate.opsForList().rightPush(key, turn);
// 超过最大轮数,删除最老的
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_TURNS) {
redisTemplate.opsForList().leftPop(key);
}
// 刷新过期时间
redisTemplate.expire(key, Duration.ofHours(TTL_HOURS));
}
public List<ChatTurn> getHistory(String sessionId, int lastN) {
String key = HISTORY_PREFIX + sessionId;
Long size = redisTemplate.opsForList().size(key);
if (size == null || size == 0) return List.of();
long start = Math.max(0, size - lastN);
List<Object> raw = redisTemplate.opsForList().range(key, start, -1);
if (raw == null) return List.of();
return raw.stream()
.filter(o -> o instanceof ChatTurn)
.map(o -> (ChatTurn) o)
.collect(Collectors.toList());
}
public void clearHistory(String sessionId) {
redisTemplate.delete(HISTORY_PREFIX + sessionId);
}
}向量数据库选型
五款主流向量数据库对比:
| 数据库 | 向量规模 | QPS | 部署复杂度 | Spring AI支持 | 最适合场景 |
|---|---|---|---|---|---|
| PGVector | 100万以内 | 中等 | 低(PostgreSQL插件) | 官方 | 已有PG、初创项目 |
| Qdrant | 千万级 | 高 | 中(独立部署) | 官方 | 高性能生产环境 |
| Milvus | 亿级 | 极高 | 高(分布式集群) | 官方 | 超大规模企业 |
| Chroma | 百万内 | 中等 | 极低(内嵌/本地) | 官方 | 本地开发、原型 |
| Redis | 百万内 | 极高 | 低(已有Redis) | 官方 | 低延迟实时场景 |
怎么选?
问题1:文档规模多大?
- < 100万向量(约1万份文档)→ PGVector 足够,不需要额外基础设施
- 100万 ~ 1亿 → Qdrant,性能强,运维简单
1亿 → Milvus,专业向量数据库集群
问题2:是否已有PostgreSQL?
- 有PG → 直接装PGVector插件,零成本升级
问题3:对延迟要求有多高?
- P99 < 50ms → Redis Vector
- P99 < 200ms → Qdrant
- P99 < 1s → PGVector
老陈的场景(5000份文档,日均1000次查询):直接用PGVector,省钱省事。
PGVector本地快速启动
# Docker一键启动(含pgvector扩展)
docker run -d \
--name pgvector \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=rag_db \
-p 5432:5432 \
pgvector/pgvector:pg16
# 连入后初始化
psql -U postgres -d rag_db -c "CREATE EXTENSION IF NOT EXISTS vector;"Spring AI会自动在启动时建表和索引,无需手动建表。
生产环境踩坑记录
坑1:相同内容重复入库
文档更新后重新上传,旧数据没清除,导致同一内容有多份向量。解决:入库前先按docId删除旧数据:
// 先删后写,保证幂等
public void updateDocument(MultipartFile file, String docId, ...) {
deleteDocument(docId); // 删旧数据
ingest(file, ...); // 入新数据
}坑2:Embedding模型换了必须重建索引
text-embedding-3-small(1536维)和text-embedding-3-large(3072维)的向量空间完全不兼容。换模型 = 全量重新入库。不要在生产环境随意换模型。
坑3:similarity-threshold设置太高
阈值0.8听起来精准,但实际上很多语义相关的文档被过滤掉了,检索结果变成0,LLM只能说"不知道"。建议从0.5开始,逐步调高,配合A/B测试。
坑4:大PDF每页单独分块
PDF按页读取时,如果某个知识点跨了两页,两页分别入库会把完整的信息切断。解决:使用overlap参数让相邻块有重叠,确保跨页信息不丢失。
坑5:中文特殊字符导致向量化失败
PDF里的表格、公式、特殊符号提取后往往包含乱码。入库前加一步清洗:
private String cleanText(String raw) {
return raw
.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "") // 控制字符
.replaceAll("\\s{3,}", "\n\n") // 多余空行压缩
.trim();
}评估:怎么知道RAG效果好不好?
上线之前,必须有量化评估,不能只靠"感觉"。
/**
* RAG评估服务
* 计算召回率(Recall@K)和首位命中率(Hit@1)
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RagEvaluationService {
private final RagQueryService ragQueryService;
/**
* 批量评估召回质量
*
* @param testCases 测试集:每条包含 question + expectedDocId
* @param tenantId 租户ID
*/
public EvaluationReport evaluate(List<EvalCase> testCases, String tenantId) {
int hitAt1 = 0; // 第1条就命中
int hitAt5 = 0; // Top-5内命中
for (EvalCase tc : testCases) {
RagResponse response = ragQueryService.query(tc.getQuestion(), tenantId);
List<String> retrievedDocIds = response.getSources().stream()
.map(SourceReference::getDocId)
.collect(Collectors.toList());
if (!retrievedDocIds.isEmpty() &&
retrievedDocIds.get(0).equals(tc.getExpectedDocId())) {
hitAt1++;
}
if (retrievedDocIds.contains(tc.getExpectedDocId())) {
hitAt5++;
}
}
int total = testCases.size();
double recallAt1 = (double) hitAt1 / total;
double recallAt5 = (double) hitAt5 / total;
log.info("RAG评估结果:Hit@1={:.1%}, Hit@5={:.1%},测试集={}条",
recallAt1, recallAt5, total);
return EvaluationReport.builder()
.totalCases(total)
.hitAt1(hitAt1)
.hitAt5(hitAt5)
.recallAt1(recallAt1)
.recallAt5(recallAt5)
.build();
}
}行业参考值:好的RAG系统,Hit@5 应该在85%以上。如果低于70%,优先排查分块策略和相似度阈值。
完整API接口设计
@RestController
@RequestMapping("/api/rag")
@RequiredArgsConstructor
@Slf4j
public class RagController {
private final DocumentIngestionService ingestionService;
private final RagQueryService queryService;
// -------- 文档管理 --------
@PostMapping("/documents")
public ResponseEntity<IngestionResult> uploadDocument(
@RequestParam MultipartFile file,
@RequestParam(defaultValue = "default") String docType,
@RequestHeader("X-Tenant-Id") String tenantId) throws IOException {
return ResponseEntity.ok(
ingestionService.ingest(file, docType, tenantId, null));
}
@DeleteMapping("/documents/{docId}")
public ResponseEntity<Void> deleteDocument(@PathVariable String docId) {
ingestionService.deleteDocument(docId);
return ResponseEntity.noContent().build();
}
// -------- 问答查询 --------
@PostMapping("/query")
public ResponseEntity<RagResponse> query(
@RequestBody QueryRequest request,
@RequestHeader("X-Tenant-Id") String tenantId) {
RagResponse response = queryService.query(request.getQuestion(), tenantId);
return ResponseEntity.ok(response);
}
@PostMapping("/query/chat")
public ResponseEntity<RagResponse> queryWithChat(
@RequestBody ChatQueryRequest request,
@RequestHeader("X-Tenant-Id") String tenantId) {
RagResponse response = queryService.queryWithHistory(
request.getQuestion(), request.getSessionId(), tenantId);
return ResponseEntity.ok(response);
}
// 流式输出(Server-Sent Events)
@PostMapping(value = "/query/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> queryStream(
@RequestBody QueryRequest request,
@RequestHeader("X-Tenant-Id") String tenantId) {
return queryService.queryStream(request.getQuestion(), tenantId);
}
}