从零构建学术RAG系统:三种检索策略的演进
2026/7/22 4:32:54 网站建设 项目流程

最近我在做一个”学术文献 AI 助手”:把一堆论文 PDF 扔进去,能直接用自然语言问”哪篇论文提出了 X”“Y 方法的优缺点是什么”,并且答案必须给出具体出处。技术栈是典型的 RAG(Retrieval-Augmented Generation),但真上手才发现,检索模块才是这个系统的天花板——检索不到对的段落,LLM 再强也只能一本正经地编。
这篇文章记录我把检索模块从”能用”改到”敢用”的三次演进:V1 BM25 稀疏检索 → V2 向量检索 → V3 混合检索 + RRF 融合 + Cross-Encoder 重排。这不是教科书式的罗列,而是项目里真实的踩坑顺序。三段核心代码全部可运行(写文章当天我实跑过,文中输出都是真实粘贴的),外加一张选型对比表和一份调优清单。

  1. 引言:为什么”给论文做检索”比普通 RAG 更难
    做这个项目的动机很朴素:我电脑里躺着两百多篇 PDF,每次想引用”那篇提出某某方法的论文”,都要靠文件名记忆术配合阅读器的全文搜索,效率感人。这个助手要解决的就是这件事——用自然语言提问,系统给出答案,并附上出处(哪篇论文、哪一节、哪一页)。
    RAG 的道理一句话能讲完:先从语料里检索出相关段落,再把段落塞给 LLM 生成答案。但”生成”只是面子,“检索”才是里子。检索不到,生成就只能编,而且编得理直气壮。所以这个项目里我花时间最多的不是调 prompt,而是检索模块。
    给论文做检索,比给新闻、FAQ 做检索难在四个地方:
    •长文档。一篇论文 8k–20k tokens,必须分块(chunk)。但论文不是一锅粥,它有 Abstract / Intro / Method / Experiments 的骨架,随手按 500 tokens 一刀切,很容易把”方法描述”和”实验结论”缝进同一个块,embedding 出来四不像。
    •专业术语与中英混杂。学术查询里全是 BM25、DPO、LoRA 这种缩写和模型名,还经常中英混排。比如查”DPO 与 PPO 的区别”,既要求检索器精确命中术语缩写,又要求它理解”区别”是在问两个方法的对比——分词级的精确匹配和向量级的语义理解,缺一不可。
    •公式与图表。LaTeX 公式、表格、图注都是信息载体,分块和索引时怎么处理它们,直接决定”第 3 节那个损失函数”这类查询能不能命中。
    •引用溯源。答案必须能回指到具体论文、章节、页码,否则在学术场景就是耍流氓。这要求每个 chunk 从一开始就背着完整元数据走。
    我的演进路线是 V1 → V2 → V3,顺序完全由踩坑决定:先上 BM25 让系统跑起来,被口语化查询打脸后加向量检索,又发现术语查询变”飘”了,最后把两路合并、再加一道重排兜底。
    读完本文你能带走三样东西:三套可直接运行的代码(同一份语料、同一组查询,三版效果差异肉眼可见)、一张选型对比表、一份我在项目里实际使用的调优清单。
  2. 系统整体架构:先把地图画出来
    整个系统分离线和在线两个阶段。离线侧负责把论文变成”可检索”的形态:PDF 解析(PyMuPDF / GROBID)→ 清洗 → 章节感知分块 → 同时建两个索引(BM25 倒排索引 + FAISS 向量索引)。在线侧负责应答:用户查询 → 稀疏、稠密双路召回 → RRF 融合 → Cross-Encoder 重排 → Top-5 结果塞给 LLM 生成答案并附引用。
    flowchart LR
    subgraph 离线索引
    A[论文 PDF 库] --> B[解析与清洗
    PyMuPDF / GROBID]
    B --> C[章节感知分块
    + 元数据 title/doi/section/page]
    C --> D[(倒排索引
    BM25)]
    C --> E[Embedding 模型
    bge-m3]
    E --> F[(向量索引
    FAISS)]
    end
    subgraph 在线检索生成
    Q[用户查询] --> R1[稀疏召回 Top-50]
    Q --> R2[稠密召回 Top-50]
    D --> R1
    F --> R2
    R1 --> M[RRF 融合]
    R2 --> M
    M --> N[Cross-Encoder 重排 Top-5]
    N --> G[LLM 生成
    附引用溯源]
    end
    本文只聚焦图里的检索模块(双索引、双路召回、融合、重排),PDF 解析和 LLM 生成只交代接口,不展开。解析侧的接口约定是”进 PDF、出带元数据的 chunk”:PyMuPDF 负责抽正文文本和版面,GROBID 负责提取标题、作者、参考文献等结构化字段;生成侧的接口约定是”进 Top-K chunk、出带引用的答案”:把 chunk 文本连元数据一起拼进 prompt,要求 LLM 按编号引用来源,最后由代码把编号翻译回具体的论文、章节、页码。检索模块夹在中间,上下游都以 chunk 为”最小货币”打交道——这也是为什么后面三版策略改来改去,接口从来没变过。
    可能有读者会问:V1 阶段明明只用 BM25,为什么架构图上第一天就画了双索引?这是踩坑换来的习惯:索引是离线资产,重建一次就要全量跑一遍,所以哪怕 V1 只用稀疏路,向量索引也该同步建着”养”在那里,等到 V2 上线时直接切换,不用回炉重造。
    先交代一个贯穿全文的设计:元数据。每个 chunk 除文本外还背着一个字典——title / authors / year / doi 或 arXiv id / section / page。这是”引用溯源”的地基:后面三版策略检索出来的不只是”一段文字”,而是”一段知道出处的文字”,生成阶段才能给出规范引用。第 7 节还会看到,把”标题 > 章节名”拼进 chunk 文本本身,顺手还能提升检索质量。
    技术选型与版本:Python 3.10+;rank_bm25 做稀疏索引;sentence-transformers 负责 embedding 与重排模型;faiss-cpu 做向量索引;jieba 负责中文分词。全部 pip 可装,演示规模下不需要 GPU,文末附 requirements。
  3. V1:BM25 稀疏检索——先让系统”能用”
    第一版的目标只有一个:跑通。我选了最经典的 BM25。它的原理一句话:查询和文档共享的词越多、这些词在整个语料里越稀有,文档得分越高。可以理解为 TF-IDF 的概率化改良,外加两个工程修正:词频饱和(一个词出现 10 次不会让得分翻 10 倍,参数 k1 控制饱和速度)和长度归一(长文档不因词多而占便宜,参数 b 控制归一力度):

