# RAG系统核心结构与四大检索方式全解析:从架构设计到工程落地

RAG系统核心结构与四大检索方式全解析:从架构设计到工程落地

导读:RAG(检索增强生成)看起来简单——“把文档切成块,转成向量,用户提问时搜一下,把结果丢给大模型回答”。但真到生产环境,你会发现检索不准、召回不全、模型还是爱瞎编。问题出在"每一步都有坑"。本文从 RAG 的 5 大核心模块讲起,深度拆解向量检索、关键词检索、混合检索、重排序 4 种检索方式的原理、适用场景和组合策略,附完整代码和选型决策树。

适合读者

  • 正在搭建或优化 RAG 系统的开发者
  • 需要理解 RAG 全貌和检索选型逻辑的工程师
  • 准备 RAG 方向面试、需要系统回答"RAG系统结构"的同学
  • 对混合检索(向量+关键词+重排序)链路感兴趣的技术人员

阅读收益

  • 掌握 RAG 5 大核心模块的职责和数据流转
  • 理解 4 种检索方式的原理、优缺点和适用场景
  • 学会设计混合检索链路(向量+BM25→RRF融合→重排序)
  • 获得可直接落地的检索选型决策树和代码实现
  • 了解工业界推荐的"hybrid_rerank"完整链路

目录

  1. RAG核心思想:为什么需要检索增强
  2. 五大核心模块拆解
  3. 四大检索方式深度对比
  4. 向量检索:语义理解的利器
  5. 关键词检索(BM25):精确匹配的保底
  6. 混合检索:RRF融合两路结果
  7. 重排序:二次精筛提升质量
  8. 完整检索链路代码实现
  9. 检索选型决策树
  10. 踩坑清单:检索链路的8个关键问题
  11. 面试速答版
  12. 总结与延伸
  13. 文末互动

1. RAG核心思想:为什么需要检索增强

1.1 大模型的两大痛点

痛点一:知识过时

大模型的训练数据有截止日期。2024 年的政策、2025 年的产品更新,模型不知道。

用户:2024年公积金提取新政策是什么?
GPT-4(2023年训练):根据我所知,公积金提取需要满足以下条件...
→ 回答的是旧政策,可能已经变了

痛点二:幻觉(Hallucination)

模型遇到不知道的问题时,不会说"我不知道",而是会编一个听起来像真的答案。

用户:你们公司的年假几天?
大模型:根据一般企业惯例,年假通常是5-15天...
→ 编了一个通用答案,不是你们公司的实际规定

1.2 RAG的解法

传统方式:用户问题 → 大模型 → 回答(靠模型记忆,可能过时/编造)
RAG方式:用户问题 → 检索知识库 → 检索结果+问题 → 大模型 → 回答(基于真实资料)

核心思想:不让大模型"凭空想象",而是给它"开卷考试"——先检索相关资料,让模型基于检索到的真实文档回答。


2. 五大核心模块拆解

2.1 极简记忆口诀

加载解析 → 文本分块 → 向量化入库 → 检索召回 → Prompt组装生成

2.2 模块一:文档加载与解析

职责:把各种格式的原始文档变成纯文本。

PDF ──→ pypdf ──→ 提取文本 + 页码
DOCX ─→ python-docx ──→ 提取段落 + 样式
TXT ──→ 直接读取
网页 ─→ requests + BeautifulSoup ──→ 提取正文

关键注意点

  • PDF 解析要保留页码和章节信息(用于后续引用标注)
  • 去重:同一文档多次上传会生成重复切片
  • 过滤:页眉页脚、页码、水印等无效内容要清洗掉

2.3 模块二:文本切分(Chunk分块)

职责:把长文档切成大小合适的文本块。

为什么不能太大?
  → 向量语义混杂,一个块里包含多个主题,检索精度下降

为什么不能太小?
  → 丢失完整语义,"上下文"残缺,模型看不懂

常用策略:
  - 固定长度切分:简单,但不保语义边界
  - 递归字符切分:优先在段落、句子边界切分
  - 语义切分:用模型判断语义边界,计算成本高
  - 标题感知切分:按章节标题切分,保留上下文

推荐方案(父子分块)

# 父块:1200字符,用于召回时提供完整上下文
# 子块:420字符,用于精确匹配
# overlap:80字符,保证边界不截断语义

# 实际检索时:
# 1. 用子块做向量检索(小块匹配更精准)
# 2. 返回对应的父块内容(给模型更多上下文)

