计算感知RAG:检索与重排的算力权衡实践
在开发 RAG(检索增强生成)系统时,检索和重排往往被当成两个独立环节来处理:先拉向量库,再做重排,最后拼 Prompt 丢给大模型。但真正把系统落到生产环境,你会发现一个很棘手的问题:每一步都在消耗计算资源,而计算资源和回答质量之间并不是简单的线性关系。有时候多召回几段文本,重排模型大了几个档次,效果提升却很有限,反而把响应延迟和 GPU 成本拉高了一大截。
近期看到 SciRet 这样一个以“计算感知(Compute-Aware)”为核心视角、针对科学文献 RAG 场景的检索与重排实证研究,觉得这个方向对做知识库问答、论文解读、技术文档问答的开发者都很有参考价值。本文会围绕 SciRet 的研究思路展开,拆解它关注的核心问题,并结合常见 RAG 框架给出可落地的实验设计与工程配置思路。如果你正在纠结“检索到底召回多少条”“重排模型选多大”“RAG 效果怎么评估”,这篇文章可以帮你理清头绪。
1. 背景:为什么 RAG 的检索与重排需要被重新审视
1.1 从 RAG 的基本链路说起
RAG 的经典链路可以概括为四个步骤:
- 文档加载与解析。
- 文本切块(Chunking)与向量化。
- 向量检索召回候选文本。
- 对候选文本做重排(Rerank),把最相关的内容送入大模型生成回答。
很多入门教程会把重点放在第 2 步和第 3 步,比如如何选 Chunk 大小、用哪种 Embedding 模型、向量库选 Milvus 还是 Qdrant。但实际上,大部分线上 RAG 系统的效果瓶颈并不在生成端,而在“召回质量”和“排序质量”。
召回质量决定了大模型能不能“看到”正确答案所在的段落。如果检索回来的一大堆文本都不相关,那么无论 Prompt 写得再好,大模型也只能基于噪声生成答案。重排的作用则是在召回的候选集中做二次筛选,把相关性最高的段落排到前面,同时压缩真正送入大模型的上下文长度。
1.2 科学文献场景的特殊性
SciRet 把研究对象聚焦在“Scientific RAG”,也就是面向论文、技术报告、实验文档的问答场景。这类场景和通用知识库问答相比,有几个明显的差异:
- 术语密度高:论文里充满了缩写、专有名词、公式、引用编号,普通切块方式很容易把一个完整的术语或公式拦腰截断。
- 语义粒度细:科学文献的答案往往集中在某一小段,而不是整篇文档,需要检索器具备更强的段落级语义理解能力。
- 结构复杂:论文有摘要、引言、方法、实验、结论等结构,不同部分的信息价值差异很大,单纯的向量相似度难以体现这种结构信息。
- 评估成本高:科学问题的答案通常需要专家标注,自动评估指标(如命中率、准确率)和人工判定的相关性之间可能存在较大偏差。
正是因为这些特殊性,SciRet 这类研究才强调“计算感知”,也就是在评估检索和重排效果时,把计算开销作为一个关键维度,而不是只关心准确率。
1.3 什么是“Compute-Aware”研究视角
传统的信息检索研究通常以“效果指标”为核心,比如 Recall@K、MRR(Mean Reciprocal Rank)、NDCG(Normalized Discounted Cumulative Gain)。而计算感知的研究视角会额外引入一组问题:
- 为了提升 1 个百分点的 Recall,需要多消耗多少倍的检索时间?
- 更大的重排模型带来的增益,是否值得它在 GPU 上占用的显存和延迟?
- 在固定计算预算下,应该优先扩大召回数量,还是升级重排模型?
换句话说,Compute-Aware 研究关注的不是“哪种方法最好”,而是“在给定的算力约束下,哪种配置组合最划算”。这对实际工程落地特别重要,因为线上服务的延迟和成本都有硬性指标,不可能无限堆算力。
2. SciRet 研究的核心问题拆解
SciRet 虽然是一篇实证研究,但它的选题思路可以拆解成若干个可以迁移到日常开发中的问题。下面逐个展开分析。
2.1 检索器与重排器的组合如何影响最终效果
在 RAG 系统中,检索器和重排器并不是独立发挥作用的。检索器决定了候选集的上限,重排器决定了最终送入 Prompt 的内容质量。SciRet 这类研究会系统比较:
- 稀疏检索(如 BM25)与密集检索(如向量相似度)在不同领域数据上的表现差异。
- 混合检索(稀疏 + 密集)相对单一检索方式带来的提升幅度。
- 不同规模的重排模型(如小型的 cross-encoder 与大型跨编码器)对最终答案质量的影响。
从工程角度看,比较经典的组合方式有下面几种:
| 检索方式 | 重排方式 | 适用场景 | 计算成本 |
|---|---|---|---|
| 纯向量检索 | 不重排 | 原型验证、对延迟极度敏感的场景 | 低 |
| 纯向量检索 | 小型 Cross-Encoder 重排 | 通用知识库问答 | 中 |
| 混合检索(BM25 + 向量) | 小型 Cross-Encoder 重排 | 专业术语较多的场景 | 中高 |
| 混合检索 | 大型重排模型 | 对回答质量要求极高的场景 | 高 |
2.2 Chunk 切分策略对检索上限的影响
SciRet 关注的另一个核心问题是 Chunk 切分。科学文献中,一个“理想”的 Chunk 应该满足两个条件:
- 语义完整,能够独立表达一个事实或论点。
- 粒度适中,既能被检索器有效匹配,又不会因为过长而稀释相关性。
在实践中,Chunk 大小通常设置在 256 到 1024 个 token 之间,但单纯调整大小并不够。更关键的是切分方式:
- 固定窗口切分:实现简单,但容易切断语义。
- 递归字符切分:按段落、句子、标点逐级切分,对普通文本效果好。
- 语义切分:基于 embedding 相似度判断句子边界,适合科学文献。
- 结构感知切分:按 Markdown 标题、LaTeX 章节结构切分,适合论文 PDF 转化后的文本。
SciRet 研究的价值在于,它会量化不同切分策略对后续检索和重排的影响,而不是孤立地看切分结果。
2.3 Top-K 数量与上下文窗口的权衡
检索后送入大模型的文本数量(Top-K)是 RAG 系统中的关键超参数。K 值越大,大模型能看到的信息越多,但也会引入更多噪声,同时增加生成阶段的输入 token 数,进而提高成本和延迟。
SciRet 这类实证研究会关注:
- 在固定重排器下,Top-K 从 3 增加到 10,效果提升是否显著。
- 在固定上下文窗口(如 4K、8K、32K)下,检索段数增加后,有效信息占比是否下降。
- 重排器能否在 Top-K 较大的情况下,通过精准排序降低噪声干扰。
工程上一个比较实用的策略是:检索阶段多召回(比如 K=20),重排阶段少保留(比如 K=5)。这样既保证了召回率,又控制了上下文噪声。
2.4 重排器参数规模与效果的关系
重排通常使用 Cross-Encoder 模型,它能同时编码 Query 和文档,计算相关性分数,因此效果优于向量检索的双塔结构。但 Cross-Encoder 的计算开销随着参数规模增长非常明显。
SciRet 关注的核心矛盾在于:重排器的参数规模增大,带来的效果提升是否值得额外的计算成本。小型模型(如几亿参数的版本)在 CPU 上也能跑,大型模型则需要 GPU 加速,部署成本和延迟都会显著上升。
一个直观的判断方法是:在小规模测试集上对比不同重排器的排序质量,再结合线上延迟要求选择模型。如果小型重排器已经能把相关文档排在 Top 5,那么换用大型模型的意义就不大。
3. 环境准备与实验设计
要复现 SciRet 这类研究的思路,并不需要完整复刻它的实验环境。我们可以在自己的 RAG 项目中,设计一组小规模的对照实验,来衡量检索、重排和计算成本之间的关系。
3.1 推荐的技术栈
下面是一个比较通用的 RAG 实验技术栈,重点在于方便快速迭代:
| 组件 | 推荐方案 | 说明 |
|---|---|---|
| 开发语言 | Python 3.10+ | 生态最丰富 |
| RAG 框架 | LlamaIndex 或 LangChain | 两者都支持检索、重排的插拔式设计 |
| 向量数据库 | Chroma 或 Qdrant | 本地开发用 Chroma 最方便,数据量大的场景用 Qdrant |
| Embedding 模型 | BAAI/bge-small-zh-v1.5 或 OpenAI embedding | 中文场景推荐 bge 系列 |
| 重排模型 | BAAI/bge-reranker-base 或 Cohere Rerank | 开源场景优先 bge-reranker |
| 评估框架 | RAGAS 或自定义脚本 | 用于评估 Faithfulness、Answer Relevance 等指标 |
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
3.2 实验设计模板
在复现 SciRet 思路时,建议按照下面这个模板设计实验:
- 固定数据集:准备一批科学文献段落,以及对应的 Query 集合。
- 确定变量:每次只改变一个变量,其他保持不变。
- 记录指标:同时记录效果指标和计算指标。
- 对比基线:设置一个最简单的基线(比如纯向量检索 + 不重排),所有改进都与基线对比。
实验矩阵可以这样设计:
| 实验组 | 检索方式 | 重排方式 | Top-K | Chunk 大小 |
|---|---|---|---|---|
| Baseline | 向量检索 | 无 | 5 | 512 |
| 实验 A | 向量检索 | 小型重排 | 5 | 512 |
| 实验 B | 混合检索 | 小型重排 | 5 | 512 |
| 实验 C | 混合检索 | 小型重排 | 10 | 512 |
| 实验 D | 混合检索 | 小型重排 | 10 | 256 |
通过这张表,可以系统分析每个变量对结果的影响。
4. 用代码实现一个计算感知的实验框架
下面通过一个完整的 Python 示例,演示如何构建一个可以对比不同检索和重排配置的最小实验框架。
4.1 创建项目结构
sci_ret_demo/ ├── data/ # 存放待检索的文档 ├── experiments/ # 实验结果输出 ├── src/ │ ├── __init__.py │ ├── ingest.py # 文档加载、切块、写入向量库 │ ├── retrievers.py # 不同检索方式的封装 │ ├── rerankers.py # 重排器封装 │ └── evaluate.py # 评估脚本 └── requirements.txt4.2 安装依赖
pip install llama-index-core llama-index-readers-file llama-index-vector-stores-chroma llama-index-postprocessor-cohere-rerank chromadb sentence-transformers不同版本对 API 的封装略有差异,如果遇到导入错误,建议根据实际安装版本查阅对应文档。
4.3 文档索引模块
先实现文档加载、切块和向量化存储的功能。
# 文件路径:sci_ret_demo/src/ingest.py from llama_index.core import SimpleDirectoryReader from llama_index.core.node_parser import SentenceSplitter from llama_index.core.schema import TextNode from llama_index.core.vector_stores import VectorStoreIndex from chroma import PersistentClient def load_documents(data_dir: str): """加载目录下的所有文档""" reader = SimpleDirectoryReader(data_dir) documents = reader.load_data() return documents def build_nodes(documents, chunk_size: int = 512, chunk_overlap: int = 50): """ 将文档切分为节点,chunk_size 是实验中的关键变量 """ splitter = SentenceSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, ) nodes = splitter.get_nodes_from_documents(documents) return nodes def create_index(nodes, collection_name: str = "sci_ret_demo"): """ 创建向量索引,这里使用 Chroma 作为持久化存储 """ client = PersistentClient(path="./chroma_data") service_context = None # 实际使用时需要配置 embedding model # 这里需要根据 LlamaIndex 版本传入 service_context 或 settings # 建议在外部统一初始化 embedding 模型后传入 vector_store = client.get_or_create_collection(collection_name) # 在更高版本中,需要将 vector_store 与 Index 关联 index = VectorStoreIndex.from_nodes(nodes) return index注意,上面的代码是核心片段,实际运行时需要根据你安装的 LlamaIndex 版本补全 Embedding 模型的初始化逻辑。
4.4 检索与重排模块
然后封装检索和重排的对比逻辑。
# 文件路径:sci_ret_demo/src/retrievers.py from llama_index.core import VectorStoreIndex from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.schema import QueryBundle def retrieve_top_k(index: VectorStoreIndex, query_text: str, top_k: int = 5): """ 向量检索 Top-K """ retriever = VectorIndexRetriever( index=index, similarity_top_k=top_k, ) nodes = retriever.retrieve(QueryBundle(query_text)) return nodes# 文件路径:sci_ret_demo/src/rerankers.py from llama_index.core.postprocessor import SentenceTransformerRerank def build_reranker(model_name: str = "BAAI/bge-reranker-base", top_n: int = 3): """ 构建重排器,这里以 bge-reranker 为例 """ reranker = SentenceTransformerRerank( model=model_name, top_n=top_n, ) return reranker4.5 评估脚本
评估部分需要实现两个维度的指标:效果指标和计算指标。
# 文件路径:sci_ret_demo/src/evaluate.py import time from dataclasses import dataclass from typing import List, Optional @dataclass class ExperimentResult: config_name: str recall_at_k: float mrr: float latency_ms: float cost_notes: str def calculate_hit_rate(retrieved: List[str], relevant_ids: set) -> float: """计算命中率:检索结果中是否包含相关文档""" if not retrieved: return 0.0 hits = sum(1 for doc_id in retrieved if doc_id in relevant_ids) return hits / len(retrieved) def calculate_mrr(retrieved: List[str], relevant_ids: set) -> float: """计算 MRR:第一个相关结果排名的倒数""" for idx, doc_id in enumerate(retrieved, start=1): if doc_id in relevant_ids: return 1.0 / idx return 0.0 def run_experiment( config_name: str, retrieve_func, rerank_func: Optional, query: str, relevant_ids: set, top_k: int = 10, final_n: int = 3, ): """ 运行单个实验,记录效果和耗时 """ start = time.time() # 召回阶段 nodes = retrieve_func(query, top_k) retrieved_docs = [n.node.node_id for n in nodes] if nodes else [] # 重排阶段 if rerank_func is not None: reranked = rerank_func.postprocess_nodes(nodes, query_str=query) final_docs = [n.node.node_id for n in reranked[:final_n]] else: final_docs = retrieved_docs[:final_n] latency_ms = (time.time() - start) * 1000 result = ExperimentResult( config_name=config_name, recall_at_k=calculate_hit_rate(final_docs, relevant_ids), mrr=calculate_mrr(final_docs, relevant_ids), latency_ms=round(latency_ms, 2), cost_notes=f"召回{top_k}条,保留{final_n}条", ) return result4.6 运行实验
现在可以串联所有模块,跑一组简单对比实验。
# 文件路径:run_experiments.py from src.ingest import load_documents, build_nodes, create_index from src.retrievers import retrieve_top_k from src.rerankers import build_reranker from src.evaluate import run_experiment # 1. 加载与索引 docs = load_documents("./data") nodes = build_nodes(docs, chunk_size=512) index = create_index(nodes) # 2. 定义实验查询和期望命中的文档 ID queries = [ { "query": "什么是检索增强生成?", "relevant_ids": {"doc_001", "doc_002"}, } ] # 3. 构建不同配置 reranker_small = build_reranker("BAAI/bge-reranker-base", top_n=3) # 4. 执行对比实验 results = [] for item in queries: # 基线:不重排 base = run_experiment( config_name="baseline_vector_top5", retrieve_func=lambda q, k: retrieve_top_k(index, q, top_k=k), rerank_func=None, query=item["query"], relevant_ids=item["relevant_ids"], top_k=5, final_n=3, ) results.append(base) # 实验组:向量检索 + 重排 exp = run_experiment( config_name="vector_rerank_top10", retrieve_func=lambda q, k: retrieve_top_k(index, q, top_k=k), rerank_func=reranker_small, query=item["query"], relevant_ids=item["relevant_ids"], top_k=10, final_n=3, ) results.append(exp) # 5. 输出结果 for res in results: print(res)预期输出会展示每个配置下的命中率、MRR 和延迟。通过对比基线和实验组的差异,就能判断重排器的加入是否真的带来了收益。
5. 常见问题与排查思路
在搭建类似实验框架的过程中,下面几个问题是出现频率最高的。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 检索结果总是缺少正确答案段落 | Embedding 模型与语料领域不匹配 | 换成领域适配的 Embedding,如 bge 系列;尝试混合检索 |
| 重排前后效果几乎没有变化 | 重排模型与检索模型来自不同领域,或 Top-K 太小 | 扩大召回数量;更换重排模型;检查相关文档是否在候选集内 |
| 响应延迟过高 | Top-K 过大、重排模型过重、文档切分过长 | 降低 Top-K;使用小规模重排模型;限制上下文长度 |
| 生成的回答经常“答非所问” | 上下文噪声太多,大模型被无关文本干扰 | 提高重排后保留的段落质量;增加 Prompt 中的引用要求 |
| 不同实验之间结果波动明显 | 数据集太小、评估指标不稳定 | 扩大测试集;多次运行取平均;使用人工标注的子集做验证 |
| 向量库召回结果无法稳定复现 | Chunk 切分参数不一致或随机种子未固定 | 固定切分参数;固定 Embedding 模型权重 |
排查是否用到重排器的下降问题
有一个比较容易忽视的问题:重排器虽然能在相关性上做二次判断,但它无法解决“召回阶段就漏掉正确答案”的问题。如果向量检索阶段 Top-K 太小,把正确答案段落排在了候选集之外,重排器再强也无能为力。所以排查效果不佳时,建议先看“召回命中率”,再看“重排准确率”。
6. 最佳实践与工程建议
基于 SciRet 的研究视角,可以从下面几个维度优化实际 RAG 系统。
6.1 把“计算成本”当成一等公民指标
在 RAG 项目里,建议在评估表格中同时记录:
- 召回阶段耗时。
- 重排阶段耗时。
- LLM 生成阶段消耗的 token 数。
- 单次问答的总成本估算(按 token 单价折算)。
不要把目光只盯在准确率上,因为很多情况下准确率提升 2%,成本却上升了 50%,这在生产环境是不可接受的。
6.2 按场景选择检索和重排策略
- 通用知识库:向量检索 + bge-reranker-base,Top-K 设为 10,重排后保留 3 到 5 条,性价比最高。
- 专业文献问答:建议使用混合检索(BM25 + 向量),Top-K 设为 20,重排后保留 5 条。科学文献中术语的精确匹配往往比语义相似度更可靠。
- 延迟敏感场景:可以考虑不做重排,但把向量检索的相似度阈值调高,用规则过滤掉明显不相关的段落。
- 大模型上下文很长(如 128K):不要盲目塞入大量检索文档。检索质量下降时,增加文档数量只会增加噪声,不会提升答案质量。
6.3 Chunk 策略要绑定评估一起调
不要把 Chunk 大小当作一个固定参数。建议在评估中加入一组“Chunk 大小对比实验”,比如 256、512、1024,观察对 Recall 和最终答案质量的影响。科学文献场景可以考虑“按段落切分 + 段落级检索 + 关键句提取”的组合,避免固定 token 窗口切断语义。
6.4 安全与权限边界
当 RAG 系统接入企业内部文档时,要特别注意检索阶段的权限隔离。向量库中不允许存放未脱敏的敏感信息,检索结果也应按用户权限过滤后再送入大模型。生产环境建议:
- 在向量库中增加文档级权限字段。
- 检索后、重排前进行权限过滤。
- 记录完整的检索、重排、生成日志,用于追溯和审计。
6.5 日志与可观测性
RAG 系统的调优高度依赖日志。建议每条线上请求都记录:
- 查询文本
- 检索召回的所有文档 ID 和分数
- 重排后的排名和最终得分
- LLM 生成答案引用的文档 ID
有了这些日志,之后做效果回归和错误分析时就有据可依,不用靠“猜”来调参。
7. 下一步可以深入的方向
SciRet 这类计算感知研究给实际工程带来的最大启发,是提醒我们不要孤立地看检索和重排方法,而是要以“系统总开销”为约束,寻找最优组合。沿着这个思路,后续可以深入的方向包括:
- Agentic RAG:把检索、重排、查询改写交给 Agent 动态决定,适合多轮复杂问题,但延迟和成本更高,需要做更精细的计算预算控制。
- RAG 评估体系:引入 RAGAS、TruLens 或自建评估集,把 Faithfulness、Answer Relevance、Context Relevance 纳入评估框架。
- 知识图谱与向量数据库结合:先用知识图谱做结构化过滤,再用向量检索做语义召回,能够显著提升专业领域的检索精度,但工程复杂度更高。
- 引用溯源与 Groundedness 校验:让大模型在回答中标注引用来源,用规则或模型校验生成内容是否忠实于检索到的文档。这在企业场景几乎属于刚需。
如果你正在做自己的 RAG 项目,可以先从这篇论文的实验思路中抽离出一部分,搭建一个小型评估平台,固定数据集、固定评估指标、逐项对比检索器、重排器和 Chunk 参数。不要一上来就追求复杂的框架,先把“计算成本”和“回答质量”这两本账算清楚,再逐步扩展。这样无论是做技术选型,还是向业务方解释系统行为,都会更有底气。
如果你想进一步落地,也可以把本文的实验框架扩展成自动化的回归测试:每次升级 Embedding 模型、重排模型或调整切块策略时,都跑一遍标准测试集,对比效果和耗时。这是让 RAG 系统从“能跑”走向“可控”的关键一步。
