RAG系统核心结构与四大检索方式全解析:从架构设计到工程落地
导读:RAG(检索增强生成)看起来简单——“把文档切成块,转成向量,用户提问时搜一下,把结果丢给大模型回答”。但真到生产环境,你会发现检索不准、召回不全、模型还是爱瞎编。问题出在"每一步都有坑"。本文从 RAG 的 5 大核心模块讲起,深度拆解向量检索、关键词检索、混合检索、重排序 4 种检索方式的原理、适用场景和组合策略,附完整代码和选型决策树。
适合读者:
- 正在搭建或优化 RAG 系统的开发者
- 需要理解 RAG 全貌和检索选型逻辑的工程师
- 准备 RAG 方向面试、需要系统回答"RAG系统结构"的同学
- 对混合检索(向量+关键词+重排序)链路感兴趣的技术人员
阅读收益:
- 掌握 RAG 5 大核心模块的职责和数据流转
- 理解 4 种检索方式的原理、优缺点和适用场景
- 学会设计混合检索链路(向量+BM25→RRF融合→重排序)
- 获得可直接落地的检索选型决策树和代码实现
- 了解工业界推荐的"hybrid_rerank"完整链路
目录
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并行 |
| 4 | top-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 系统全貌出发,完整拆解了五大模块和四大检索方式。如果觉得有帮助,欢迎点赞收藏,后续会更新多路召回和自适应检索的进阶内容。