2.4 模块三:向量化与入库

职责:把文本块转成向量,存入向量数据库。

文本块 ──→ Embedding模型(如bge-m3)──→ 1024维向量
                                              ↓
                                        向量数据库(Milvus/Chroma/FAISS)
                                              ↓
                                        存储:向量 + 原始文本 + 元数据

关键注意点

  • 文档和查询要用同一个 Embedding 模型
  • 不同模型的向量维度不同(bge-m3是1024维,text-embedding-ada-002是1536维)
  • 向量库要支持元数据过滤(按部门、时间、状态过滤)

2.5 模块四:检索召回

职责:用户提问时,找到最相关的文本片段。

用户问题 ──→ Embedding ──→ 查询向量
                              ↓
                    向量库相似度搜索 ──→ Top-K相关片段
                              ↓
                    可选:BM25关键词检索 ──→ Top-K相关片段
                              ↓
                    RRF融合两路结果 ──→ 融合后的Top-K
                              ↓
                    重排序精筛 ──→ 最终Top-N送入LLM

2.6 模块五:Prompt组装与生成

职责:把检索结果和提问拼接成 Prompt,让大模型基于资料回答。

system_prompt = """你是一个企业政策问答助手。
请只基于下面提供的参考资料回答用户问题。
如果参考资料中没有相关信息,请如实说明。
每条关键结论必须标注引用来源。

参考资料:
{context}
"""

# context 由检索到的 Top-N 片段拼接而成

3. 四大检索方式深度对比

检索方式原理优点缺点适用场景
向量检索Embedding转向量,算余弦相似度懂语义,同义词/转述也能召回专有名词/数字/ID精确匹配差语义类问答
关键词检索(BM25)基于词频和逆文档频率专有名词/编号精确匹配好,速度快不懂语义,同义词召回差精确查询
混合检索(Hybrid)向量+关键词并行,RRF融合兼顾语义+精确,召回全面提升计算量略大,维护两套链路生产首选
重排序(Rerank)用Cross-Encoder精排问题-文档相关性大幅提升上下文质量,减少噪声增加额外延迟,非初次召回精度要求高

4. 向量检索:语义理解的利器

4.1 原理

文本 → Embedding模型 → 稠密向量(如1024维)

查询向量 vs 文档向量:
  余弦相似度 = (A·B) / (|A| × |B|)
  
  值域:[-1, 1]
  1 = 完全相同方向(语义最相似)
  0 = 正交(无关)
  -1 = 相反方向(极少出现)

4.2 为什么向量检索"懂语义"

传统关键词匹配:
  "怎么申请公积金提取" 和 "住房公积金提取流程" 
  → 字面完全不同,关键词匹配不到

向量检索:
  两个句子语义相近 → 向量在空间中距离近 → 被召回

4.3 向量检索的短板

场景1:用户问"订单A1002的状态"
  → "A1002"在向量空间中只是随机向量
  → 向量检索找不到精确匹配

场景2:用户问"2024年3月的退款政策"
  → "2024""3月"作为数字,语义信息弱
  → 向量检索容易召回其他年份的政策

场景3:用户问"BOS产品定价"
  → "BOS"是专有名词缩写
  → 向量检索可能召回"BOSS""BOC"等无关内容

5. 关键词检索(BM25):精确匹配的保底

5.1 原理

BM25 是一种基于概率模型的关键词检索算法。核心思想:

文档相关性得分 = 
  查询词在文档中出现的频率(TF)
  × 查询词的稀有程度(IDF,越稀有的词权重越高)
  × 文档长度归一化(避免长文档占便宜)

5.2 为什么需要BM25保底

# 场景:用户查询订单号
question = "订单 A1002 发货了吗"

# 向量检索:"A1002"是专有名词,向量相似度低 → 可能召回不到
# BM25检索:直接匹配"A1002"这个词 → 精确命中

# 另一个场景:用户问具体政策条款
question = "2024年公积金提取新政策"

# 向量检索:可能召回2023年、2025年的政策
# BM25检索:精确匹配"2024""公积金""提取" → 命中准确

5.3 中文BM25的实现

"""中文BM25检索实现"""
import jieba
from rank_bm25 import BM25Okapi

