第2128篇:RAG召回率优化的工程实战——当"检索不到"成为系统瓶颈
大约 8 分钟
第2128篇:RAG召回率优化的工程实战——当"检索不到"成为系统瓶颈
适读人群:负责RAG系统性能优化的工程师 | 阅读时长:约20分钟 | 核心价值:系统性地诊断和提升RAG检索召回率,从多角度解决"知识库里有答案但检索不到"的问题
"我们知识库里明明有这个答案,AI就是检索不到,然后说不知道。"
这是RAG系统最让人沮丧的问题。知识库建好了,文档都导进去了,但用户问的时候AI回答"根据现有资料无法回答"。然后你手动去知识库搜,那个答案就在那里躺着。
这种"有知识但检索不到"的问题,技术术语叫召回率不足(Low Recall)。解决方案不是一个,而是一套诊断和优化的方法论。
诊断:召回率问题的根本原因
/**
* 召回率问题的四类根本原因
*
* ===== 原因一:查询和文档的表达方式不匹配 =====
*
* 例:
* 用户问:"怎么开发票"
* 文档标题:"发票申请操作指南"
*
* 向量空间里,"开发票"和"发票申请"语义相近,
* 但如果Embedding模型质量不够,可能相似度不高
*
* 解决:查询扩展、同义词增强
*
* ===== 原因二:文档分块切割了关键信息 =====
*
* 例:
* 问题答案在文档第3页,但前提条件在第2页
* 如果按固定长度分块,条件和答案可能在不同块里
* 检索时只拿到了答案块,但缺少条件,导致回答不完整
*
* 解决:语义分块、父子块检索
*
* ===== 原因三:检索阈值设置不当 =====
*
* 例:
* 相似度阈值设置为0.8
* 但目标文档和查询的相似度只有0.75
* 被过滤掉了
*
* 解决:动态阈值、定期评估和调整
*
* ===== 原因四:向量化质量不足 =====
*
* 例:
* 使用英文Embedding模型处理中文文档
* 中文语义在向量空间中表达不准确
*
* 解决:换用中文专用或多语言Embedding模型
*/查询扩展(Query Expansion)
/**
* 查询扩展服务
*
* 思路:用户的原始查询可能表达不完整或用了不同术语
* 扩展成多种表达方式,分别检索,取并集
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryExpansionService {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
/**
* 扩展查询并检索
*
* 策略:
* 1. HyDE(Hypothetical Document Embeddings):让LLM生成一个假设性答案,用答案向量检索
* 2. 同义词扩展:生成查询的多种表达方式
* 3. 分解查询:把复合问题分解成多个子查询
*/
public List<Document> expandAndRetrieve(String originalQuery, int topK) {
// 同时运行多种扩展策略
List<String> expandedQueries = new ArrayList<>();
expandedQueries.add(originalQuery); // 原始查询
// 策略1:HyDE - 生成假设性回答
String hydeExpansion = generateHyDE(originalQuery);
if (hydeExpansion != null) expandedQueries.add(hydeExpansion);
// 策略2:同义词/改写扩展
List<String> paraphrases = generateParaphrases(originalQuery);
expandedQueries.addAll(paraphrases);
// 对所有扩展查询进行检索
Set<String> seenDocIds = new HashSet<>();
List<Document> results = new ArrayList<>();
for (String query : expandedQueries) {
float[] queryVector = embeddingModel.embed(query).content().vector();
List<VectorStore.SearchResult> hits = vectorStore.search(queryVector, topK, null);
for (VectorStore.SearchResult hit : hits) {
if (!seenDocIds.contains(hit.getId())) {
seenDocIds.add(hit.getId());
results.add(Document.from(hit.getContent(),
Map.of("score", hit.getScore(), "sourceQuery", query)));
}
}
}
// 按最高相似度排序
results.sort((a, b) -> Double.compare(
(double) b.metadata().get("score"),
(double) a.metadata().get("score")
));
return results.stream().limit(topK).toList();
}
/**
* HyDE:Hypothetical Document Embeddings
*
* 核心思路:
* 用LLM生成一个"假设的答案",对答案做向量检索
* 答案的向量空间表示往往比问题更接近实际文档
*
* 例:
* 问题:"开发票需要什么材料"
* HyDE生成的假设答案:
* "开具发票需要提供以下材料:营业执照副本、税务登记证..."
* 这个假设答案和实际文档在向量空间里更接近
*/
private String generateHyDE(String query) {
String prompt = """
请根据以下问题,生成一个简短的假设性回答(100-200字)。
注意:这是用于检索的辅助文本,可以是不完整的答案,
但要覆盖问题相关的关键词和概念。
问题:%s
假设性回答:
""".formatted(query);
try {
return llm.generate(prompt);
} catch (Exception e) {
log.warn("HyDE生成失败: {}", e.getMessage());
return null;
}
}
/**
* 生成查询的多种表达方式
*/
private List<String> generateParaphrases(String query) {
String prompt = """
请为以下问题生成3种不同的表达方式,覆盖不同的同义词和表达角度。
每行一种,只输出改写后的问题,不要编号或解释。
原始问题:%s
改写:
""".formatted(query);
try {
String response = llm.generate(prompt);
return Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty() && !s.equals(query))
.limit(3)
.toList();
} catch (Exception e) {
log.warn("查询改写失败: {}", e.getMessage());
return List.of();
}
}
/**
* 分解复合查询
*
* 用户问:A、B、C三种产品哪个更适合小企业?
* 分解为:
* - A产品的适用场景
* - B产品的适用场景
* - C产品的适用场景
* - 小企业选产品的标准
*/
public List<String> decomposeQuery(String complexQuery) {
String prompt = """
如果以下问题包含多个子问题,请分解成独立的子问题。
如果是简单问题,直接返回原问题。
每行一个子问题,不超过4个。
问题:%s
子问题(每行一个):
""".formatted(complexQuery);
try {
String response = llm.generate(prompt);
List<String> subQueries = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(4)
.toList();
return subQueries.isEmpty() ? List.of(complexQuery) : subQueries;
} catch (Exception e) {
return List.of(complexQuery);
}
}
}父子块检索(Parent-Child Chunk)
/**
* 父子块检索策略
*
* 问题:小块(200 tokens)用于精确检索,但缺少上下文
* 大块(1000 tokens)包含完整上下文,但检索精度低
*
* 解决方案:
* - 用小块(child chunk)做向量检索(精度高)
* - 检索到小块后,返回其父块(large chunk)给LLM(上下文完整)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ParentChildChunkingService {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
private final DocumentChunkRepository chunkRepo;
/**
* 构建父子块索引
*
* parent chunk: 500-1000 tokens,包含完整语义单元
* child chunk: 100-200 tokens,用于精确检索
*/
public void indexDocument(String docId, String content) {
// 1. 创建父块(按语义段落分割,500 tokens左右)
List<ParentChunk> parentChunks = splitIntoParentChunks(docId, content, 500);
for (ParentChunk parent : parentChunks) {
// 2. 把父块内容分成更小的子块(100-150 tokens)
List<ChildChunk> childChunks = splitIntoChildChunks(parent);
// 3. 存储父块到数据库(不放到向量库)
chunkRepo.saveParentChunk(parent);
// 4. 向量化子块并存入向量库(附上parent_id)
for (ChildChunk child : childChunks) {
float[] vector = embeddingModel.embed(child.content()).content().vector();
vectorStore.add(VectorStore.Document.builder()
.id(child.chunkId())
.content(child.content())
.vector(vector)
.metadata(Map.of(
"parent_id", parent.chunkId(),
"doc_id", docId
))
.build());
}
}
log.info("文档索引完成: docId={}, parentChunks={}", docId, parentChunks.size());
}
/**
* 父子块检索
*
* 用小块检索,返回父块内容
*/
public List<String> retrieve(String query, int topK) {
float[] queryVector = embeddingModel.embed(query).content().vector();
// 检索相似的子块
List<VectorStore.SearchResult> childHits = vectorStore.search(queryVector, topK * 2, null);
// 收集唯一的父块ID
Set<String> parentIds = new LinkedHashSet<>();
for (VectorStore.SearchResult child : childHits) {
String parentId = (String) child.getMetadata().get("parent_id");
if (parentId != null) {
parentIds.add(parentId);
if (parentIds.size() >= topK) break;
}
}
// 返回父块内容(包含更完整的上下文)
return parentIds.stream()
.map(parentId -> chunkRepo.getParentChunkContent(parentId))
.filter(Objects::nonNull)
.toList();
}
private List<ParentChunk> splitIntoParentChunks(
String docId, String content, int targetTokens) {
// 按段落或语义边界分割
// 实现细节略(参考文档分块策略相关文章)
return List.of();
}
private List<ChildChunk> splitIntoChildChunks(ParentChunk parent) {
// 把父块切成更小的子块
return List.of();
}
record ParentChunk(String chunkId, String docId, String content, int position) {}
record ChildChunk(String chunkId, String parentId, String content) {}
}召回率评估与监控
/**
* 召回率评估服务
*
* 定期自动评估RAG系统的检索质量
* 发现下滑时及时告警
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RetrievalQualityMonitor {
private final QueryExpansionService queryExpansion;
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
private final NotificationService notificationService;
/**
* 每天运行评估
*/
@Scheduled(cron = "0 30 2 * * *") // 每天凌晨2:30
public void dailyEvaluation() {
// 加载评估数据集(定期由知识管理团队维护)
List<EvaluationCase> cases = loadEvaluationCases();
if (cases.isEmpty()) {
log.warn("评估数据集为空,跳过评估");
return;
}
EvaluationReport report = runEvaluation(cases);
log.info("召回率评估报告: recall@5={:.3f}, recall@10={:.3f}",
report.recall5(), report.recall10());
// 如果召回率下降超过5%,发告警
double previousRecall5 = loadPreviousRecall5();
if (previousRecall5 > 0 && report.recall5() < previousRecall5 - 0.05) {
notificationService.sendAlert(
"RAG检索质量下降",
String.format("Recall@5从%.3f下降到%.3f,请检查Embedding模型或知识库变化",
previousRecall5, report.recall5())
);
}
saveReport(report);
}
public EvaluationReport runEvaluation(List<EvaluationCase> cases) {
int hit5 = 0, hit10 = 0;
for (EvaluationCase testCase : cases) {
float[] queryVector = embeddingModel.embed(testCase.query()).content().vector();
List<VectorStore.SearchResult> results = vectorStore.search(queryVector, 10, null);
List<String> topIds = results.stream()
.map(VectorStore.SearchResult::getId)
.toList();
Set<String> expected = new HashSet<>(testCase.expectedDocIds());
boolean foundIn5 = topIds.subList(0, Math.min(5, topIds.size()))
.stream().anyMatch(expected::contains);
boolean foundIn10 = topIds.stream().anyMatch(expected::contains);
if (foundIn5) hit5++;
if (foundIn10) hit10++;
}
return new EvaluationReport(
LocalDateTime.now(),
cases.size(),
(double) hit5 / cases.size(),
(double) hit10 / cases.size()
);
}
private List<EvaluationCase> loadEvaluationCases() {
// 从数据库加载测试用例
return List.of();
}
private double loadPreviousRecall5() {
// 从历史记录加载上次的指标
return 0;
}
private void saveReport(EvaluationReport report) {
// 保存报告到数据库
}
record EvaluationCase(String query, List<String> expectedDocIds) {}
record EvaluationReport(LocalDateTime timestamp, int totalCases,
double recall5, double recall10) {}
}实践建议
HyDE是提升召回率性价比最高的技术
在我测试过的几个中文知识库场景里,单独使用HyDE(不增加其他改动)能把Recall@5从0.65提升到0.78左右,提升幅度约20%。这是因为用户的问题和文档的表述方式经常差距很大,而HyDE生成的假设答案和文档的表述方式更接近。成本:每次检索多一次LLM调用(用mini模型约0.001元),对大多数应用来说可以接受。
父子块策略解决的是"有正确块但上下文不够"的问题
父子块不是万灵药,它解决的是特定问题:小块精确匹配但缺少上下文。如果你的问题是"根本找不到相关块",父子块没有帮助。诊断方式:把检索到的块直接展示出来,看看是"找错块了"还是"块找对了但内容不完整"。前者需要查询扩展,后者需要父子块。
召回率评估要定期做,不是一次性工作
知识库在变(新增文档、修改文档),Embedding模型可能在更新,用户的查询方式在变化。如果只在上线前评估一次,三个月后知识库已经变了很多,但你不知道召回率是否还正常。建立定期评估(每周或每天),用固定的评估数据集,才能及时发现退化。维护一个100-200条的"黄金评估集",是RAG系统长期健康运行的基础投资。
