- Published on
RAG 论文综述:Retrieval-Augmented Generation 的演进 — 从 RETRO 到 Self-RAG、Corrective-RAG
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言
- Original RAG (Lewis et al., 2020)
- REALM 与 RETRO:大规模检索整合
- Atlas:Few-shot 学习与检索
- Self-RAG:基于自我反思的自适应检索
- Corrective RAG (CRAG)
- 从 Naive RAG 到 Advanced RAG、Modular RAG 的演进
- 基准测试对比分析
- 实践应用中的考量事项
- 未来研究方向
- 结语
- 参考资料

引言
大语言模型(LLM)展现出惊人的语言理解与生成能力,但存在两个根本性的局限。第一,幻觉(hallucination) 问题——会煞有介事地生成不符合事实的内容。第二,受限于训练数据的知识断层(knowledge cutoff),无法反映最新信息。把知识存放在参数中的方式在扩展性上存在瓶颈,重新训练模型的成本也高得惊人。
Retrieval-Augmented Generation(RAG) 正是作为应对这一问题的最实用方案而出现的。核心思路很简单:给定一个问题,从外部知识库中检索(Retrieve)相关文档,再把它作为上下文用于生成(Generate)答案。这样一来,无需修改模型参数,就能反映最新知识、减少幻觉。
本文以核心论文为线索,追溯 RAG 研究的演进历程:从 2020 年 Lewis 等人的 Original RAG 出发,经过 REALM 与 RETRO 的大规模检索整合、Atlas 的 Few-shot 学习,一直到 Self-RAG 的自我反思机制、Corrective-RAG 的检索质量评估,对比分析各自的架构与基准测试表现。
Original RAG (Lewis et al., 2020)
架构概览
Lewis 等人在 NeurIPS 2020 上发表的《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》是 RAG 范式的起点。核心结构是 DPR(Dense Passage Retrieval)检索器 与 BART seq2seq 生成器 的结合。
模型利用两种类型的记忆。
- Parametric Memory:存储在 BART 预训练参数中的知识
- Non-parametric Memory:基于 Wikipedia 转储构建的 FAISS 索引外部知识库
RAG-Sequence vs RAG-Token
论文提出了两种模型变体。
- RAG-Sequence:生成整个序列时使用同一份文档。给定文档 z,一次性生成完整输出 y
- RAG-Token:每个 token 可以参照不同的文档。在生成每个 token 时都重新计算文档分布
基于 DPR 的文档检索实现
import torch
import numpy as np
from transformers import DPRQuestionEncoder, DPRQuestionEncoderTokenizer
from transformers import DPRContextEncoder, DPRContextEncoderTokenizer
class DPRRetriever:
"""基于 DPR 的 Dense Passage Retrieval 实现"""
def __init__(self, model_name="facebook/dpr-question_encoder-single-nq-base"):
self.q_encoder = DPRQuestionEncoder.from_pretrained(model_name)
self.q_tokenizer = DPRQuestionEncoderTokenizer.from_pretrained(model_name)
ctx_model = "facebook/dpr-ctx_encoder-single-nq-base"
self.ctx_encoder = DPRContextEncoder.from_pretrained(ctx_model)
self.ctx_tokenizer = DPRContextEncoderTokenizer.from_pretrained(ctx_model)
self.document_embeddings = None
self.documents = []
def encode_documents(self, documents: list[str]) -> np.ndarray:
"""将文档语料转换为向量表示"""
self.documents = documents
embeddings = []
for doc in documents:
inputs = self.ctx_tokenizer(
doc, return_tensors="pt",
max_length=256, truncation=True, padding=True
)
with torch.no_grad():
output = self.ctx_encoder(**inputs)
embeddings.append(output.pooler_output.numpy())
self.document_embeddings = np.vstack(embeddings)
# 进行 L2 归一化
norms = np.linalg.norm(self.document_embeddings, axis=1, keepdims=True)
self.document_embeddings = self.document_embeddings / norms
return self.document_embeddings
def retrieve(self, query: str, top_k: int = 5) -> list[dict]:
"""检索与查询最相关的前 k 个文档"""
inputs = self.q_tokenizer(
query, return_tensors="pt",
max_length=64, truncation=True, padding=True
)
with torch.no_grad():
q_embedding = self.q_encoder(**inputs).pooler_output.numpy()
q_embedding = q_embedding / np.linalg.norm(q_embedding)
scores = np.dot(self.document_embeddings, q_embedding.T).squeeze()
top_indices = np.argsort(scores)[::-1][:top_k]
results = []
for idx in top_indices:
results.append({
"document": self.documents[idx],
"score": float(scores[idx]),
"index": int(idx)
})
return results
# 使用示例
retriever = DPRRetriever()
corpus = [
"RAG는 검색과 생성을 결합한 모델이다.",
"Transformer는 Self-Attention 메커니즘을 사용한다.",
"BERT는 양방향 사전학습 언어 모델이다.",
"DPR은 밀집 벡터를 사용하여 패시지를 검색한다.",
]
retriever.encode_documents(corpus)
results = retriever.retrieve("RAG에서 문서 검색은 어떻게 동작하나요?")
for r in results:
print(f"[Score: {r['score']:.4f}] {r['document']}")
Original RAG 在 Natural Questions 上取得了 44.5 EM,在 TriviaQA 上取得了 56.8 EM,证明了相较于当时的抽取式 QA 方法,生成式方法同样具备可行性。
REALM 与 RETRO:大规模检索整合
REALM:预训练阶段的检索
Guu 等人(2020)提出的 REALM(Retrieval-Enhanced Language Model)比 RAG 早了一步,是最早从预训练阶段就整合检索的研究。在 Masked Language Modeling 过程中,为了预测被掩盖的 token 而检索外部文档,这个检索过程也通过反向传播一起被训练。
核心贡献在于证明了检索器与生成器可以端到端(end-to-end)联合训练。
RETRO:2 万亿 token 数据库
Borgeaud 等人(2022)提出的 RETRO(Retrieval-Enhanced Transformer)把检索的规模做了戏剧性的扩展。它构建了规模达 2 万亿 token 的数据库,并引入 Chunked Cross-Attention(CCA) 机制,高效利用检索到的文本块。
RETRO 的核心设计原理如下。
| 特性 | RETRO | GPT-3 |
|---|---|---|
| 参数量 | 7.5B | 175B |
| 检索数据库 | 2T token | 无 |
| Pile 测试 perplexity | 相近 | 基准 |
| 训练成本 | 相对较低 | 高昂 |
相比 GPT-3,RETRO 用约少 25 倍的参数就达到了相当的性能。这一结果实证了,并非所有知识都必须存放在参数中。
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class ChunkedCrossAttention(nn.Module):
"""RETRO 风格的 Chunked Cross-Attention 实现"""
def __init__(self, d_model: int = 512, n_heads: int = 8, chunk_size: int = 64):
super().__init__()
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.chunk_size = chunk_size
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
self.layer_norm = nn.LayerNorm(d_model)
def forward(
self,
hidden_states: torch.Tensor,
retrieved_chunks: torch.Tensor
) -> torch.Tensor:
"""
Args:
hidden_states: (B, seq_len, d_model) - 解码器隐藏状态
retrieved_chunks: (B, n_chunks, chunk_len, d_model) - 检索到的相邻文本块
"""
B, seq_len, D = hidden_states.shape
n_chunks = seq_len // self.chunk_size
# 按 chunk 切分序列
h_chunks = hidden_states[:, :n_chunks * self.chunk_size].reshape(
B, n_chunks, self.chunk_size, D
)
# 对每个 chunk 与检索到的相邻块执行 Cross-Attention
Q = self.W_q(h_chunks) # (B, n_chunks, chunk_size, D)
K = self.W_k(retrieved_chunks) # (B, n_chunks, chunk_len, D)
V = self.W_v(retrieved_chunks)
# 拆分为多头
Q = Q.reshape(B, n_chunks, self.chunk_size, self.n_heads, self.d_k).permute(0, 1, 3, 2, 4)
K = K.reshape(B, n_chunks, -1, self.n_heads, self.d_k).permute(0, 1, 3, 2, 4)
V = V.reshape(B, n_chunks, -1, self.n_heads, self.d_k).permute(0, 1, 3, 2, 4)
# Scaled Dot-Product Attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
attn_weights = F.softmax(scores, dim=-1)
attn_output = torch.matmul(attn_weights, V)
# 合并多头并做输出投影
attn_output = attn_output.permute(0, 1, 3, 2, 4).reshape(
B, n_chunks, self.chunk_size, D
)
attn_output = self.W_o(attn_output)
# 残差连接与层归一化
output = self.layer_norm(h_chunks + attn_output)
output = output.reshape(B, n_chunks * self.chunk_size, D)
# 恢复剩余 token(当序列长度无法被 chunk_size 整除时)
if seq_len > n_chunks * self.chunk_size:
remainder = hidden_states[:, n_chunks * self.chunk_size:]
output = torch.cat([output, remainder], dim=1)
return output
# RETRO 风格检索流水线示例
cca = ChunkedCrossAttention(d_model=512, n_heads=8, chunk_size=64)
hidden = torch.randn(2, 256, 512) # 배치 2, 시퀀스 256
retrieved = torch.randn(2, 4, 32, 512) # 4 청크, 각 32 토큰
output = cca(hidden, retrieved)
print(f"Input shape: {hidden.shape} -> Output shape: {output.shape}")
Atlas:Few-shot 学习与检索
Izacard 等人(2023)提出的 Atlas 结合了 Contriever 检索器与 Fusion-in-Decoder(FiD) 生成器。其核心发现是:只要检索质量足够高,即使大幅削减参数量,也能与大规模模型竞争。
Atlas 11B 模型仅凭 64 个示例(64-shot)就在 Natural Questions 上超越了 PaLM 540B 的表现。这说明参数量小 50 倍的模型,也能凭借出色的检索机制战胜大规模模型。
| 模型 | 参数量 | NQ (64-shot) | TriviaQA (64-shot) |
|---|---|---|---|
| PaLM | 540B | 39.6 | 81.4 |
| Atlas | 11B | 42.4 | 84.7 |
| Chinchilla | 70B | 35.5 | 72.3 |
Atlas 训练策略中值得关注的一点是 Attention Distillation。通过利用生成器的 Cross-Attention 分布对检索器进行微调,检索器与生成器之间形成相互强化的良性循环。
Self-RAG:基于自我反思的自适应检索
ICLR 2024 Oral 论文
Asai 等人(2023)提出的 Self-RAG(Self-Reflective Retrieval-Augmented Generation)入选了 ICLR 2024 的 Oral 报告(约前 1%)。它正面回应了既有 RAG 的一个根本局限:既有方式不论问题类型,总是执行检索,而在简单常识性问题或创作类任务中,不必要的检索反而会拖累性能。
Reflection Token 机制
Self-RAG 的核心创新是 4 种反思 token(Reflection Token)。
| 反思 token | 作用 | 输出值 |
|---|---|---|
| Retrieve | 判断是否需要检索 | Yes, No, Continue |
| ISREL | 评估检索文档的相关性 | Relevant, Irrelevant |
| ISSUP | 评估生成内容的依据充分性 | Fully Supported, Partially Supported, No Support |
| ISUSE | 评估整体回答的有用性 | 1~5 分 |
模型在生成过程中自主输出这些 token,从而自行判断是否需要检索、文档相关性以及回答质量。
性能对比
Self-RAG 相比既有方法展现出压倒性的性能提升。
| 模型 | PopQA | Bio | ASQA (EM) |
|---|---|---|---|
| Llama2-7B | 14.7 | 31.6 | 21.9 |
| Llama2 + RAG | 38.2 | 36.7 | 25.3 |
| Self-RAG (7B) | 55.8 | 51.5 | 30.1 |
| ChatGPT | 29.3 | 41.2 | 27.8 |
在 PopQA 上,相比 Llama2 取得了超过 270% 的提升,相比 ChatGPT 取得了超过 90% 的提升。
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class RetrieveDecision(Enum):
YES = "yes"
NO = "no"
CONTINUE = "continue"
class RelevanceScore(Enum):
RELEVANT = "relevant"
IRRELEVANT = "irrelevant"
class SupportScore(Enum):
FULLY_SUPPORTED = "fully_supported"
PARTIALLY_SUPPORTED = "partially_supported"
NO_SUPPORT = "no_support"
@dataclass
class ReflectionResult:
retrieve: RetrieveDecision
relevance: Optional[RelevanceScore] = None
support: Optional[SupportScore] = None
utility: Optional[int] = None # 1-5 分
class SelfRAGPipeline:
"""Self-RAG 风格的自适应检索-生成流水线"""
def __init__(self, generator, retriever, reflection_model):
self.generator = generator
self.retriever = retriever
self.reflection_model = reflection_model
def should_retrieve(self, query: str, partial_output: str = "") -> RetrieveDecision:
"""自主判断是否需要检索的反思步骤"""
prompt = (
f"Query: {query}\n"
f"Partial output: {partial_output}\n"
"Does this query require external knowledge retrieval? "
"Answer: yes, no, or continue"
)
decision = self.reflection_model.predict(prompt)
return RetrieveDecision(decision.strip().lower())
def evaluate_relevance(self, query: str, document: str) -> RelevanceScore:
"""评估检索文档的相关性(模拟 ISREL token)"""
prompt = (
f"Query: {query}\n"
f"Document: {document}\n"
"Is this document relevant to answering the query? "
"Answer: relevant or irrelevant"
)
score = self.reflection_model.predict(prompt)
return RelevanceScore(score.strip().lower())
def evaluate_support(
self, query: str, document: str, response: str
) -> SupportScore:
"""评估生成结果的依据充分性(模拟 ISSUP token)"""
prompt = (
f"Query: {query}\n"
f"Document: {document}\n"
f"Response: {response}\n"
"Is the response supported by the document? "
"Answer: fully_supported, partially_supported, or no_support"
)
score = self.reflection_model.predict(prompt)
return SupportScore(score.strip().lower())
def generate_with_reflection(self, query: str) -> dict:
"""执行完整的 Self-RAG 流水线"""
# 第 1 步:判断是否需要检索
retrieve_decision = self.should_retrieve(query)
if retrieve_decision == RetrieveDecision.NO:
# 无需检索 —— 直接生成
response = self.generator.generate(query)
return {
"response": response,
"retrieved": False,
"reflection": ReflectionResult(retrieve=RetrieveDecision.NO)
}
# 第 2 步:检索文档
documents = self.retriever.retrieve(query, top_k=5)
# 第 3 步:通过相关性评估过滤文档
relevant_docs = []
for doc in documents:
relevance = self.evaluate_relevance(query, doc["text"])
if relevance == RelevanceScore.RELEVANT:
relevant_docs.append(doc)
if not relevant_docs:
# 没有相关文档 —— 不检索直接生成
response = self.generator.generate(query)
return {
"response": response,
"retrieved": True,
"relevant_docs": 0,
"reflection": ReflectionResult(
retrieve=RetrieveDecision.YES,
relevance=RelevanceScore.IRRELEVANT
)
}
# 第 4 步:生成候选回答并评估
best_response = None
best_score = -1
for doc in relevant_docs:
context = f"Context: {doc['text']}\nQuery: {query}"
candidate = self.generator.generate(context)
support = self.evaluate_support(query, doc["text"], candidate)
# 计算支持度得分
support_score = {
SupportScore.FULLY_SUPPORTED: 3,
SupportScore.PARTIALLY_SUPPORTED: 1,
SupportScore.NO_SUPPORT: 0
}.get(support, 0)
if support_score > best_score:
best_score = support_score
best_response = candidate
best_support = support
return {
"response": best_response,
"retrieved": True,
"relevant_docs": len(relevant_docs),
"reflection": ReflectionResult(
retrieve=RetrieveDecision.YES,
relevance=RelevanceScore.RELEVANT,
support=best_support,
utility=min(best_score + 2, 5)
)
}
Corrective RAG (CRAG)
引入检索质量评估器
Yan 等人(2024)提出的 Corrective RAG(CRAG)瞄准了既有 RAG 的另一个弱点:既有方式对检索到的文档是否真正有用不加验证、直接使用。当检索质量不佳时,不准确的上下文反而可能加剧幻觉。
CRAG 引入了轻量级检索评估器(Retrieval Evaluator),对检索结果的可信度进行定量评估,并依据评估结果触发三种动作。
| 判定结果 | 置信度条件 | 动作 |
|---|---|---|
| Correct | 置信度高 | 从检索文档中提炼核心知识后使用 |
| Incorrect | 置信度低 | 转向网络搜索等替代知识来源 |
| Ambiguous | 置信度中等 | 结合提炼后的检索结果与网络搜索结果 |
Decompose-then-Recompose 算法
CRAG 的另一项核心贡献是 Decompose-then-Recompose 算法。它从检索文档中剔除无关信息,仅提取核心知识并重新组织。
- 将检索文档分解为细粒度的知识单元(knowledge strip)
- 对每个单元单独评估相关性
- 只挑选相关的知识单元重新组合
- 用重组后的上下文生成最终回答
from dataclasses import dataclass
from enum import Enum
import numpy as np
class ConfidenceLevel(Enum):
CORRECT = "correct"
INCORRECT = "incorrect"
AMBIGUOUS = "ambiguous"
@dataclass
class EvaluationResult:
confidence: ConfidenceLevel
score: float
action: str
class CRAGPipeline:
"""Corrective RAG 风格的流水线实现"""
def __init__(
self,
retriever,
evaluator,
generator,
web_searcher,
upper_threshold: float = 0.7,
lower_threshold: float = 0.3
):
self.retriever = retriever
self.evaluator = evaluator
self.generator = generator
self.web_searcher = web_searcher
self.upper_threshold = upper_threshold
self.lower_threshold = lower_threshold
def evaluate_retrieval(self, query: str, documents: list[dict]) -> EvaluationResult:
"""评估检索结果的置信度"""
scores = []
for doc in documents:
score = self.evaluator.score(query, doc["text"])
scores.append(score)
max_score = max(scores) if scores else 0.0
if max_score >= self.upper_threshold:
return EvaluationResult(
confidence=ConfidenceLevel.CORRECT,
score=max_score,
action="refine_and_use"
)
elif max_score <= self.lower_threshold:
return EvaluationResult(
confidence=ConfidenceLevel.INCORRECT,
score=max_score,
action="web_search_fallback"
)
else:
return EvaluationResult(
confidence=ConfidenceLevel.AMBIGUOUS,
score=max_score,
action="combine_sources"
)
def decompose_then_recompose(
self, query: str, document: str
) -> str:
"""Decompose-then-Recompose:仅从文档中提取相关知识"""
# 第 1 步:将文档分解为细粒度的知识单元
sentences = document.split(". ")
knowledge_strips = [s.strip() + "." for s in sentences if s.strip()]
# 第 2 步:评估每个知识单元的相关性
relevant_strips = []
for strip in knowledge_strips:
relevance = self.evaluator.score(query, strip)
if relevance > 0.5:
relevant_strips.append((strip, relevance))
# 第 3 步:按相关性排序并重新组合
relevant_strips.sort(key=lambda x: x[1], reverse=True)
refined_context = " ".join([s[0] for s in relevant_strips])
return refined_context if refined_context else document
def process_query(self, query: str) -> dict:
"""执行完整的 CRAG 流水线"""
# 第 1 步:初始文档检索
documents = self.retriever.retrieve(query, top_k=10)
# 第 2 步:评估检索质量
evaluation = self.evaluate_retrieval(query, documents)
context = ""
sources = []
if evaluation.confidence == ConfidenceLevel.CORRECT:
# 信任检索结果 —— 提炼核心知识后使用
for doc in documents[:3]:
refined = self.decompose_then_recompose(query, doc["text"])
context += refined + "\n"
sources = ["internal_retrieval"]
elif evaluation.confidence == ConfidenceLevel.INCORRECT:
# 不信任检索结果 —— 转向网络搜索
web_results = self.web_searcher.search(query)
for result in web_results[:3]:
context += result["snippet"] + "\n"
sources = ["web_search"]
else: # AMBIGUOUS
# 结合两种来源
for doc in documents[:2]:
refined = self.decompose_then_recompose(query, doc["text"])
context += refined + "\n"
web_results = self.web_searcher.search(query)
for result in web_results[:2]:
context += result["snippet"] + "\n"
sources = ["internal_retrieval", "web_search"]
# 第 3 步:生成最终回答
prompt = f"Context: {context}\nQuery: {query}\nAnswer:"
response = self.generator.generate(prompt)
return {
"response": response,
"confidence": evaluation.confidence.value,
"score": evaluation.score,
"sources": sources
}
从 Naive RAG 到 Advanced RAG、Modular RAG 的演进
Gao 等人(2024)的综述论文《Retrieval-Augmented Generation for Large Language Models: A Survey》把 RAG 的发展归纳为三个阶段。
架构演进对比表
| 分类 | Naive RAG | Advanced RAG | Modular RAG |
|---|---|---|---|
| 时期 | 2020~2022 | 2022~2023 | 2023~ |
| 检索策略 | 简单相似度检索 | 查询重写、HyDE | 自适应检索、路由 |
| 分块 | 固定大小 | 语义分块 | 分层、递归分块 |
| 检索后处理 | 无 | 重排序、压缩 | 自我反思、纠正 |
| 局限 | 检索精度低、幻觉 | 流水线复杂度高 | 设计空间爆炸 |
| 代表模型 | RAG (Lewis) | RETRO、Atlas | Self-RAG、CRAG |
Pre-retrieval、Retrieval、Post-retrieval 优化
Advanced RAG 之后,各个阶段陆续出现了多种优化技巧。
Pre-retrieval 优化:
- 查询重写(Query Rewriting):把原始问题转换为更适合检索的形式
- HyDE(Hypothetical Document Embeddings):先生成一份假想文档,再用它作为检索查询
- Step-back Prompting:转换为更抽象的问题,从而执行更宽泛的检索
Retrieval 优化:
- Hybrid Search:结合 BM25(Sparse)与向量检索(Dense)
- 多向量检索:类似 ColBERT 的 token 级交互
- 递归检索:基于初步结果反复检索
Post-retrieval 优化:
- 重排序(Re-ranking):用 Cross-Encoder 对检索结果重新排序
- 上下文压缩:去除不必要的信息
- Self-RAG / CRAG:自我反思与纠正
基准测试对比分析
主要模型性能综合对比
| 模型 | 类型 | NQ (EM) | TriviaQA (EM) | PopQA (F1) | FEVER (Acc) |
|---|---|---|---|---|---|
| RAG (Lewis, 2020) | Naive | 44.5 | 56.8 | - | - |
| REALM (Guu, 2020) | Pre-train | 40.4 | - | - | - |
| RETRO (Borgeaud, 2022) | Pre-train | - | - | - | - |
| Atlas-11B (Izacard, 2023) | Few-shot | 42.4 | 84.7 | - | - |
| Self-RAG-7B (Asai, 2023) | Adaptive | - | - | 55.8 | - |
| CRAG (Yan, 2024) | Corrective | - | - | - | - |
之所以难以在同一基准上直接比较,是因为各篇论文所用的评估设置(few-shot 数量、检索语料规模、模型规模)各不相同。但整体趋势是明确的:越是引入自适应检索与自我反思机制,性能就越好。
CRAG Benchmark (Meta, NeurIPS 2024)
Meta 在 NeurIPS 2024 上发布的 CRAG Benchmark,横跨 8 个领域、多种问题类型,对 RAG 系统进行系统性评估。
| 方法 | 总体准确率 | 幻觉率 |
|---|---|---|
| 纯 LLM(无检索) | 34% | 高 |
| Naive RAG | 44% | 中等 |
| Advanced RAG | 55% | 低 |
| SOTA RAG 系统 | 63% | 极低 |
这一结果说明了两点。第一,相比纯 LLM,RAG 带来了明确的改善(34% 对 44%)。第二,从简单 RAG 升级到高级 RAG,还能再带来 20 个百分点以上的额外提升。
实践应用中的考量事项
检索器选择:Dense vs Sparse vs Hybrid
在实践中,检索器的选择取决于数据特性与需求。
| 检索方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Sparse (BM25) | 关键词匹配精准、速度快 | 无法反映语义相似性 | 专业术语、代码检索 |
| Dense (向量) | 能捕捉语义相似性 | 可能出现关键词不匹配 | 通用问答、对话式检索 |
| Hybrid | 结合两者优点 | 实现复杂、需要调节权重 | 生产系统 |
Hybrid Retrieval 流水线实现
import numpy as np
from dataclasses import dataclass, field
from typing import Optional
import re
from collections import Counter
import math
@dataclass
class Document:
text: str
doc_id: str
metadata: dict = field(default_factory=dict)
@dataclass
class SearchResult:
document: Document
score: float
source: str # "sparse", "dense", or "hybrid"
class BM25Retriever:
"""BM25 Sparse Retriever 的简化实现"""
def __init__(self, k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.documents: list[Document] = []
self.doc_freqs: dict[str, int] = {}
self.doc_lengths: list[int] = []
self.avg_doc_length: float = 0
self.doc_term_freqs: list[dict[str, int]] = []
def _tokenize(self, text: str) -> list[str]:
return re.findall(r'\w+', text.lower())
def index(self, documents: list[Document]):
self.documents = documents
for doc in documents:
tokens = self._tokenize(doc.text)
self.doc_lengths.append(len(tokens))
term_freq = Counter(tokens)
self.doc_term_freqs.append(term_freq)
for term in set(tokens):
self.doc_freqs[term] = self.doc_freqs.get(term, 0) + 1
self.avg_doc_length = (
sum(self.doc_lengths) / len(self.doc_lengths) if self.doc_lengths else 0
)
def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
query_tokens = self._tokenize(query)
n_docs = len(self.documents)
scores = []
for i, doc in enumerate(self.documents):
score = 0.0
for term in query_tokens:
if term not in self.doc_term_freqs[i]:
continue
tf = self.doc_term_freqs[i][term]
df = self.doc_freqs.get(term, 0)
idf = math.log((n_docs - df + 0.5) / (df + 0.5) + 1)
dl = self.doc_lengths[i]
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (
1 - self.b + self.b * dl / self.avg_doc_length
)
score += idf * numerator / denominator
scores.append(score)
top_indices = np.argsort(scores)[::-1][:top_k]
return [
SearchResult(
document=self.documents[i],
score=float(scores[i]),
source="sparse"
)
for i in top_indices if scores[i] > 0
]
class DenseRetriever:
"""Dense Vector Retriever(基于向量嵌入)"""
def __init__(self, embedding_fn):
self.embedding_fn = embedding_fn
self.documents: list[Document] = []
self.embeddings: Optional[np.ndarray] = None
def index(self, documents: list[Document]):
self.documents = documents
texts = [doc.text for doc in documents]
self.embeddings = self.embedding_fn(texts)
# L2 归一化
norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
self.embeddings = self.embeddings / (norms + 1e-10)
def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
q_emb = self.embedding_fn([query])
q_emb = q_emb / (np.linalg.norm(q_emb) + 1e-10)
scores = np.dot(self.embeddings, q_emb.T).squeeze()
top_indices = np.argsort(scores)[::-1][:top_k]
return [
SearchResult(
document=self.documents[i],
score=float(scores[i]),
source="dense"
)
for i in top_indices
]
class HybridRetriever:
"""Hybrid Retrieval:结合 BM25 与 Dense 检索"""
def __init__(
self,
sparse: BM25Retriever,
dense: DenseRetriever,
alpha: float = 0.5
):
self.sparse = sparse
self.dense = dense
self.alpha = alpha # Dense 权重(1-alpha 为 Sparse 权重)
def _normalize_scores(self, results: list[SearchResult]) -> dict[str, float]:
"""Min-Max 归一化"""
if not results:
return {}
scores = [r.score for r in results]
min_s, max_s = min(scores), max(scores)
range_s = max_s - min_s if max_s != min_s else 1.0
return {
r.document.doc_id: (r.score - min_s) / range_s
for r in results
}
def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
"""基于 Reciprocal Rank Fusion 的混合检索"""
sparse_results = self.sparse.search(query, top_k=top_k * 2)
dense_results = self.dense.search(query, top_k=top_k * 2)
sparse_scores = self._normalize_scores(sparse_results)
dense_scores = self._normalize_scores(dense_results)
# 收集所有唯一文档
all_doc_ids = set(sparse_scores.keys()) | set(dense_scores.keys())
doc_map = {}
for r in sparse_results + dense_results:
doc_map[r.document.doc_id] = r.document
# 加权组合
hybrid_scores = {}
for doc_id in all_doc_ids:
s_score = sparse_scores.get(doc_id, 0.0)
d_score = dense_scores.get(doc_id, 0.0)
hybrid_scores[doc_id] = (
(1 - self.alpha) * s_score + self.alpha * d_score
)
# 排序并返回前 k 个
sorted_docs = sorted(
hybrid_scores.items(), key=lambda x: x[1], reverse=True
)[:top_k]
return [
SearchResult(
document=doc_map[doc_id],
score=score,
source="hybrid"
)
for doc_id, score in sorted_docs
]
# 使用示例
bm25 = BM25Retriever()
docs = [
Document("RAG는 검색과 생성을 결합한다.", "doc1"),
Document("Self-RAG는 반성 토큰을 사용한다.", "doc2"),
Document("CRAG는 검색 품질을 평가한다.", "doc3"),
Document("RETRO는 2조 토큰 데이터베이스를 사용한다.", "doc4"),
]
bm25.index(docs)
sparse_results = bm25.search("RAG에서 검색 품질 평가 방법은?")
for r in sparse_results:
print(f"[BM25 Score: {r.score:.4f}] {r.document.text}")
分块策略与成本-性能权衡
分块(Chunking)对 RAG 性能有决定性影响。
| 分块策略 | 分块大小 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小 | 256~512 token | 实现简单 | 上下文断裂 |
| 基于句子 | 3~5 句 | 边界自然 | 大小不均 |
| 基于语义 | 可变 | 保持主题一致性 | 嵌入成本高 |
| 递归式 | 分层 | 支持多层次检索 | 实现复杂 |
class SemanticChunker:
"""基于语义的分块:用嵌入相似度探测自然边界"""
def __init__(self, embedding_fn, similarity_threshold: float = 0.75):
self.embedding_fn = embedding_fn
self.threshold = similarity_threshold
def chunk(self, text: str, min_chunk_size: int = 100) -> list[str]:
"""在句子间语义相似度发生变化的地方切分"""
sentences = [s.strip() for s in text.split(". ") if s.strip()]
if len(sentences) <= 1:
return [text]
# 计算每个句子的嵌入
embeddings = self.embedding_fn(sentences)
# 计算相邻句子间的余弦相似度
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
sim = np.dot(embeddings[i], embeddings[i - 1]) / (
np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i - 1])
+ 1e-10
)
if sim < self.threshold and len(". ".join(current_chunk)) >= min_chunk_size:
# 若相似度低于阈值,则开始新的 chunk
chunks.append(". ".join(current_chunk) + ".")
current_chunk = [sentences[i]]
else:
current_chunk.append(sentences[i])
if current_chunk:
chunks.append(". ".join(current_chunk) + ".")
return chunks
未来研究方向
Agentic RAG:工具使用与检索的结合
近来最受关注的方向是 Agentic RAG。它不再局限于单纯检索文档,而是让 LLM 智能体利用各种工具(API 调用、数据库查询、代码执行)主动收集所需信息。检索本身被纳为其中一种工具(tool),智能体会根据情况在检索、计算、API 调用之间选择最优动作。
Multi-modal RAG:图像与表格检索
不仅是文本,能够检索并利用图像、表格、图表等多种模态的 Multi-modal RAG 也在被积极研究。比如从技术文档中检索架构图,或者解析财务报告中的表格来回答数值型问题这类场景。像 ColPali 这样基于视觉-语言模型的检索器,就是这个方向的代表性研究。
实时知识更新
在生产环境的 RAG 系统中,知识库的实时更新依然是尚未解决的课题。文档被增加/修改/删除时如何高效更新嵌入索引、如何做版本管理、如何维持一致性,都是核心研究主题。流式索引与增量更新技术正受到关注。
结语
RAG 的演进展现出从单纯的「先检索、后生成」,向智能且自适应的知识利用的转变。核心发展脉络可以归纳如下。
- Original RAG(2020):证明了检索与生成的结合是可行的
- RETRO(2022):通过大规模检索把参数效率发挥到极致
- Atlas(2023):实证了检索质量可以替代模型规模
- Self-RAG(2023):让检索本身变得可选,并用自我反思保障质量
- CRAG(2024):评估检索结果的可信度,并用替代来源加以纠正
在实践中,关键在于有选择地组合这些论文的思路。如果只是简单的内部问答系统,Naive RAG + BM25 或许就足够了;但在医疗/法律这类要求高准确率的领域,Self-RAG 的反思机制或 CRAG 的质量评估技巧则是必不可少的。
参考资料
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks - Lewis 等,2020
- Improving Language Models by Retrieving from Trillions of Tokens (RETRO) - Borgeaud 等,2022
- Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection - Asai 等,2023
- Corrective Retrieval Augmented Generation - Yan 等,2024
- Retrieval-Augmented Generation for Large Language Models: A Survey - Gao 等,2024
- CRAG Benchmark - Meta, 2024
- REALM: Retrieval-Enhanced Language Model Pre-Training - Guu 等,2020
- Atlas: Few-shot Learning with Retrieval Augmented Language Models - Izacard 等,2023