class ChineseBM25Retriever:
    def __init__(self, documents: list[str]):
        # jieba分词
        self.tokenized_docs = [
            list(jieba.cut(doc)) for doc in documents
        ]
        self.bm25 = BM25Okapi(self.tokenized_docs)
        self.documents = documents

    def search(self, query: str, top_k: int = 10) -> list[tuple[str, float]]:
        tokenized_query = list(jieba.cut(query))
        scores = self.bm25.get_scores(tokenized_query)

        # 取Top-K
        top_indices = sorted(
            range(len(scores)),
            key=lambda i: scores[i],
            reverse=True,
        )[:top_k]

        return [
            (self.documents[i], scores[i])
            for i in top_indices
        ]

6. 混合检索:RRF融合两路结果

6.1 为什么需要混合

向量检索强:语义理解、同义词召回、转述匹配
向量检索弱:专有名词、数字ID、精确术语

BM25强:精确匹配、专有名词、关键词命中
BM25弱:语义理解、同义词、转述表达

→ 两者互补,融合后召回更全面

6.2 RRF融合算法

RRF(Reciprocal Rank Fusion)是一种简单有效的融合算法:

def rrf_fusion(
    vector_results: list[Document],  # 向量检索结果(已按相似度排序)
    bm25_results: list[Document],    # BM25检索结果(已按分数排序)
    k: int = 60,                      # 平滑常数
) -> list[Document]:
    """RRF融合:倒数排名融合

    原理:每条文档的得分 = 1/(k + rank)
    两路结果中同一文档的得分相加,总分越高排名越靠前

    k=60 是经验值,防止排名靠后的文档得分衰减过快
    """
    scores = {}

    # 向量检索结果赋分
    for rank, doc in enumerate(vector_results):
        doc_id = doc.metadata["chunk_id"]
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

    # BM25结果赋分
    for rank, doc in enumerate(bm25_results):
        doc_id = doc.metadata["chunk_id"]
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

    # 按总分排序
    sorted_ids = sorted(
        scores.keys(),
        key=lambda doc_id: scores[doc_id],
        reverse=True,
    )

    # 返回融合后的文档
    doc_map = {d.metadata["chunk_id"]: d for d in vector_results + bm25_results}
    return [doc_map[doc_id] for doc_id in sorted_ids]

6.3 RRF为什么有效

文档A:向量检索排第2,BM25排第10
  → RRF得分 = 1/(60+2) + 1/(60+10) = 0.016 + 0.014 = 0.030

文档B:向量检索排第8,BM25排第3
  → RRF得分 = 1/(60+8) + 1/(60+3) = 0.015 + 0.016 = 0.031

文档C:只在向量检索排第1,BM25没出现
  → RRF得分 = 1/(60+1) = 0.016

→ 文档B总分最高(两路都表现不错),文档A次之(一路好一路一般),
  文档C较低(只有一路召回)

核心思想:两条链路都召回的文档更可信,单路召回的文档可能 noise 较大。


7. 重排序:二次精筛提升质量

7.1 为什么需要重排序

向量检索和BM25只看"片段本身"的相似度:
  "这个片段和查询词有多像?"

但实际需要的是"这个片段能不能回答用户的问题":
  "这个片段和问题-文档对的相关性有多高?"

重排序模型(Cross-Encoder)直接对"问题+文档"做联合编码,
输出相关性分数,精度远高于向量相似度。

7.2 重排序原理

向量检索(Bi-Encoder):
  问题 ──→ Embedding模型 ──→ 向量A
  文档 ──→ Embedding模型 ──→ 向量B
  相似度 = cos(向量A, 向量B)
  → 问题和文档分别编码,速度快但精度有限

重排序(Cross-Encoder):
  [问题] + [SEP] + [文档] ──→ Cross-Encoder模型 ──→ 相关性分数
  → 问题和文档联合编码,精度高但速度慢

7.3 完整重排序实现

"""重排序精筛实现"""
from langchain_core.documents import Document

class Reranker:
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3"):
        # 加载Cross-Encoder重排序模型
        self.model = load_rerank_model(model_name)

    def rerank(
        self,
        query: str,
        documents: list[Document],
        top_k: int = 5,
    ) -> list[tuple[Document, float]]:
        """对候选文档重排序,返回(文档, 分数)列表"""
        pairs = [(query, doc.page_content) for doc in documents]

        # 批量打分
        scores = self.model.predict(pairs)

        # 按分数排序
        scored_docs = list(zip(documents, scores))
        scored_docs.sort(key=lambda x: x[1], reverse=True)

        return scored_docs[:top_k]

    def filter_evidence(
        self,
        scored_docs: list[tuple[Document, float]],
        threshold: float = 0.3,
    ) -> list[Document]:
        """过滤低分文档,低于阈值的不送入LLM"""
        return [doc for doc, score in scored_docs if score >= threshold]