BM25 的数学公式如下:

score(D,Q)=∑iIDF(qi)⋅f(qi,D)⋅(k1+1)f(qi,D)+k1⋅(1−b+b⋅∣D∣avgdl) \text{score}(D, Q) = \sum_{i} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}score(D,Q)=iIDF(qi)f(qi,D)+k1(1b+bavgdlD)f(qi,D)(k1+1)

其中:

  • DDD表示文档,QQQ表示查询
  • qiq_iqi是查询QQQ中的第iii个词项
  • f(qi,D)f(q_i, D)f(qi,D)是词项qiq_iqi在文档DDD中的词频
  • IDF(qi)\text{IDF}(q_i)IDF(qi)是词项qiq_iqi的逆文档频率,衡量该词在整个语料库中的稀有程度
  • ∣D∣|D|D是文档DDD的长度(词数)
  • avgdl\text{avgdl}avgdl是整个语料库的平均文档长度
  • k1k_1k1是词频饱和参数,控制词频对得分的影响程度(通常取值 1.2-2.0)
  • bbb是长度归一化参数,控制文档长度对得分的影响(通常取值 0.75)

这个公式的核心思想是:词频饱和通过(k1+1)(k_1 + 1)(k1+1)项防止高频词过度影响得分,长度归一化通过1−b+b⋅∣D∣avgdl1 - b + b \cdot \frac{|D|}{\text{avgdl}}1b+bavgdlD项避免长文档仅因词多而占优。当b=1b=1b=1时,长度归一化完全生效;当b=0b=0b=0时,不考虑文档长度影响。
公式看看就好,不用推导。BM25 是信息检索的经典强基线(Robertson & Zaragoza, 2009),直到今天,在 BEIR 这类零样本检索基准上它 依然是让很多新模型头疼的基线(Thakur et al., 2021)——这也是我敢拿它打底的原因。
为了让全文自洽,后面三段代码共用同一份”迷你语料”:6 条一句话版论文摘要(模拟分块后的 chunk),以及两个精心设计的查询——q_exact 含稀有术语,检验精确匹配能力;q_semantic 是语义改写,检验语义泛化能力。

