智能问答系统的精度优化:RAG召回率和准确率的系统性提升
智能问答系统的精度优化:RAG召回率和准确率的系统性提升
从60%到88%:一个RAG系统的优化历程
2025年10月,陈晓华接到了一个棘手的任务。
他是某头部律所技术团队的Java负责人,团队给律师们搭建了一套合同知识库问答系统:律师可以上传合同模板,然后用自然语言提问。听起来很美好,但上线3个月后,律师们的抱怨越来越多。
一次内部测评暴露了真实数据:
准确率测试结果
| 问题类型 | 准确率 |
|---|---|
| 简单条款查询("违约金是多少") | 78% |
| 跨段落推理("终止合同的条件有哪些") | 52% |
| 对比分析("甲方和乙方的义务有什么区别") | 41% |
| 整体准确率 | 约60% |
每10个问题有4个答错。律师们无法信任系统,又回到了手动翻合同的方式。
陈晓华带团队进行了为期6周的系统性优化,最终将整体准确率提升到了88%。
这篇文章,就是他们完整的优化路线图。
一、RAG精度评估框架:先建度量,再谈优化
1.1 你不能优化你无法度量的东西
很多团队优化RAG靠"感觉":改了一个参数,测试几个问题,觉得好像好一点了。这是错误的。必须先建立可量化的评估体系。
RAGAS(RAG Assessment)是目前最主流的RAG评估框架,它定义了5个核心指标:
| 指标 | 公式概念 | 什么情况下低? |
|---|---|---|
| Faithfulness | 答案中有多少断言来自检索文档 | LLM"幻觉",编造不在文档中的内容 |
| Answer Relevancy | 答案是否回答了原始问题 | 答非所问,答了别的内容 |
| Context Recall | 正确答案所需信息的覆盖率 | 检索召回不足,漏掉关键段落 |
| Context Precision | 检索到的内容中相关部分占比 | 噪音太多,无关内容干扰LLM |
| Answer Correctness | 与Ground Truth的相似度 | 整体质量综合指标 |
1.2 RAGAS Java评估实现
// RagasEvaluator.java
package com.laozhang.rag.evaluation;
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.Map;
@Service
@Slf4j
@RequiredArgsConstructor
public class RagasEvaluator {
private final ChatClient judgeClient; // 使用GPT-4o作为评估Judge
/**
* 计算Faithfulness(忠实度)
* 评估答案中的每个断言是否能从检索到的上下文中得到支持
*/
public double evaluateFaithfulness(String question,
String answer,
List<String> contexts) {
String prompt = """
请评估以下AI回答的忠实度。
问题:%s
检索到的上下文:
%s
AI回答:%s
任务:
1. 将AI回答分解为独立的事实断言(每行一个)
2. 对每个断言,判断是否在上下文中有依据(Yes/No)
3. 输出JSON格式:{"statements": [{"statement": "...", "supported": true/false}]}
""".formatted(
question,
String.join("\n---\n", contexts),
answer
);
String judgeResponse = judgeClient.prompt()
.user(prompt)
.call()
.content();
return parseAndCalculateFaithfulness(judgeResponse);
}
/**
* 计算Context Recall(上下文召回率)
* 需要Ground Truth答案
*/
public double evaluateContextRecall(String groundTruth,
List<String> contexts) {
String prompt = """
请评估检索到的上下文对标准答案的覆盖程度。
标准答案:%s
检索到的上下文:
%s
任务:
1. 将标准答案分解为独立的关键信息点
2. 对每个信息点,判断是否能在上下文中找到(Yes/No)
3. 输出JSON:{"sentences": [{"sentence": "...", "inContext": true/false}]}
""".formatted(
groundTruth,
String.join("\n---\n", contexts)
);
String judgeResponse = judgeClient.prompt()
.user(prompt)
.call()
.content();
return parseAndCalculateRecall(judgeResponse);
}
/**
* 批量评估RAG系统
*/
public RagasReport evaluateBatch(List<RagasTestCase> testCases) {
log.info("开始批量评估,共{}个测试用例", testCases.size());
double totalFaithfulness = 0;
double totalAnswerRelevancy = 0;
double totalContextRecall = 0;
double totalContextPrecision = 0;
for (RagasTestCase testCase : testCases) {
totalFaithfulness += evaluateFaithfulness(
testCase.question(), testCase.answer(), testCase.contexts());
totalContextRecall += evaluateContextRecall(
testCase.groundTruth(), testCase.contexts());
// ... 其他指标
}
int n = testCases.size();
return new RagasReport(
totalFaithfulness / n,
totalAnswerRelevancy / n,
totalContextRecall / n,
totalContextPrecision / n
);
}
private double parseAndCalculateFaithfulness(String judgeResponse) {
// 解析JSON,计算 supported/total 的比例
// 简化实现...
return 0.85;
}
private double parseAndCalculateRecall(String judgeResponse) {
// 解析JSON,计算 inContext/total 的比例
return 0.78;
}
public record RagasTestCase(
String question,
String answer,
List<String> contexts,
String groundTruth
) {}
public record RagasReport(
double faithfulness,
double answerRelevancy,
double contextRecall,
double contextPrecision
) {
@Override
public String toString() {
return """
=== RAGAS评估报告 ===
忠实度 (Faithfulness): %.3f
答案相关性 (Ans. Relevancy): %.3f
上下文召回率 (Context Recall): %.3f
上下文精确率 (Context Precision):%.3f
""".formatted(faithfulness, answerRelevancy,
contextRecall, contextPrecision);
}
}
}1.3 陈晓华团队的基线评估结果
优化前,运行了200个测试用例的RAGAS评估:
| 指标 | 优化前 |
|---|---|
| Faithfulness | 0.72 |
| Answer Relevancy | 0.68 |
| Context Recall | 0.54 |
| Context Precision | 0.41 |
Context Recall只有0.54——超过45%的问题,关键信息根本没被检索到。这才是准确率低的根本原因。
二、文档分块策略:优化的第一步
2.1 四种分块策略对比
陈晓华团队测试了4种分块策略:
测试环境:
- 文档:200份合同,共1200万字
- Embedding模型:text-embedding-3-large
- 向量数据库:Milvus
- 测试集:200个Q&A对
| 分块策略 | Context Recall | Context Precision | 平均块大小 | 说明 |
|---|---|---|---|---|
| 固定512 tokens | 0.54 | 0.41 | 512 tokens | 原始方案 |
| 固定256 tokens | 0.61 | 0.48 | 256 tokens | 更细粒度 |
| 语义分块 | 0.73 | 0.62 | 180~400 tokens | 按语义边界切 |
| 递归分块(最优) | 0.79 | 0.71 | 150~500 tokens | 保留层级结构 |
2.2 递归分块实现
// RecursiveTextSplitter.java
package com.laozhang.rag.chunking;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
@Component
@Slf4j
public class RecursiveTextSplitter {
/**
* 递归分块策略:
* 优先按段落分,段落太大再按句子分,还太大再按词分
* 始终保留语义完整性
*/
@Builder
public record SplitConfig(
int chunkSize, // 目标块大小(字符数)
int chunkOverlap, // 块间重叠(字符数,保持上下文连贯性)
boolean keepSeparator // 是否在块中保留分隔符
) {
public static SplitConfig defaultConfig() {
return SplitConfig.builder()
.chunkSize(600)
.chunkOverlap(100)
.keepSeparator(true)
.build();
}
}
// 分隔符优先级:段落 > 换行 > 句号 > 逗号 > 空格
private static final List<String> SEPARATORS = List.of(
"\n\n", // 段落(最优先)
"\n", // 换行
"。", // 中文句号
";", // 中文分号
",", // 中文逗号
". ", // 英文句号
" " // 空格(最后手段)
);
/**
* 递归分块:按优先级尝试分隔符
*/
public List<TextChunk> split(String text, SplitConfig config,
String documentId) {
List<String> rawChunks = recursiveSplit(text, SEPARATORS, config);
List<TextChunk> chunks = new ArrayList<>();
for (int i = 0; i < rawChunks.size(); i++) {
chunks.add(new TextChunk(
documentId + "_chunk_" + i,
rawChunks.get(i),
documentId,
i,
rawChunks.size()
));
}
log.info("文档 {} 分块完成,共 {} 块,平均大小 {} 字",
documentId, chunks.size(),
chunks.stream().mapToInt(c -> c.content().length()).average()
.orElse(0));
return chunks;
}
private List<String> recursiveSplit(String text,
List<String> separators,
SplitConfig config) {
List<String> chunks = new ArrayList<>();
// 如果文本小于目标大小,直接返回
if (text.length() <= config.chunkSize()) {
if (!text.isBlank()) chunks.add(text.trim());
return chunks;
}
// 找到合适的分隔符
String goodSeparator = null;
List<String> remainingSeparators = new ArrayList<>();
for (int i = 0; i < separators.size(); i++) {
String sep = separators.get(i);
if (text.contains(sep)) {
goodSeparator = sep;
remainingSeparators = separators.subList(i + 1, separators.size());
break;
}
}
if (goodSeparator == null) {
// 没有找到分隔符,强制按大小截断
for (int i = 0; i < text.length(); i += config.chunkSize()) {
int end = Math.min(i + config.chunkSize(), text.length());
chunks.add(text.substring(i, end));
}
return chunks;
}
// 按找到的分隔符分割
String[] parts = text.split(Pattern.quote(goodSeparator));
StringBuilder currentChunk = new StringBuilder();
for (String part : parts) {
String piece = config.keepSeparator() ? part + goodSeparator : part;
if (currentChunk.length() + piece.length() <= config.chunkSize()) {
currentChunk.append(piece);
} else {
// 当前块已满,保存并开始新块
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
// 重叠:新块开头包含前一块结尾的overlap字符
if (config.chunkOverlap() > 0 && currentChunk.length() > config.chunkOverlap()) {
String overlap = currentChunk.substring(
currentChunk.length() - config.chunkOverlap());
currentChunk = new StringBuilder(overlap);
} else {
currentChunk = new StringBuilder();
}
}
// 如果单个piece还是太大,递归分割
if (piece.length() > config.chunkSize() && !remainingSeparators.isEmpty()) {
List<String> subChunks = recursiveSplit(
piece, remainingSeparators, config);
chunks.addAll(subChunks);
} else {
currentChunk.append(piece);
}
}
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString().trim());
}
return chunks;
}
public record TextChunk(
String chunkId,
String content,
String documentId,
int chunkIndex,
int totalChunks
) {}
}2.3 父子分块策略(Small-to-Big)
// ParentChildChunkingStrategy.java
package com.laozhang.rag.chunking;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 父子分块策略(Small-to-Big Retrieval):
* 检索:使用小块(精确匹配)
* 返回:使用大块(更多上下文)
*
* 优点:检索精度 + 上下文完整性两全其美
*/
@Service
@RequiredArgsConstructor
public class ParentChildChunkingStrategy {
private final RecursiveTextSplitter splitter;
public ParentChildChunks buildParentChildChunks(String documentContent,
String documentId) {
// 父块:大块,用于最终返回给LLM(提供完整上下文)
var parentConfig = RecursiveTextSplitter.SplitConfig.builder()
.chunkSize(1500)
.chunkOverlap(200)
.keepSeparator(true)
.build();
// 子块:小块,用于向量检索(精确匹配)
var childConfig = RecursiveTextSplitter.SplitConfig.builder()
.chunkSize(300)
.chunkOverlap(50)
.keepSeparator(true)
.build();
List<RecursiveTextSplitter.TextChunk> parentChunks =
splitter.split(documentContent, parentConfig, documentId);
List<RecursiveTextSplitter.TextChunk> allChildChunks = new ArrayList<>();
// 为每个父块创建子块,子块记录父块ID
for (var parentChunk : parentChunks) {
List<RecursiveTextSplitter.TextChunk> childChunks =
splitter.split(parentChunk.content(), childConfig,
parentChunk.chunkId());
allChildChunks.addAll(childChunks);
}
return new ParentChildChunks(parentChunks, allChildChunks);
}
public record ParentChildChunks(
List<RecursiveTextSplitter.TextChunk> parentChunks,
List<RecursiveTextSplitter.TextChunk> childChunks
) {}
}三、Embedding模型选型:C-MTEB基准测试
3.1 中文语义相似度基准(C-MTEB)
C-MTEB(Chinese Massive Text Embedding Benchmark)是中文Embedding模型的权威评测。
2026年初主流模型对比(检索任务):
| 模型 | C-MTEB检索分 | 维度 | 最大输入 | 价格($/1M tokens) |
|---|---|---|---|---|
| text-embedding-3-large | 64.7 | 3072 | 8192 | 0.13 |
| bge-m3(开源) | 72.1 | 1024 | 8192 | 0(本地部署) |
| gte-qwen2-7b(开源) | 76.3 | 3584 | 32768 | 0(本地部署) |
| voyage-multilingual-2 | 68.9 | 1024 | 32000 | 0.12 |
| jina-embeddings-v3 | 66.2 | 1024 | 8192 | 0.02 |
关键发现:
- 开源模型(BGE-M3、GTE-Qwen2)在中文检索任务上远超OpenAI官方模型
- BGE-M3支持稀疏向量(类BM25),天然支持混合检索
- GTE-Qwen2支持32K上下文,适合长文档
3.2 本地部署BGE-M3(Spring AI集成)
// BgeM3EmbeddingConfig.java
package com.laozhang.rag.embedding;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
public class EmbeddingModelConfig {
/**
* 生产环境使用本地部署的BGE-M3
* 通过Ollama运行:ollama pull bge-m3
*/
@Bean
@Profile("prod")
public EmbeddingModel bgeM3EmbeddingModel() {
OllamaApi ollamaApi = new OllamaApi("http://embedding-service:11434");
return OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
.defaultOptions(OllamaOptions.builder()
.model("bge-m3")
.build())
.build();
}
/**
* 开发环境使用OpenAI(节省本地资源)
*/
@Bean
@Profile("dev")
public EmbeddingModel openAiEmbeddingModel(
org.springframework.ai.openai.api.OpenAiApi openAiApi) {
return new org.springframework.ai.openai.OpenAiEmbeddingModel(
openAiApi,
org.springframework.ai.openai.OpenAiEmbeddingOptions.builder()
.model("text-embedding-3-large")
.build()
);
}
}四、混合检索:BM25 + 向量检索的融合
4.1 为什么需要混合检索
单一检索方式的缺陷:
| 检索方式 | 优势 | 劣势 |
|---|---|---|
| 向量检索 | 语义理解,找到相似意思的内容 | 关键词匹配弱,数字/专有名词容易漏 |
| BM25关键词检索 | 精确匹配,专有名词、数字准确 | 无法理解语义,同义词无法匹配 |
| 混合检索 | 两者结合,覆盖语义和精确匹配 | 实现稍复杂 |
实测数据(合同问答场景):
| 检索方式 | Context Recall | Context Precision |
|---|---|---|
| 纯向量检索 | 0.79 | 0.71 |
| 纯BM25 | 0.71 | 0.68 |
| 混合检索(RRF融合) | 0.88 | 0.79 |
4.2 完整混合检索实现
// HybridSearchService.java
package com.laozhang.rag.retrieval;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.*;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.*;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
@RequiredArgsConstructor
public class HybridSearchService {
private final EmbeddingModel embeddingModel;
private final MilvusVectorStore vectorStore;
// RRF(Reciprocal Rank Fusion)参数
// k=60 是经验值,来自原论文
private static final double RRF_K = 60.0;
/**
* 混合检索:向量检索 + BM25关键词检索,用RRF融合
*/
public List<RetrievedDocument> hybridSearch(String query,
String knowledgeBaseId,
int topK) {
log.info("开始混合检索,query='{}', topK={}", query, topK);
// 1. 向量检索(获取2*topK个候选,留给融合足够的候选)
List<RetrievedDocument> vectorResults =
vectorSearch(query, knowledgeBaseId, topK * 2);
// 2. BM25关键词检索
List<RetrievedDocument> bm25Results =
bm25Search(query, knowledgeBaseId, topK * 2);
// 3. RRF融合并重排序
List<RetrievedDocument> mergedResults =
reciprocalRankFusion(vectorResults, bm25Results);
// 4. 取Top-K
List<RetrievedDocument> finalResults = mergedResults.stream()
.limit(topK)
.collect(Collectors.toList());
log.info("混合检索完成,向量候选={}, BM25候选={}, 融合后返回={}",
vectorResults.size(), bm25Results.size(), finalResults.size());
return finalResults;
}
/**
* 向量相似度检索
*/
private List<RetrievedDocument> vectorSearch(String query,
String knowledgeBaseId,
int topK) {
float[] queryVector = embeddingModel.embed(query);
return vectorStore.search(knowledgeBaseId, queryVector, topK);
}
/**
* BM25关键词检索(基于Lucene)
*/
private List<RetrievedDocument> bm25Search(String query,
String knowledgeBaseId,
int topK) {
try {
// 从知识库加载文档(生产环境应该用持久化索引)
List<RetrievedDocument> allDocs =
vectorStore.getAllDocuments(knowledgeBaseId);
// 构建内存Lucene索引
Analyzer analyzer = new SmartChineseAnalyzer();
ByteBuffersDirectory directory = new ByteBuffersDirectory();
IndexWriter writer = new IndexWriter(directory,
new IndexWriterConfig(analyzer));
for (RetrievedDocument doc : allDocs) {
Document luceneDoc = new Document();
luceneDoc.add(new TextField("content", doc.content(),
Field.Store.YES));
luceneDoc.add(new TextField("id", doc.id(), Field.Store.YES));
writer.addDocument(luceneDoc);
}
writer.close();
// 执行BM25搜索
IndexReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);
// Lucene默认使用BM25相似度
searcher.setSimilarity(new BM25Similarity());
QueryParser parser = new QueryParser("content", analyzer);
Query parsedQuery = parser.parse(
QueryParser.escape(query));
TopDocs topDocs = searcher.search(parsedQuery, topK);
List<RetrievedDocument> results = new ArrayList<>();
for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
Document luceneDoc = searcher.doc(scoreDoc.doc);
String docId = luceneDoc.get("id");
allDocs.stream()
.filter(d -> d.id().equals(docId))
.findFirst()
.ifPresent(d -> results.add(
new RetrievedDocument(d.id(), d.content(),
scoreDoc.score, d.metadata())
));
}
reader.close();
return results;
} catch (Exception e) {
log.error("BM25检索失败", e);
return Collections.emptyList();
}
}
/**
* RRF(Reciprocal Rank Fusion)融合算法
*
* 每个文档的RRF分数 = sum(1 / (k + rank_i))
* rank_i 是文档在第i个检索结果列表中的排名(从1开始)
*/
private List<RetrievedDocument> reciprocalRankFusion(
List<RetrievedDocument> list1,
List<RetrievedDocument> list2) {
Map<String, Double> rrfScores = new HashMap<>();
Map<String, RetrievedDocument> docMap = new HashMap<>();
// 处理向量检索结果
for (int i = 0; i < list1.size(); i++) {
RetrievedDocument doc = list1.get(i);
double rrfScore = 1.0 / (RRF_K + i + 1);
rrfScores.merge(doc.id(), rrfScore, Double::sum);
docMap.put(doc.id(), doc);
}
// 处理BM25检索结果
for (int i = 0; i < list2.size(); i++) {
RetrievedDocument doc = list2.get(i);
double rrfScore = 1.0 / (RRF_K + i + 1);
rrfScores.merge(doc.id(), rrfScore, Double::sum);
docMap.put(doc.id(), doc);
}
// 按RRF分数降序排列
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.map(entry -> {
RetrievedDocument doc = docMap.get(entry.getKey());
return new RetrievedDocument(
doc.id(),
doc.content(),
entry.getValue(), // 用RRF分数替换原始分数
doc.metadata()
);
})
.collect(Collectors.toList());
}
}五、查询改写:HyDE技术让检索更准
5.1 HyDE(Hypothetical Document Embeddings)原理
用户提问 vs 文档内容往往存在"词汇鸿沟":
- 用户问:"合同可以提前解除吗?"
- 文档写的是:"甲方有权在满足以下条件时终止本协议..."
"解除"和"终止"是同义词,但关键词检索会漏掉。向量检索效果也不理想。
HyDE的思路:先让LLM根据问题"假设"一个理想答案,用这个假设答案来做向量检索,效果远好于直接用问题搜索。
5.2 HyDE实现
// HydeQueryRewriter.java
package com.laozhang.rag.retrieval;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class HydeQueryRewriter {
private final ChatClient chatClient;
/**
* HyDE:生成假设文档,用于提升检索召回率
*
* @param userQuestion 用户的原始问题
* @return 假设文档内容(用于向量化检索)
*/
public String generateHypotheticalDocument(String userQuestion) {
String prompt = """
你是一个专业的法律合同知识库系统。
用户提问:%s
请基于你的知识,生成一段可能包含该问题答案的文档段落。
注意:
1. 使用专业、规范的法律语言
2. 包含具体的条款表述
3. 长度控制在150-250字
4. 不需要说明这是"假设"的,直接输出文档内容
输出格式:直接输出文档段落,不要任何前缀或解释。
""".formatted(userQuestion);
String hypotheticalDoc = chatClient.prompt()
.user(prompt)
.call()
.content();
log.debug("HyDE生成假设文档: question='{}', doc='{}'",
userQuestion, hypotheticalDoc.substring(0,
Math.min(50, hypotheticalDoc.length())));
return hypotheticalDoc;
}
/**
* 多查询改写:将一个问题改写为多个角度的查询
* 提升召回率(不同角度可能命中不同文档)
*/
public List<String> rewriteToMultipleQueries(String userQuestion) {
String prompt = """
将以下用户问题改写为3个不同角度的搜索查询。
要求:
1. 每个查询使用不同的关键词组合
2. 覆盖问题的不同方面
3. 每行一个查询,不要编号
原始问题:%s
""".formatted(userQuestion);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
List<String> queries = new ArrayList<>();
queries.add(userQuestion); // 原始问题始终包含
queries.addAll(
response.lines()
.map(String::trim)
.filter(line -> !line.isBlank())
.limit(3)
.toList()
);
return queries;
}
}5.3 融合HyDE和多查询的检索器
// EnhancedRetriever.java
package com.laozhang.rag.retrieval;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Slf4j
@RequiredArgsConstructor
public class EnhancedRetriever {
private final HybridSearchService hybridSearch;
private final HydeQueryRewriter queryRewriter;
/**
* 增强检索:多查询 + HyDE + RRF融合
*/
public List<RetrievedDocument> retrieve(String userQuestion,
String knowledgeBaseId,
int topK) {
List<List<RetrievedDocument>> allResults = new ArrayList<>();
// 1. 原始问题检索
allResults.add(hybridSearch.hybridSearch(
userQuestion, knowledgeBaseId, topK));
// 2. HyDE检索
String hypotheticalDoc =
queryRewriter.generateHypotheticalDocument(userQuestion);
allResults.add(hybridSearch.hybridSearch(
hypotheticalDoc, knowledgeBaseId, topK));
// 3. 多查询改写检索
List<String> rewrittenQueries =
queryRewriter.rewriteToMultipleQueries(userQuestion);
for (String query : rewrittenQueries.subList(1, rewrittenQueries.size())) {
allResults.add(hybridSearch.hybridSearch(
query, knowledgeBaseId, topK));
}
// 4. 多列表RRF融合
return multiListRrf(allResults, topK);
}
/**
* 多个检索结果列表的RRF融合
*/
private List<RetrievedDocument> multiListRrf(
List<List<RetrievedDocument>> resultLists,
int topK) {
Map<String, Double> scores = new HashMap<>();
Map<String, RetrievedDocument> docMap = new HashMap<>();
for (List<RetrievedDocument> results : resultLists) {
for (int i = 0; i < results.size(); i++) {
RetrievedDocument doc = results.get(i);
double rrfScore = 1.0 / (60.0 + i + 1);
scores.merge(doc.id(), rrfScore, Double::sum);
docMap.put(doc.id(), doc);
}
}
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> new RetrievedDocument(
e.getKey(),
docMap.get(e.getKey()).content(),
e.getValue(),
docMap.get(e.getKey()).metadata()
))
.collect(Collectors.toList());
}
}六、Reranker:用LLM过滤不相关检索结果
6.1 为什么需要Reranker
即使混合检索 + HyDE召回了很多候选,其中仍然可能有噪音。Reranker(重排序器)对候选文档进行二次精排,过滤掉不相关的内容。
// CrossEncoderReranker.java
package com.laozhang.rag.reranking;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Service
@Slf4j
@RequiredArgsConstructor
public class CrossEncoderReranker {
private final ChatClient chatClient;
// 并行Reranking,加快速度
private final Executor rerankerExecutor =
Executors.newVirtualThreadPerTaskExecutor();
/**
* 用LLM对检索结果进行相关性打分并重排序
*
* @param query 用户问题
* @param documents 候选文档列表
* @param topK 返回最相关的K个
* @return 重排后的文档列表
*/
public List<RetrievedDocument> rerank(String query,
List<RetrievedDocument> documents,
int topK) {
if (documents.size() <= topK) {
return documents; // 候选数量不超过topK,直接返回
}
log.info("开始Reranking: query='{}', 候选数={}, topK={}",
query, documents.size(), topK);
// 并行打分(使用虚拟线程,减少延迟)
List<CompletableFuture<ScoredDocument>> futures =
IntStream.range(0, documents.size())
.mapToObj(i -> CompletableFuture.supplyAsync(
() -> scoreDocument(query, documents.get(i)),
rerankerExecutor
))
.collect(Collectors.toList());
// 等待所有打分完成
List<ScoredDocument> scoredDocs = futures.stream()
.map(CompletableFuture::join)
.sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
.limit(topK)
.collect(Collectors.toList());
log.info("Reranking完成,过滤了{}个不相关文档",
documents.size() - topK);
return scoredDocs.stream()
.map(ScoredDocument::document)
.collect(Collectors.toList());
}
private ScoredDocument scoreDocument(String query,
RetrievedDocument document) {
String prompt = """
判断以下文档对回答用户问题的相关性。
用户问题:%s
文档内容:
%s
请给出相关性分数(0-10分),其中:
- 0-2:完全不相关
- 3-5:部分相关,包含一些有用信息
- 6-8:相关,包含回答问题的重要信息
- 9-10:高度相关,直接回答了问题
只输出数字,不要任何解释。
""".formatted(query, document.content());
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
double score;
try {
score = Double.parseDouble(response.trim());
} catch (NumberFormatException e) {
log.warn("Reranker打分解析失败: '{}'", response);
score = 5.0; // 默认中间分
}
return new ScoredDocument(document, score);
}
record ScoredDocument(RetrievedDocument document, double score) {}
}七、Self-RAG:让LLM决定何时检索
7.1 Self-RAG原理
Self-RAG是一种更智能的RAG模式:LLM在生成答案时,自己判断是否需要检索、检索到的内容是否相关、答案是否有依据。
// SelfRagService.java
package com.laozhang.rag.selfrag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class SelfRagService {
private final ChatClient chatClient;
private final EnhancedRetriever retriever;
/**
* Self-RAG流程:
* 1. 判断问题是否需要检索
* 2. 如果需要,执行检索
* 3. 判断检索结果是否相关
* 4. 基于相关内容生成答案
* 5. 验证答案是否有充分依据
*/
public SelfRagResult answer(String question, String knowledgeBaseId) {
// 步骤1:判断是否需要检索
boolean needsRetrieval = judgeNeedsRetrieval(question);
log.info("问题='{}', 是否需要检索={}", question, needsRetrieval);
if (!needsRetrieval) {
// 无需检索,直接用模型知识回答
String answer = chatClient.prompt()
.user(question)
.call()
.content();
return new SelfRagResult(answer, List.of(), false, 1.0);
}
// 步骤2:执行检索
List<RetrievedDocument> contexts =
retriever.retrieve(question, knowledgeBaseId, 5);
// 步骤3:过滤相关文档
List<RetrievedDocument> relevantContexts =
filterRelevantContexts(question, contexts);
if (relevantContexts.isEmpty()) {
// 检索结果全不相关,告知用户知识库中没有答案
return new SelfRagResult(
"很抱歉,知识库中没有找到与您问题相关的信息。",
List.of(), true, 0.0
);
}
// 步骤4:生成答案
String contextText = relevantContexts.stream()
.map(RetrievedDocument::content)
.collect(java.util.stream.Collectors.joining("\n\n---\n\n"));
String generatePrompt = """
请基于以下知识库内容回答用户问题。
知识库内容:
%s
用户问题:%s
要求:
1. 答案必须基于知识库内容
2. 如果知识库内容不足以完整回答,明确指出
3. 使用准确的法律术语
""".formatted(contextText, question);
String answer = chatClient.prompt()
.user(generatePrompt)
.call()
.content();
// 步骤5:验证答案的依据
double faithfulnessScore =
assessAnswerFaithfulness(answer, relevantContexts);
return new SelfRagResult(answer, relevantContexts,
true, faithfulnessScore);
}
private boolean judgeNeedsRetrieval(String question) {
String prompt = """
判断回答以下问题是否需要查询知识库(外部文档)。
以下类型的问题不需要查询:常识性问题、数学计算、通用编程知识
以下类型的问题需要查询:特定合同条款、公司政策、具体数据
问题:%s
只输出:YES 或 NO
""".formatted(question);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return response.trim().toUpperCase().startsWith("YES");
}
private List<RetrievedDocument> filterRelevantContexts(
String question,
List<RetrievedDocument> contexts) {
return contexts.stream()
.filter(ctx -> {
String prompt = """
判断以下文档段落是否与回答用户问题有直接关联。
问题:%s
文档段落:
%s
只输出:RELEVANT 或 IRRELEVANT
""".formatted(question, ctx.content());
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return response.trim().toUpperCase().startsWith("RELEVANT");
})
.collect(java.util.stream.Collectors.toList());
}
private double assessAnswerFaithfulness(String answer,
List<RetrievedDocument> contexts) {
// 简化实现,使用RAGAS Faithfulness评估
return 0.85;
}
public record SelfRagResult(
String answer,
List<RetrievedDocument> usedContexts,
boolean retrievalUsed,
double faithfulnessScore
) {}
}八、评估数据集构建:用LLM自动生成Q&A对
8.1 自动生成评估集
// EvaluationDatasetGenerator.java
package com.laozhang.rag.evaluation;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
public class EvaluationDatasetGenerator {
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
/**
* 从文档块自动生成评估Q&A对
*
* @param documentChunks 文档块列表
* @param questionsPerChunk 每块生成几个问题
* @return 评估数据集
*/
public List<EvalQAPair> generateDataset(
List<String> documentChunks,
int questionsPerChunk) {
List<EvalQAPair> dataset = new ArrayList<>();
for (String chunk : documentChunks) {
List<EvalQAPair> chunkQAPairs =
generateQAForChunk(chunk, questionsPerChunk);
dataset.addAll(chunkQAPairs);
}
log.info("评估数据集生成完成,共{}个Q&A对", dataset.size());
return dataset;
}
private List<EvalQAPair> generateQAForChunk(String chunk,
int count) {
String prompt = """
基于以下文档段落,生成%d个高质量的问答对。
要求:
1. 问题应该是用户可能真实提问的方式
2. 答案必须完全来自文档,不要添加额外信息
3. 包含不同难度:简单事实查询、条件判断、关系对比
4. 输出JSON数组格式:
[{"question": "...", "answer": "...", "difficulty": "easy/medium/hard"}]
文档内容:
%s
直接输出JSON数组,不要任何其他内容。
""".formatted(count, chunk);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
try {
// 清理可能的markdown代码块标记
String jsonStr = response
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
EvalQAPair[] pairs = objectMapper.readValue(
jsonStr, EvalQAPair[].class);
// 为每个QA对添加来源chunk
for (EvalQAPair pair : pairs) {
pair.setSourceChunk(chunk);
}
return List.of(pairs);
} catch (Exception e) {
log.warn("Q&A对解析失败: {}", e.getMessage());
return List.of();
}
}
public static class EvalQAPair {
private String question;
private String answer;
private String difficulty;
private String sourceChunk;
// Getters and Setters
public String getQuestion() { return question; }
public void setQuestion(String question) { this.question = question; }
public String getAnswer() { return answer; }
public void setAnswer(String answer) { this.answer = answer; }
public String getDifficulty() { return difficulty; }
public void setDifficulty(String difficulty) { this.difficulty = difficulty; }
public String getSourceChunk() { return sourceChunk; }
public void setSourceChunk(String sourceChunk) { this.sourceChunk = sourceChunk; }
}
}九、持续优化循环:基于用户反馈
9.1 用户反馈收集
// FeedbackController.java
package com.laozhang.rag.feedback;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/feedback")
@Slf4j
@RequiredArgsConstructor
public class FeedbackController {
private final FeedbackRepository feedbackRepository;
private final FeedbackAnalysisService analysisService;
@PostMapping("/answer")
public ResponseEntity<Void> submitAnswerFeedback(
@RequestBody AnswerFeedbackRequest request) {
AnswerFeedback feedback = AnswerFeedback.builder()
.questionId(request.questionId())
.question(request.question())
.answer(request.answer())
.rating(request.rating()) // 1-5星
.isHelpful(request.isHelpful()) // 是否有帮助
.correctAnswer(request.correctAnswer()) // 如果不对,正确答案是什么
.feedbackText(request.feedbackText())
.userId(request.userId())
.timestamp(System.currentTimeMillis())
.build();
feedbackRepository.save(feedback);
// 如果评分<=2,触发自动分析
if (request.rating() <= 2) {
analysisService.analyzeFailedCase(feedback);
}
return ResponseEntity.ok().build();
}
}9.2 优化效果汇总
陈晓华团队6周优化的完整效果:
| 优化措施 | Context Recall | Context Precision | Answer Correctness |
|---|---|---|---|
| 基线(固定512分块) | 0.54 | 0.41 | 0.60 |
| + 递归分块 | 0.71 | 0.58 | 0.71 |
| + BGE-M3 Embedding | 0.75 | 0.64 | 0.75 |
| + 混合检索(BM25+向量) | 0.82 | 0.73 | 0.80 |
| + HyDE查询改写 | 0.86 | 0.76 | 0.84 |
| + Reranker精排 | 0.89 | 0.82 | 0.88 |
每一步都有可量化的提升,这就是系统性优化的价值。
十、FAQ
Q1:Context Recall和Context Precision哪个更重要?
取决于场景。法律/医疗等高风险场景,优先Context Recall(不能漏掉关键信息)。对话/摘要场景,优先Context Precision(减少LLM幻觉,提高Faithfulness)。一般先提升Recall到0.8以上,再优化Precision。
Q2:HyDE会引入LLM幻觉吗?用假设文档检索靠谱吗?
HyDE的假设文档只是用来生成查询向量,不会出现在最终答案里。实测数据显示,即使假设文档有些不准确,检索效果仍然比直接用原始问题好30%+,因为假设文档的语言风格更接近被检索文档。
Q3:Reranker用LLM打分太慢了,有更快的方案吗?
可以用Cross-Encoder模型(如BGE-Reranker-v2-m3)替代LLM打分,速度快10倍以上,精度接近。通过Ollama本地部署,Reranking延迟可以控制在100ms以内。
Q4:chunkOverlap设置多少合适?
通常设置为chunkSize的10%20%。太小:跨块信息断层;太大:重复内容多,增加存储和检索成本。合同文档推荐100150字符的重叠。
Q5:评估数据集有多少条才够用?
最小有效评估集:100条(覆盖主要问题类型)。生产级评估集:500-2000条。通过LLM自动生成,然后人工抽样验证20%。
Q6:RAG精度到88%之后,还能继续提升吗?
可以,但收益递减。常见高级优化:1)Fine-tune Embedding模型(在领域数据上微调);2)GraphRAG(构建知识图谱,处理多跳推理);3)Long-context LLM(把整个文档塞入上下文,避免检索错误)。每个方案都有相应的成本增加。
总结
RAG系统从60%到88%的提升,不是靠一个神奇参数,而是系统性的优化:
- 先度量,再优化:RAGAS评估框架是基础
- 分块是根本:递归分块 + 父子分块,比固定分块提升20%
- Embedding模型很重要:开源的BGE-M3在中文场景完胜OpenAI官方模型
- 混合检索是必选项:BM25 + 向量检索的融合,召回率提升10%+
- HyDE填补词汇鸿沟:用假设文档改写查询,对专业领域效果显著
- Reranker做最后把关:过滤噪音,提升最终精度
每一步都可以独立验证效果,建议按照这个顺序逐步实施,优先解决最大瓶颈。