7.4 重排序在链路中的位置

完整检索链路:

用户问题
    ↓
并行执行:
  ├── 向量检索(召回Top-10)
  └── BM25检索(召回Top-10)
    ↓
RRF融合(20条 → 排序取Top-15)
    ↓
重排序(15条 → 按问题-文档相关性打分)
    ↓
过滤低分证据(阈值0.3,保留Top-5)
    ↓
送入LLM生成回答

8. 完整检索链路代码实现

"""完整RAG检索链路实现"""
import asyncio
from dataclasses import dataclass
from typing import Literal
from langchain_core.documents import Document


@dataclass
class RetrievalResult:
    """检索结果数据结构"""
    documents: list[Document]        # 最终送入LLM的文档
    mode: str                        # 使用的检索模式
    trace: dict                      # 调试追踪信息


class RAGRetriever:
    def __init__(
        self,
        vectorstore,                     # 向量数据库
        bm25_retriever,                  # BM25检索器
        reranker=None,                   # 重排序模型
        vector_top_k: int = 10,
        bm25_top_k: int = 10,
        rerank_top_k: int = 5,
        score_threshold: float = 0.3,
    ):
        self.vectorstore = vectorstore
        self.bm25 = bm25_retriever
        self.reranker = reranker
        self.vector_top_k = vector_top_k
        self.bm25_top_k = bm25_top_k
        self.rerank_top_k = rerank_top_k
        self.score_threshold = score_threshold

    def _vector_search(self, query: str) -> list[Document]:
        """向量检索"""
        return self.vectorstore.similarity_search(
            query, k=self.vector_top_k
        )

    def _bm25_search(self, query: str) -> list[Document]:
        """BM25关键词检索"""
        return self.bm25.search(query, top_k=self.bm25_top_k)

    def _rrf_fusion(
        self,
        vector_results: list[Document],
        bm25_results: list[Document],
        k: int = 60,
    ) -> list[Document]:
        """RRF倒数排名融合"""
        scores = {}
        doc_map = {}

        for rank, doc in enumerate(vector_results):
            doc_id = doc.metadata.get("chunk_id", str(id(doc)))
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
            doc_map[doc_id] = doc

        for rank, doc in enumerate(bm25_results):
            doc_id = doc.metadata.get("chunk_id", str(id(doc)))
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
            doc_map[doc_id] = doc

        sorted_ids = sorted(
            scores.keys(),
            key=lambda x: scores[x],
            reverse=True,
        )

        return [doc_map[doc_id] for doc_id in sorted_ids]

    def _rerank(
        self,
        query: str,
        documents: list[Document],
    ) -> list[tuple[Document, float]]:
        """重排序"""
        if not self.reranker or not documents:
            return [(doc, 0.0) for doc in documents]

        return self.reranker.rerank(
            query, documents, top_k=self.rerank_top_k
        )

    async def retrieve(
        self,
        query: str,
        mode: Literal["vector", "hybrid", "hybrid_rerank", "full"] = "full",
    ) -> RetrievalResult:
        """统一检索入口

        四种模式:
        - vector:纯向量检索
        - hybrid:向量+BM25,RRF融合
        - hybrid_rerank:hybrid+重排序
        - full:hybrid_rerank+证据过滤
        """
        trace = {"query": query, "mode": mode}

        # 1. 向量检索
        vector_results = await asyncio.to_thread(
            self._vector_search, query
        )
        trace["vector_count"] = len(vector_results)

        if mode == "vector":
            return RetrievalResult(
                documents=vector_results[:self.rerank_top_k],
                mode=mode,
                trace=trace,
            )

        # 2. BM25检索(并行)
        bm25_results = await asyncio.to_thread(
            self._bm25_search, query
        )
        trace["bm25_count"] = len(bm25_results)

        # 3. RRF融合
        fused = self._rrf_fusion(vector_results, bm25_results)
        trace["fused_count"] = len(fused)

        if mode == "hybrid":
            return RetrievalResult(
                documents=fused[:self.rerank_top_k],
                mode=mode,
                trace=trace,
            )

        # 4. 重排序
        reranked = self._rerank(query, fused[:15])
        trace["rerank_count"] = len(reranked)

        if mode == "hybrid_rerank":
            return RetrievalResult(
                documents=[doc for doc, _ in reranked],
                mode=mode,
                trace=trace,
            )

        # 5. 过滤低分证据
        evidence = [
            doc for doc, score in reranked
            if score >= self.score_threshold
        ]
        trace["evidence_count"] = len(evidence)
        trace["refused"] = len(evidence) == 0

        return RetrievalResult(
            documents=evidence,
            mode=mode,
            trace=trace,
        )