pip install rank_bm25 jieba

import jieba
import numpy as np
from rank_bm25 import BM25Okapi

DOCS = [
“Attention Is All You Need:提出 Transformer 架构,完全基于自注意力(self-attention)机制,摒弃循环结构”, # doc0
“BERT:基于 Transformer 编码器的双向预训练语言模型,通过掩码语言模型(MLM)学习上下文表示”, # doc1
“BM25 是经典的信息检索排序函数,基于词频 TF 与逆文档频率 IDF 的概率模型,是稀疏检索基线”, # doc2
“Retrieval-Augmented Generation (RAG):将参数化语言模型与非参数化的外部知识检索相结合,用于知识密集型任务”, # doc3
“Dense Passage Retrieval (DPR):用双塔编码器把问题和段落编码为稠密向量,用于开放域问答的段落检索”, # doc4
“FAISS:Meta 开源的高效向量相似度搜索库,支持十亿级向量的近似最近邻检索”, # doc5
]
QUERIES = {
“q_exact”: “BM25 的打分公式是什么”, # 含稀有术语 → 检验精确匹配
“q_semantic”: “不用循环结构的序列建模方法”, # 语义改写 → 检验语义泛化
}

tok = lambda s: list(jieba.lcut(s)) # 演示用默认词典;真实项目要加学术自定义词典
bm25 = BM25Okapi([tok(d) for d in DOCS])

