Skip to content
Published on

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

分享
Authors
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 时都重新计算文档分布
PRAG-Seq(yx)ztop-kPη(zx)iPθ(yix,z,y1:i1)P_{\text{RAG-Seq}}(y|x) \approx \sum_{z \in \text{top-k}} P_\eta(z|x) \prod_{i} P_\theta(y_i|x, z, y_{1:i-1}) PRAG-Token(yx)iztop-kPη(zx)Pθ(yix,z,y1:i1)P_{\text{RAG-Token}}(y|x) \approx \prod_{i} \sum_{z \in \text{top-k}} P_\eta(z|x) P_\theta(y_i|x, z, y_{1:i-1})

基于 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 的核心设计原理如下。

特性RETROGPT-3
参数量7.5B175B
检索数据库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)
PaLM540B39.681.4
Atlas11B42.484.7
Chinchilla70B35.572.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 相比既有方法展现出压倒性的性能提升。

模型PopQABioASQA (EM)
Llama2-7B14.731.621.9
Llama2 + RAG38.236.725.3
Self-RAG (7B)55.851.530.1
ChatGPT29.341.227.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 算法。它从检索文档中剔除无关信息,仅提取核心知识并重新组织。

  1. 将检索文档分解为细粒度的知识单元(knowledge strip)
  2. 对每个单元单独评估相关性
  3. 只挑选相关的知识单元重新组合
  4. 用重组后的上下文生成最终回答
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 RAGAdvanced RAGModular RAG
时期2020~20222022~20232023~
检索策略简单相似度检索查询重写、HyDE自适应检索、路由
分块固定大小语义分块分层、递归分块
检索后处理重排序、压缩自我反思、纠正
局限检索精度低、幻觉流水线复杂度高设计空间爆炸
代表模型RAG (Lewis)RETRO、AtlasSelf-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)Naive44.556.8--
REALM (Guu, 2020)Pre-train40.4---
RETRO (Borgeaud, 2022)Pre-train----
Atlas-11B (Izacard, 2023)Few-shot42.484.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 RAG44%中等
Advanced RAG55%
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 的演进展现出从单纯的「先检索、后生成」,向智能且自适应的知识利用的转变。核心发展脉络可以归纳如下。

  1. Original RAG(2020):证明了检索与生成的结合是可行的
  2. RETRO(2022):通过大规模检索把参数效率发挥到极致
  3. Atlas(2023):实证了检索质量可以替代模型规模
  4. Self-RAG(2023):让检索本身变得可选,并用自我反思保障质量
  5. CRAG(2024):评估检索结果的可信度,并用替代来源加以纠正

在实践中,关键在于有选择地组合这些论文的思路。如果只是简单的内部问答系统,Naive RAG + BM25 或许就足够了;但在医疗/法律这类要求高准确率的领域,Self-RAG 的反思机制或 CRAG 的质量评估技巧则是必不可少的。

参考资料