9. 检索选型决策树

开始
  │
  ├── 数据量 < 10万?
  │     ├── 是 → 用FLAT暴力扫描(测试环境)
  │     └── 否 → 继续
  │
  ├── 是否需要语义理解?
  │     ├── 否 → 只用BM25关键词检索
  │     └── 是 → 继续
  │
  ├── 是否需要精确匹配?
  │     ├── 否 → 只用向量检索
  │     └── 是 → 混合检索(向量+BM25)
  │
  ├── 对精度要求极高?
  │     ├── 否 → hybrid模式(RRF融合)
  │     └── 是 → hybrid_rerank模式(融合+重排序)
  │
  └── 生产环境?
        ├── 否 → vector/hybrid即可
        └── 是 → full模式(融合+重排序+证据过滤)

生产推荐full 模式 = 向量 + BM25(并行)→ RRF 融合 → 重排序 → 过滤低分证据


10. 踩坑清单:检索链路的8个关键问题

序号问题现象原因解决方案
1只用向量检索专有名词/数字召回不到向量对精确匹配弱加BM25做混合检索
2只用BM25检索同义词/转述召回不到BM25不懂语义加向量做混合检索
3串行执行两路检索延迟翻倍没并行asyncio.gather并行
4top-k太大重排序成本爆炸,噪声多召回过多向量/BM25各召回10条,rerank处理15条
5不重排序低质量片段混入,LLM输出差向量相似度≠问题相关性加Cross-Encoder重排序
6不过滤低分证据无关内容送入LLM缺少阈值过滤设score_threshold=0.3
7文档和查询用不同Embedding召回率暴跌向量空间不一致统一使用同一个模型
8切片不带章节标题语义不完整缺少上下文父子分块,子块带父块标题

11. 面试速答版

RAG系统分5大模块:加载解析→文本分块→向量化入库→检索召回→Prompt组装生成。检索有4种方式:向量检索懂语义但精确匹配弱,BM25精确匹配好但不懂语义,混合检索用RRF融合两路结果,重排序用Cross-Encoder精筛问题-文档相关性。生产推荐full模式:向量+BM25并行→RRF融合→重排序→过滤低分证据。记住:向量找意思相近的,BM25找字面匹配的,RRF把两路结果融合,Rerank再精排一遍挑最好的喂给大模型。


12. 总结与延伸

12.1 核心知识点回顾

RAG五模块:加载解析 → 分块 → 向量化入库 → 检索召回 → Prompt生成

四种检索:
  向量检索:语义理解,同义词召回
  BM25检索:精确匹配,关键词命中
  混合检索:RRF融合两路,互补增强
  重排序:Cross-Encoder精筛,提升质量

生产链路:
  向量(Top-10) + BM25(Top-10) ──并行──┐
                                        ↓
                              RRF融合(取Top-15)
                                        ↓
                              重排序(Top-5)
                                        ↓
                              过滤低分(阈值0.3)
                                        ↓
                              送入LLM

12.2 延伸方向

  • 多路召回:向量 + BM25 + 图检索 + 知识图谱,多路融合
  • 查询扩展:对查询做同义词扩展,提升向量检索召回
  • 查询重写:多轮对话中补全指代和省略(已在前文讲解)
  • 自适应检索:根据问题类型自动选择检索策略(精确查询走BM25,开放问题走向量)

13. 文末互动

你的 RAG 项目用的是哪种检索方式——纯向量、纯BM25、还是混合检索?有没有遇到"向量检索召回不到但BM25能命中"的情况?评论区聊聊你的实战经验。

思考题:如果一个知识库里同时包含"产品说明书"(适合向量检索)和"订单查询"(适合BM25精确匹配),你的检索系统应该如何根据问题类型自动选择检索策略?欢迎在评论区讨论。


本文从 RAG 系统全貌出发,完整拆解了五大模块和四大检索方式。如果觉得有帮助,欢迎点赞收藏,后续会更新多路召回和自适应检索的进阶内容。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值