for tag, q in QUERIES.items():
scores = bm25.get_scores(tok(q))
order = np.argsort(-scores) # 按得分从高到低排序
print(f"[{tag}] {q}“)
for r, i in enumerate(order[:3]):
print(f” Top{r+1}: doc{i} score={scores[i]:.3f} {DOCS[i][:24]}…")
我机器上的真实输出:
[q_exact] BM25 的打分公式是什么
Top1: doc2 score=3.980 BM25 是经典的信息检索排序函数,基于词频 T…
Top2: doc5 score=0.687 FAISS:Meta 开源的高效向量相似度搜索库…
Top3: doc4 score=0.686 Dense Passage Retrieval …
[q_semantic] 不用循环结构的序列建模方法
Top1: doc0 score=2.468 Attention Is All You Nee…
Top2: doc5 score=0.400 FAISS:Meta 开源的高效向量相似度搜索库…
Top3: doc2 score=0.372 BM25 是经典的信息检索排序函数,基于词频 T…
两组结果都值得掰开看:
•q_exact 是 BM25 的主场:doc2 以 3.980 对 0.687 的悬殊比分碾压,术语型查询稳得毫无悬念。
•q_semantic 要诚实交代:写文章前我本以为这个查询会让 BM25 当场翻车,实际跑出来 doc0 居然是第一。翻看分词结果才发现原因——jieba 把查询切成[‘不用’, ‘循环’, ‘结构’, ‘的’, ‘序列’, ‘建模’, ‘方法’],而 doc0 也被切出了”循环”“结构”两个词,这是字面巧合送的命中,不是语义理解。
巧合有多脆弱?把同一个意思换得更口语化一点,立刻现原形:
[q_spoken] 那个不用 RNN 的模型是怎么做的
Top1: doc2 score=3.581 BM25 是经典的信息检索排序函数,基于词频 T…
Top2: doc1 score=2.203 BERT:基于 Transformer 编码器的…
Top3: doc3 score=1.837 Retrieval-Augmented Gene…
(doc0 实际得分 1.037,排在第 6/6 名)
正确答案 doc0 直接垫底,Top1 反而是那篇讲 BM25 的论文。“RNN”和”循环结构”在字面层面是两个世界,BM25 没有桥。
学术场景下的优点:零训练、毫秒级(示意量级,下同)、结果可解释——每个得分都能对到具体的词,调试时能直接看出”这篇为什么被召回”,这在排查 bad case 时非常救命;对术语缩写、模型名、作者名、年份这类”精确字符串”天然友好——而这恰是学术查询的高频模式。缺点:同义词、改写、跨语言查询基本无能为力;对分词质量敏感;公式符号基本失效。
分词这一点值得展开两句,它是中文 BM25 最容易被低估的坑。jieba 默认词典不认识学术圈的黑话,像 “LoRA”、“DPO” 这类缩写一定要 jieba.add_word() 加进自定义词典,否则可能被切碎;英文术语建议同时保留原词 token(别把 “Self-Attention” 拆成 “self” 和 “attention” 就完事),我的做法是中文走 jieba、连续英数串整体保留,两路 token 混合进索引。另外 BM25Okapi 的 k1 和 b 参数我用的是默认值(k1=1.5, b=0.75),这也是大多数系统的常用起点,小语料上调参意义不大。
工程小贴士:rank_bm25 是纯 Python 实现,适合原型验证;语料上到百万级 chunk 后请直接换 Elasticsearch / OpenSearch,这里不展开。
V1 小结:术语查询稳,口语化提问翻车——这就是上 V2 的直接动机。
4. V2:稠密向量检索——补上语义理解
V2 的思路换了个物种:用预训练模型把查询和文档都编码成向量,语义相近则向量距离相近,再用 ANN(近似最近邻)索引快速找最近邻。三个关键概念三句话讲清:
•双塔编码器:query 和 doc 用同一个模型但独立编码。好处是文档向量可以在离线阶段全部算好存进索引,在线只需编码查询,检索就是一次向量近邻查询,极快。
•余弦相似度 = 归一化后的内积:代码里 normalize_embeddings=True 配上 FAISS 的 IndexFlatIP(IP 即内积),算出来的就是余弦相似度。
•ANN 索引的取舍:IndexFlatIP 是暴力全扫描,结果精确、几万条内毫无压力;数据量大了再换 IVF / HNSW,用一点精度换数量级的速度。
模型选择上,演示代码用 BAAI/bge-small-zh-v1.5(约 100MB,CPU 秒加载);生产环境建议直接上 bge-m3,第 7 节展开。另外如实交代一句:我写文章的环境里 huggingface.co 不可达,模型是通过 ModelScope 镜像下载的同一个模型(AI-ModelScope/bge-small-zh-v1.5);网络正常的读者直接 SentenceTransformer(“BAAI/bge-small-zh-v1.5”) 即可,网络受限就用 ModelScope 下载到本地再传路径,行为完全一致。

pip install sentence-transformers faiss-cpu

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer(“BAAI/bge-small-zh-v1.5”) # 演示用小模型,生产可换 bge-m3
emb = model.encode(DOCS, normalize_embeddings=True) # 归一化后内积 = 余弦相似度
index = faiss.IndexFlatIP(emb.shape[1]) # 原型期暴力检索即可
index.add(np.asarray(emb, dtype=“float32”))

for tag, q in QUERIES.items():
qv = model.encode([q], normalize_embeddings=True).astype(“float32”)
D, I = index.search(qv, 3) # D=相似度,I=文档下标
print(f"[{tag}] {q}“)
for r, (d, i) in enumerate(zip(D[0], I[0])):
print(f” Top{r+1}: doc{i} cos={d:.3f} {DOCS[i][:24]}…")
真实输出:
[q_exact] BM25 的打分公式是什么
Top1: doc2 cos=0.601 BM25 是经典的信息检索排序函数,基于词频 T…
Top2: doc1 cos=0.397 BERT:基于 Transformer 编码器的…
Top3: doc4 cos=0.365 Dense Passage Retrieval …
[q_semantic] 不用循环结构的序列建模方法
Top1: doc0 cos=0.507 Attention Is All You Nee…
Top2: doc4 cos=0.465 Dense Passage Retrieval …
Top3: doc1 cos=0.463 BERT:基于 Transformer 编码器的…
两组结果同样掰开看:
•q_semantic 被救活了:doc0 以 0.507 拿第一。但注意第二名是 doc4(DPR,0.465),差距只有 0.04——“检索”主题的论文在语义空间里和”序列建模”查询靠得也不远,语义干扰项开始贴身。
•q_exact 虽然 doc2 仍是第一,但对比一下优势幅度:BM25 是 3.980 对 0.687(近 6 倍差),向量路是 0.601 对 0.397(约 1.5 倍)。术语查询的”稳赢感”没了,BERT、DPR 这些语义近邻全挤进了 Top3。稀有术语、编号、人名在 embedding 里容易被”语义稀释”——模型对高频共现的语义敏感,对字符串本身并不敏感。
真正的意外在口语化查询上。我原本的预期是”向量检索总该行了吧”,实跑结果当场教育了我:
[q_spoken] 那个不用 RNN 的模型是怎么做的
Top1: doc1 cos=0.542 BERT:基于 Transformer 编码器的…
Top2: doc2 cos=0.461 BM25 是经典的信息检索排序函数,基于词频 T…
Top3: doc3 cos=0.435 Retrieval-Augmented Gene…
(doc0 实际 cos=0.392,排在第 5/6 名)
doc0 排第 5,Top1 是 BERT——BERT 确实是 Transformer 家族的,但答非所问。演示用的小模型对”RNN ↔ 循环结构”这种跨表述映射并不牢靠。这个真实结果比预想的更有价值:向量检索不是银弹,效果同时受模型能力和查询表述影响。这个坑给第 5 节的重排埋下了伏笔,也提醒演示模型和生产模型的差距——别拿 small 级模型的行为当最终结论。
学术场景下的优点:语义泛化强,对改写、跨语言查询鲁棒,特别适合”我知道意思但叫不出术语”的探索式查询;对长摘要块的整体语义把握好于词匹配。缺点:稀有术语/编号/人名可能被语义稀释;chunk 过长会进一步稀释语义(第 7 节谈分块);结果不可解释;效果依赖 embedding 模型的领域适配;并且引入了模型推理成本和向量索引的运维。
工程小贴士:原型期 IndexFlatIP 完全够用;chunk 上到十万级再考虑 HNSW / IVF,或者干脆上 Chroma / Qdrant 这类现成向量库。
V2 小结:语义查询救活了,但精确查询的优势变薄、近义干扰开始插队——V3 的动机齐了。
5. V3:混合检索 + RRF 融合 + Cross-Encoder 重排——生产级方案
V1 保精确、V2 保语义,两者的错误模式恰好互补:把它们并起来,召回就更全;再花一点算力把候选精排一遍,排序就更准。混合检索通常优于任何单一检索,这在 BEIR 后续研究(如 Luan et al., TACL 2021 对稀疏+稠密混合表示的系统分析)和工业界实践里都是通行结论。V3 由两个机制组成:
•RRF 融合(Reciprocal Rank Fusion):只用”排名”不用”原始分数”做融合,score = Σ 1/(k+rank),k 常取 60。为什么要绕开原始分数?因为 BM25 的分(0 到十几)和余弦相似度(0 到 1)尺度根本不可比,加权求和就得调权重,而 RRF 免调参且效果稳(Cormack, Clarke & Büttcher, SIGIR 2009)。
•Cross-Encoder 重排:双塔检索是”查询和文档各自编码、再比向量”,信息在编码阶段就被压缩掉了;Cross-Encoder 则把 query 和候选文档拼接后一起过模型,让两者在注意力层面充分交互,打分明显更准,但慢——所以它只用于对双路召回的 Top-N(比如 50)候选做精排,而不是全库扫描(Nogueira & Cho, 2019)。
整条流水线长这样:
flowchart TB
Q[查询] --> S[BM25 召回 Top-50] & T[向量召回 Top-50]
S --> R[“RRF 融合
score = Σ 1/(60+rank)”]
T --> R
R --> C[取并集候选 Top-50]
C --> X[Cross-Encoder 逐对打分]
X --> O[重排后 Top-5 进入生成]
代码上注意一个”演进感”:V3 不重写检索器,而是把前两节的 bm25、index、model 当组件直接复用:

复用前两节的 bm25 / index / model,体现"演进"而非"重写"

import numpy as np
from sentence_transformers import CrossEncoder

RRF_K, RECALL_N, FINAL_N = 60, 50, 3
reranker = CrossEncoder(“BAAI/bge-reranker-base”) # 只加载一次,千万别写进查询函数里

def hybrid_search(query: str):
# 1) 双路召回:各取 Top-RECALL_N
bm25_rank = np.argsort(-bm25.get_scores(tok(query)))[:RECALL_N]
qv = model.encode([query], normalize_embeddings=True).astype(“float32”)
vec_rank = index.search(qv, RECALL_N)[1][0]
vec_rank = vec_rank[vec_rank >= 0] # 语料不足 RECALL_N 时 FAISS 用 -1 填充,必须过滤
print(f" BM25 路 Top3: {[int(i) for i in bm25_rank[:3]]}"
f" 向量路 Top3: {[int(i) for i in vec_rank[:3]]}“)
# 2) RRF 融合:只用排名,不碰两路的原始分数(尺度不可比)
fused = {}
for ranks in (bm25_rank, vec_rank):
for r, doc_id in enumerate(ranks):
fused[doc_id] = fused.get(doc_id, 0) + 1 / (RRF_K + r + 1)
cands = sorted(fused, key=fused.get, reverse=True)[:RECALL_N]
print(f” RRF 融合后 Top3: {[int(i) for i in cands[:3]]}")
# 3) Cross-Encoder 精排:query 与候选拼接后逐对打分,慢但准
pairs = [(query, DOCS[i]) for i in cands]
ce = np.asarray(reranker.predict(pairs))
order = np.argsort(-ce)
return [(int(cands[i]), float(ce[i])) for i in order[:FINAL_N]]

for tag, q in [(“q_exact”, QUERIES[“q_exact”]),
(“q_semantic”, QUERIES[“q_semantic”]),
(“q_spoken”, “那个不用 RNN 的模型是怎么做的”)]:
print(f"[{tag}] {q}“)
for r, (i, s) in enumerate(hybrid_search(q)):
print(f” 最终Top{r+1}: doc{i} ce_score={s:.3f} {DOCS[i][:24]}…")
print()
真实输出(这是全文我最喜欢的一组数字):
[q_exact] BM25 的打分公式是什么
BM25 路 Top3: [2, 5, 4] 向量路 Top3: [2, 1, 4]
RRF 融合后 Top3: [2, 1, 4]
最终Top1: doc2 ce_score=0.997 BM25 是经典的信息检索排序函数,基于词频 T…
最终Top2: doc3 ce_score=0.005 Retrieval-Augmented Gene…
最终Top3: doc4 ce_score=0.003 Dense Passage Retrieval …

[q_semantic] 不用循环结构的序列建模方法
BM25 路 Top3: [0, 5, 2] 向量路 Top3: [0, 4, 1]
RRF 融合后 Top3: [0, 2, 1]
最终Top1: doc0 ce_score=0.977 Attention Is All You Nee…
最终Top2: doc2 ce_score=0.021 BM25 是经典的信息检索排序函数,基于词频 T…
最终Top3: doc4 ce_score=0.014 Dense Passage Retrieval …

[q_spoken] 那个不用 RNN 的模型是怎么做的
BM25 路 Top3: [2, 1, 3] 向量路 Top3: [1, 2, 3]
RRF 融合后 Top3: [2, 1, 3]
最终Top1: doc0 ce_score=0.003 Attention Is All You Nee…
最终Top2: doc2 ce_score=0.001 BM25 是经典的信息检索排序函数,基于词频 T…
最终Top3: doc1 ce_score=0.000 BERT:基于 Transformer 编码器的…
逐个解读:
•q_exact:两路召回的 Top1 都是 doc2,RRF 后 doc2 居首,Cross-Encoder 给出 0.997 的高分,断层第一,第二名只有 0.005。
•q_semantic:两路 Top1 都是 doc0,CE 0.977 稳定第一,V2 里贴身紧逼的 DPR(doc4)被压到了第三。
•q_spoken:最有戏剧性的一条。前两版里 doc0 一个垫底一个第五,这里 BM25 路和向量路的 Top3 也都没有它,RRF 融合后它依然不在前三——但因为 50 的召回深度把它兜进了候选集,Cross-Encoder 逐对看完”那个不用 RNN 的模型”和”摒弃循环结构”之后,直接把 doc0 捞到了第一。不过要诚实:0.003 的绝对分并不高,说明 base 级 reranker 在这个查询上只是”矮子里拔将军”,生产环境换更大的 reranker(如 bge-reranker-v2-m3)会稳得多。
这组对照把 V3 两个机制的分工讲清楚了:融合负责”召回更全”(一路漏掉的,另一路补上),重排负责”排得更准”(候选集里的正确答案往前顶)。在我的项目语料上也观察到同样的典型现象:两路召回列表里各自漏掉的文档常被另一路补上;重排后正确答案在 Top-3 的稳定性肉眼可见地提升。
踩坑实录两条:第一,FAISS 在语料不足 k 条时会用 -1 填充返回结果,不过滤的话 doc(-1) 会被当成正常候选送进 RRF——我第一版就踩了,输出 Top3 里混进一个 -1,读者照抄时引以为戒。第二,reranker 模型要在函数外加载一次;写进查询函数里,每次查询都重新加载模型,延迟直接爆炸。
代价与边界:两路索引加一次重排推理,端到端延迟从毫秒级涨到百毫秒级(示意量级,与硬件和数据规模相关),运维对象从一个库变成”双索引 + 重排服务”。还有一条硬约束:重排只能重排召回到的候选,召回截断就是重排的天花板——先保证 Recall@50,再谈精排(本演示只有 6 条语料,RECALL_N=50 等于全库覆盖,天花板咬不到人;真实规模的语料上它才会露出獠牙)。三个主要调优旋钮:RRF 的 k、双路召回深度、重排候选数。补一句调参直觉:RRF 的 k 对结果并不敏感,原论文扫过多个取值,60 附近都行,不用花精力细调;真正影响大的是召回深度和重排候选数,这两个才是要拿评估集说话的地方。
学术场景下的优点:术语精确与语义泛化兼得;重排对”字面相近但答非所问”的干扰块(比如同领域、不同方法的论文段落)纠偏明显;进入生成环节的 Top-K 质量直接提升,引用溯源的可靠性跟着水涨船高。
6. 实战调优经验(全文干货密度最高的一节)
策略选型只是上半场,下面这些才是我项目里真正磨出来的经验。
6.1 分块策略:对学术文献最关键的一步
分块质量对最终效果的影响,比换模型更大。chunk 过长会导致 embedding 语义稀释,过短又会丢失上下文,我的做法:
•优先”章节感知”分块:按 Abstract / Intro / Method / Experiments 的 section 边界切;超长 section 再按 300–500 tokens 二次切分,相邻块重叠 10–15%(约 50 tokens),避免关键句被拦腰截断。
•chunk 头部拼”标题路径”:把文本组织成论文标题 > 章节名 > 正文。同一个词在标题路径里出现,BM25 更容易命中文题匹配的查询,embedding 也能拿到”这段话出自哪篇论文哪一节”的上下文。
•公式保留 LaTeX 源码进 chunk:术语型查询能命中公式里的符号,但别指望 embedding 理解公式语义;图表的 caption 单独成块,并关联正文中引用该图的句子,“图 3 说明什么”这类查询才有救。
•元数据必须齐全:title / authors / year / doi / section / page 一个都不能少,否则生成阶段给不出规范引用,学术场景直接不及格。
分块之前还有一关容易被忽略:PDF 解析本身就是个坑。双栏排版抽出来的文本顺序经常是错的,页眉页脚、页码、水印会混进正文,直接拿去分块等于给索引投毒。我的经验是解析后先做一轮清洗(去页眉页脚、修复断行、合并被拆散的段落),元数据交给 GROBID 这类专门工具提取,别自己写正则硬啃。
6.2 Embedding 模型选择
•中英混排 + 长文本,首选 BAAI/bge-m3:dense 和 sparse 一体,还能直接当一路稀疏检索用,和第 5 节的架构天然契合。纯英文学术语料可以试 SPECTER2,它用引文关系训练,对论文语义的表征更贴合学术场景。
•快速验证流程:固定分块方案和查询集,只换模型比 Recall@10,别凭榜单拍脑袋——榜单上的赢家不一定懂你的语料,第 4 节那个 small 模型的翻车现场就是提醒。
6.3 重排模型
•开源首选 bge-reranker 系列:中文场景 bge-reranker-v2-m3,英文场景 bge-reranker-large。重排候选数 20–50 是延迟与收益的甜点区,再多收益递减明显。
•什么情况不值得上重排:语料小(一万块以内)、查询模式简单、延迟敏感——这时候 V1+V2 的 RRF 融合就够了,别为了架构图好看而加组件。
6.4 评估先行
•造 50–100 条”问题 → 应命中的 chunk”标注集,从自己的真实提问里积累即可,难例优先——那种”换个说法就搜不到”的查询最该进集子。指标用两个就够:Recall@K(前 K 条里有没有命中正确 chunk)和 MRR(正确答案排得越靠前分越高,第一名记 1 分、第二名记 1/2,依此类推取平均)。
•每改一个变量——分块、模型、重排——都跑一遍这个小评估,避免”感觉变好了”式调优。评估脚本二十行就能写完,核心就是循环查询集、记录正确 chunk 的名次,篇幅所限不贴正文,放在文末仓库里。
•说个反直觉的体会:本文这份 6 条语料、3 个查询的演示,其实就是”评估先行”的最小实践——没有这组固定查询,V1 到 V3 的差异就只能靠嘴描述。小评估集不贵,先造起来再说。
7. 总结与后续方向
回顾三版演进的一句话画像:V1 让系统能用,V2 让它听得懂人话,V3 让它稳。核心教训只有一条:检索质量决定 RAG 的上限,而且没有单一策略通吃——BM25 的精确、向量检索的语义、重排的精排能力,是在真实查询的毒打下一件件补齐的。那个口语化查询在三个版本下的命运(BM25 垫底、小向量模型第五、加重排后回到第一),就是这条教训最浓缩的演示。
如果只能记住一件事,我希望是第 6 节那句:先 BM25 打底,语义不够加向量,质量不够上重排。别在第一天就追全套架构,也别指望单一路径走到黑。
这个系统还远远没到终点,后续方向列出来,附关键词方便大家自查:
•查询侧:HyDE(让 LLM 先生成假想答案再去检索)、query 改写/扩展、多轮对话里的指代消解。
•索引侧:引用图谱增强(GraphRAG 的思路,论文天然带引用网络)、用 bge-m3 的 dense+sparse 双输出做”单模型混合检索”,省掉一路索引。
•评估侧:RAGAS 这类端到端评估框架,把”答案对不对、引用准不准”也纳入指标。
•多模态:论文图表的图像 embedding,让”图 3 的趋势说明什么”成为一等公民查询。
文中三段代码拼起来就是一个可独立运行的完整脚本,连同 requirements 和最小评估脚本一起放在我的 GitHub 仓库(地址见评论区置顶)。如果你也在学术语料上搞过检索,欢迎在评论区分享你的踩坑——尤其是公式和图表分块这块,我至今没有特别满意的方案。
参考文献
1.Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval.(BM25 经典出处)
2.Cormack, G. V., Clarke, C. L. A., & Büttcher, S. (2009). Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009.(RRF 出处)
3.Nogueira, R., & Cho, K. (2019). Passage Re-ranking with BERT. arXiv:1901.04085.(Cross-Encoder 重排代表作)
4.Thakur, N., et al. (2021). BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS 2021 Datasets and Benchmarks.(“BM25 仍是强基线、混合检索更优”的基准依据)
5.Lewis, P., et al. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS 2020;Karpukhin, V., et al. (2020). Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020.(演示语料中 doc3/doc4 的原始论文,顺带致敬)
6.Luan, Y., Eisenstein, J., Toutanova, K., & Collins, M. (2021). Sparse, Dense, and Attentional Representations for Text Retrieval. TACL 2021.(稀疏+稠密混合表示的系统分析,“混合优于单路”的文献锚点)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询