第1947篇:检索增强生成的下一代——从RAG到DRAG再到Agent-RAG的演进
第1947篇:检索增强生成的下一代——从RAG到DRAG再到Agent-RAG的演进
RAG这个词现在被说烂了,但很多人其实对它的理解停留在两年前的水平:把文档切成块,存向量数据库,查询时检索最相关的几块,拼到prompt里。这个"朴素RAG"确实能用,但在生产环境里问题一堆。
今天从问题出发,一路讲到目前比较成熟的下一代RAG方案。
朴素RAG的问题在哪里
先说清楚朴素RAG(Naive RAG)在实际应用中的典型失败模式,这样后面讲改进方案才有意义。
检索质量问题:向量相似度检索的本质是语义接近,但语义接近不等于信息相关。"苹果手机的价格"和"苹果公司的股价"语义很接近,但是完全不同的信息。在金融、医疗这类专业领域,这个问题尤其严重。
切块边界问题:把文档切成固定大小的chunk,往往把关键的上下文割断。一个问题的答案可能横跨两个chunk,但两个chunk各自检索出来都不够完整。
多文档推理问题:有些问题需要综合多个文档的信息才能回答,比如"比较A产品和B产品的差异"。朴素RAG的检索是独立的,没有能力做跨文档的推理。
知识过时问题:文档库更新后,旧的相关文档没有被删除,模型会从新旧文档中得到矛盾信息,然后给出混乱的答案。
回答过度依赖检索问题:有时候模型已经知道答案,但检索到了不相关的内容,导致模型被"带偏",给出不如直接回答质量高的答案。
DRAG(Dense Retrieval Augmented Generation)
DRAG在检索阶段做了重要升级,核心是用bi-encoder + cross-encoder两阶段检索替代单阶段向量检索。
bi-encoder(双塔模型)效率高,适合从大量文档里快速召回候选;cross-encoder把查询和文档一起输入,能更精准地判断相关性,但速度慢,只用于对候选集精排。
Java实现这套两阶段检索:
@Service
public class DragRetrievalService {
@Autowired
private VectorStore vectorStore; // 用于bi-encoder检索
@Autowired
private CrossEncoderService crossEncoder; // 精排服务
/**
* 两阶段检索
* @param query 用户查询
* @param topK 粗召回数量
* @param topN 精排后返回数量
*/
public List<Document> retrieve(String query, int topK, int topN) {
// 阶段一:向量粗召回
List<Document> candidates = vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.5) // 过滤掉极低相似度
);
if (candidates.isEmpty()) {
return Collections.emptyList();
}
// 阶段二:Cross-encoder精排
List<ScoredDocument> reranked = crossEncoder.rerank(query, candidates);
// 返回精排后的top-N
return reranked.stream()
.sorted(Comparator.comparingDouble(ScoredDocument::getScore).reversed())
.limit(topN)
.map(ScoredDocument::getDocument)
.collect(Collectors.toList());
}
}Cross-encoder服务(调用本地的reranking模型,如BGE-Reranker):
@Service
public class CrossEncoderService {
private final RestTemplate restTemplate;
// 本地部署的reranker模型服务,如bge-reranker-v2-m3
@Value("${reranker.base-url:http://localhost:8001}")
private String rerankerBaseUrl;
public List<ScoredDocument> rerank(String query, List<Document> documents) {
// 构建rerank请求
RerankRequest request = new RerankRequest();
request.setQuery(query);
request.setDocuments(documents.stream()
.map(Document::getContent)
.collect(Collectors.toList()));
request.setTopN(documents.size()); // 对全部候选打分
RerankResponse response = restTemplate.postForObject(
rerankerBaseUrl + "/rerank",
request,
RerankResponse.class
);
// 合并原始文档和分数
return response.getResults().stream()
.map(result -> new ScoredDocument(
documents.get(result.getIndex()),
result.getRelevanceScore()
))
.collect(Collectors.toList());
}
}分层索引(Hierarchical Indexing)
解决切块边界问题的一个有效方案是分层索引:同一内容建立多个粒度的索引。
@Service
public class HierarchicalIndexer {
@Autowired
private VectorStore vectorStore;
@Autowired
private TextSplitter sentenceSplitter;
@Autowired
private TextSplitter paragraphSplitter;
/**
* 分层索引:文档级别、段落级别、句子级别
* 检索时用细粒度找到位置,用粗粒度提供上下文
*/
public void indexDocument(String documentId, String content) {
// 句子级别:用于精确匹配
List<String> sentences = sentenceSplitter.split(content);
List<Document> sentenceDocs = buildDocuments(sentences, documentId, "sentence");
// 段落级别:提供上下文
List<String> paragraphs = paragraphSplitter.split(content);
List<Document> paragraphDocs = buildDocuments(paragraphs, documentId, "paragraph");
// 文档级别:全局摘要
String summary = generateSummary(content);
Document summaryDoc = buildSummaryDocument(summary, documentId);
// 全部写入向量库,通过metadata区分层级
vectorStore.add(sentenceDocs);
vectorStore.add(paragraphDocs);
vectorStore.add(List.of(summaryDoc));
}
/**
* 小-大检索(Small-to-Big Retrieval)
* 用小块找到相关位置,返回对应的大块
*/
public List<Document> retrieveWithContext(String query, int topK) {
// 在句子级别检索
List<Document> sentenceMatches = vectorStore.similaritySearch(
SearchRequest.query(query)
.withTopK(topK)
.withFilterExpression("level == 'sentence'")
);
// 找到对应的段落块作为上下文
return sentenceMatches.stream()
.map(sentence -> findParentParagraph(sentence))
.distinct()
.collect(Collectors.toList());
}
private Document findParentParagraph(Document sentence) {
String docId = sentence.getMetadata().get("documentId").toString();
int sentenceIndex = Integer.parseInt(
sentence.getMetadata().get("index").toString());
// 找到包含这个句子的段落
List<Document> paragraphs = vectorStore.similaritySearch(
SearchRequest.query(sentence.getContent())
.withTopK(1)
.withFilterExpression("level == 'paragraph' && documentId == '" + docId + "'")
);
return paragraphs.isEmpty() ? sentence : paragraphs.get(0);
}
}HyDE(Hypothetical Document Embedding)
HyDE是个聪明的技巧:与其用查询去匹配文档,不如先让模型生成一个"假设性的答案文档",然后用这个假设文档去匹配真实文档。
@Service
public class HydeRetrievalService {
@Autowired
private ChatClient chatClient;
@Autowired
private EmbeddingModel embeddingModel;
@Autowired
private VectorStore vectorStore;
public List<Document> retrieveWithHyde(String query, int topK) {
// 1. 生成假设性回答
String hypotheticalAnswer = generateHypotheticalAnswer(query);
// 2. 用假设性回答的embedding去检索(而不是用query的embedding)
// 因为答案的语义空间和文档更接近
List<Document> results = vectorStore.similaritySearch(
SearchRequest.query(hypotheticalAnswer).withTopK(topK)
);
return results;
}
private String generateHypotheticalAnswer(String query) {
return chatClient.prompt()
.system("""
请根据以下问题,生成一个假设性的、详细的回答。
这个回答不需要完全准确,目的是帮助检索相关文档。
回答要使用专业术语,包含可能的关键词。
""")
.user(query)
.call()
.content();
}
}HyDE在专业领域效果特别好。原因是用户的问题往往用的是口语或者问句,而文档里用的是专业术语和陈述句。HyDE用"回答"的语义空间而不是"问题"的语义空间去检索,天然弥合了这个gap。
Agent-RAG:让检索本身变得智能
Agent-RAG是目前最复杂但也最强大的方向,核心思想是:检索不是一次性的,而是迭代的、自适应的。
实现一个简化版的Agent-RAG:
@Service
public class AgentRagService {
@Autowired
private ChatClient chatClient;
@Autowired
private DragRetrievalService retrievalService;
private static final int MAX_RETRIEVAL_ROUNDS = 5;
public RagResult answer(String question) {
List<Document> allRetrievedDocs = new ArrayList<>();
List<String> retrievalHistory = new ArrayList<>();
String currentContext = "";
// 初始检索策略
List<String> queries = generateInitialQueries(question);
for (int round = 0; round < MAX_RETRIEVAL_ROUNDS; round++) {
// 执行本轮检索
for (String query : queries) {
List<Document> docs = retrievalService.retrieve(query, 20, 5);
allRetrievedDocs.addAll(docs);
retrievalHistory.add("查询: " + query + " -> 检索到 " + docs.size() + " 条");
}
// 去重
allRetrievedDocs = deduplicateDocuments(allRetrievedDocs);
currentContext = buildContext(allRetrievedDocs);
// 判断是否已有足够信息回答
RetrievalAssessment assessment = assessSufficiency(
question, currentContext, retrievalHistory);
if (assessment.isSufficient()) {
break; // 信息足够,停止检索
}
if (round < MAX_RETRIEVAL_ROUNDS - 1) {
// 生成新的查询来补充缺失信息
queries = generateFollowUpQueries(
question, currentContext, assessment.getMissingAspects());
if (queries.isEmpty()) break; // 没有新查询需要,停止
}
}
// 最终生成答案
return generateFinalAnswer(question, allRetrievedDocs);
}
private List<String> generateInitialQueries(String question) {
String prompt = """
请将以下问题分解为2-3个检索查询,每个查询针对问题的不同方面。
输出JSON数组格式:["查询1", "查询2", ...]
问题:%s
""".formatted(question);
return chatClient.prompt()
.user(prompt)
.call()
.entity(new ParameterizedTypeReference<List<String>>() {});
}
private RetrievalAssessment assessSufficiency(
String question, String context, List<String> history) {
String prompt = """
基于已检索到的信息,判断是否能够充分回答用户问题。
问题:%s
已检索到的信息摘要:
%s
请输出JSON:
{
"sufficient": true/false,
"missingAspects": ["缺少X方面的信息", ...],
"confidence": 0.0-1.0
}
""".formatted(question, summarizeContext(context));
return chatClient.prompt()
.user(prompt)
.call()
.entity(RetrievalAssessment.class);
}
private RagResult generateFinalAnswer(String question, List<Document> docs) {
String context = buildContext(docs);
String answer = chatClient.prompt()
.system("""
基于提供的参考文档回答用户问题。
如果文档中没有足够信息,请明确说明。
回答要准确、有引用、结构清晰。
""")
.user("问题:" + question + "\n\n参考文档:\n" + context)
.call()
.content();
return RagResult.builder()
.answer(answer)
.sourceDocs(docs)
.build();
}
}实用的RAG评估体系
不管用哪种RAG方案,都需要有评估体系,不然你不知道改进是否有效。
常用的三个维度:
检索准确率(Retrieval Precision/Recall):检索到的文档里,有多少是真正相关的;相关的文档里,有多少被检索到了。
答案忠实度(Answer Faithfulness):生成的答案有多少比例来自检索到的文档,而不是模型凭空捏造的。
答案相关性(Answer Relevance):生成的答案是否真正回答了用户的问题。
@Service
public class RagEvaluator {
@Autowired
private ChatClient evaluatorClient;
/**
* 评估答案忠实度
* 答案里的每个声明是否都有文档支持
*/
public FaithfulnessScore evaluateFaithfulness(
String answer, List<Document> retrievedDocs) {
String context = retrievedDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
String evaluationPrompt = """
判断以下答案中的每个主要声明是否在参考文档中有依据。
参考文档:
%s
待评估答案:
%s
输出JSON:
{
"faithfulStatements": ["声明1", ...],
"unfaithfulStatements": ["没有依据的声明", ...],
"faithfulnessScore": 0.0-1.0
}
""".formatted(context, answer);
return evaluatorClient.prompt()
.user(evaluationPrompt)
.call()
.entity(FaithfulnessScore.class);
}
}各方案选型参考
一个原则:能用简单方案解决的不要用复杂方案。朴素RAG在很多场景下的80分效果,已经满足业务需求了,没必要为了追求95分而引入高维护成本的Agent-RAG。
从朴素RAG开始,先评估,再针对性地改进瓶颈,这是我推荐的路径。
