RAG学习(六)——检索优化技术进阶
检索优化
在基础的 RAG 流程中,依赖向量相似度从知识库中检索信息。然而,这种方法存在一些固有的局限性,例如最相关的文档不总是在检索结果的顶端,以及语义理解的偏差等。为了构建更强大、更精准的生产级 RAG 应用,需要引入更高级的检索技术。
一、重排序
1.1 RRF(Reciprocal Rank Fusion)
把来自多路检索器 (BM25、稠密向量、规则召回、实体召回等)的排名结果做"倒数融
合”。同一文档在不同榜单的名次越靠前,融合分越高。公式(常用k=60):k=60):k=60):
RRF(d)=∑i=1m1k+ranki(d)\mathrm{RRF}(d)=\sum_{i=1}^m\frac1{k+\mathrm{rank}_i(d)} RRF(d)=i=1∑mk+ranki(d)1
其中ranki(d)\operatorname{rank}_i(d)ranki(d)是文档ddd在第iii个召回器中的名次;不存在则忽略。
实现流程(推理)
- 对同一 query 跑多路召回,得到若干个 Top-K 排名列表;
- 设定 kkk(通常 10~100 之间,越大越“保守”);
- 遍历所有出现过的文档,按上式累加分;
- 以 RRF 分数降序得到融合榜;
5)(可选)再接 Cross-Encoder/LLM 做精排。
1.2 RankLLM(用大模型做排序/重排)
把候选文档连同查询一起给到 LLM,通过指令+格式约束让 LLM 输出相关性排序或分值。可做 pointwise(逐文打分)、pairwise(两两比较)或 listwise(一次比较整列)。
实现流程
A. 推理(常见 listwise)
- 召回 Top-N(如 50 条)候选文档;
- 构造 Prompt:包含 query、编号后的候选文档(摘要/片段)、输出格式(如“返回排序后的 doc_id 列表与 0–5 分值”);
- 温度设为 0,max_tokens 设定足够覆盖编号与分值;
- 解析 LLM 输出:得到排序或分值;
5)(可选)异常回退:若解析失败→改为 pairwise 小批量裁判; - 输出精排结果。
B. 训练(可选,蒸馏/偏好对齐)
- 用人工标注或弱监督(点击/阅读时长)构造 (q, d⁺, d⁻);
- 用 LLM 做 pairwise 反馈或监督微调(SFT/LoRA);
- 也可把 Cross-Encoder 分数蒸馏给小 LLM,用于离线打分。
一个典型的提示词示例如下:
以下是一个文档列表,每个文档都有一个编号和摘要。同时提供一个问题。请根据问题,按相关性顺序列出您认为需要查阅的文档编号,并给出相关性分数(1-10分)。请不要包含与问题无关的文档。示例格式:
文档 1: <文档1的摘要>
文档 2: <文档2的摘要>
...
文档 10: <文档10的摘要>问题: <用户的问题>回答:
Doc: 9, Relevance: 7
Doc: 3, Relevance: 4
Doc: 7, Relevance: 3
1.3 Cross-Encoder(联合编码精排,MonoBERT/MonoELECTRA 等)
它的工作原理是将查询(Query)和每个候选文档(Document)拼接成一个单一的输入(例如,[CLS] query [SEP] document [SEP]
),然后将这个整体输入到一个预训练的 Transformer 模型(如 BERT)中,模型最终会输出一个单一的分数(通常在 0 到 1 之间),这个分数直接代表了文档与查询的相关性。
- 由于是联合编码,模型能捕捉到细粒度交互(词-词对齐、上下文消歧),精排质量很高;
- 代价是:每个 (q, d) 都要独立前向一次,计算成本高。
示例
- Query: “Who wrote The Great Gatsby?”
- 候选 d1: “F. Scott Fitzgerald wrote the novel in 1925 …”
- 候选 d2: “The story is set in the Jazz Age …”
Cross-Encoder 打分:score(d1)=0.92 > score(d2)=0.41
,最终把 d1 排在前。
1.4 ColBERT(Contextualized Late Interaction)
olBERT(Contextualized Late Interaction over BERT)是一种创新的重排模型,它在 Cross-Encoder 的高精度和双编码器(Bi-Encoder)的高效率之间取得了平衡3。采用了一种“后期交互”机制。
其工作流程如下:
-
独立编码:ColBERT 分别为查询(Query)和文档(Document)中的每个 Token 生成上下文相关的嵌入向量。这一步是独立完成的,可以预先计算并存储文档的向量,从而加快查询速度。
-
后期交互:在查询时,模型会计算查询中每个 Token 的向量与文档中每个 Token 向量之间的最大相似度(MaxSim)。
s(q,d)=∑t∈qmaxu∈dcos(et,eu)s(q,d)=\sum_{t\in q}\max_{u\in d}\cos(\mathbf{e}_t,\mathbf{e}_u) s(q,d)=t∈q∑u∈dmaxcos(et,eu) -
分数聚合:最后,将查询中所有 Token 得到的最大相似度分数相加,得到最终的相关性总分。
示例(法律问答)
- Query tokens:
["劳动", "合同", "解除", "赔偿"]
- 文档 d1 token 向量包含“解除”“经济补偿金”等高相似词;
- 对每个 query token 在 d1 里找最相似 token,相似度相加→
s(q,d1)=3.87
; - d2 缺少“赔偿”相关表达 →
s(q,d2)=2.41
; - 排名:d1 > d2。
二、压缩
“压缩”技术旨在解决一个常见问题:初步检索到的文档块(Chunks)虽然整体上与查询相关,但可能包含大量无关的“噪音”文本。将这些未经处理的、冗长的上下文直接提供给 LLM,不仅会增加 API 调用的成本和延迟,还可能因为信息过载而降低最终生成答案的质量。
2.1 两类思路
-
内容提取(Extractive)
-
不改写原文,只“挑句/挑段”。
-
方式:句级/段级相关性打分 → 选 Top-k 或 Top-p,再按窗口扩展少量上下文。
-
优点:可追溯、低幻觉风险;缺点:有冗余、可读性一般。
-
-
内容改写(Abstractive / Rewrite)
-
面向问题做查询聚焦摘要(Query-Focused Summarization, QFS),把多段证据浓缩成要点。
-
优点:压缩比高、读起来紧凑;缺点:可能引入改写误差,需要“基于证据”的约束或引用。
-
实践中常把两者串联:先“提取”做粗压,再“改写”做细压。
2.2 通用流程
流程 A:提取式压缩管线(推荐起步)
- 细粒度切分
- 按句子/小段落切分(SSplit),保留段落边界与来源标识(doc_id, start, end)。
- 相关性打分(句/段级)
- 嵌入相似度:
cos(emb(sentence), emb(query))
- 或 Cross-Encoder:
score = CE([CLS] q [SEP] s [SEP])
(更准但慢)。
- 嵌入相似度:
- 选择与阈值
- 选 Top-k 句/段;或设阈值 τ\tauτ,保留
score ≥ τ
的片段。 - 对每个命中片段做 窗口扩展:左右各带 n 句,避免断句丢信息。
- 选 Top-k 句/段;或设阈值 τ\tauτ,保留
- 文档级过滤
- 若某文档被保留的片段数/分数总和过低,整篇丢弃(你的“文档过滤”)。
- 去重与合并
- 对近似重复/同义表达做聚类(MinHash/SimHash/向量聚类),只留代表性句。
- 预算分配与截断
- 计算每个文档的权重(来自检索得分/重排分),按
softmax(weight) * B
分配 token。 - 过长即截断或进一步精炼。
- 计算每个文档的权重(来自检索得分/重排分),按
- 结构化拼装
- 以“要点+证据块”的结构返回,保留出处(doc_id, span)便于可追溯与反证。
流程 B:LLM 改写式压缩管线(QFS)
- 证据准备:用流程 A 先做粗提取,保证输入干净。
- 压缩提示词(Prompt):
- 明确任务:仅保留与查询直接相关事实;
- 强制 引用来源(句末加
[doc_id:line_start-line_end]
); - 禁止外推(“不得引入未在证据中出现的信息”)。
- 生成与解析:输出“要点清单/表格/三元组”之一(结构化有利于控制长度)。
- 一致性校验:
- 逐条要点用检索到的证据反查(entailment/是否可在原文找到支撑)。
- 不一致则丢弃或回退到提取式结果。
- 最终拼装:把压缩后的“要点+引用”作为 RAG 上下文传给回答模型。
三、校正
传统的 RAG 流程有一个隐含的假设:检索到的文档总是与问题相关且包含正确答案。然而在现实世界中,检索系统可能会失败,返回不相关、过时或甚至完全错误的文档。如果将这些“有毒”的上下文直接喂给 LLM,就可能导致幻觉(Hallucination)或产生错误的回答。
校正检索(Corrective-RAG, C-RAG) 正是为解决这一问题而提出的一种策略6。思路是引入一个“自我反思”或“自我修正”的循环,在生成答案之前,对检索到的文档质量进行评估,并根据评估结果采取不同的行动。
流程解析
-
Retrieval(初次检索)
-
输入:问题 xxx。
-
输出:候选文档 d1,d2,...d_1,d_2,...d1,d2,...。来源可以是向量库、知识库或数据库。
-
目的:拿到可能相关的材料,质量未判定。
-
-
Retrieval Evaluator(检索评估器)
-
核心问题(图中蓝色对话框):“这批检索文档对回答 xxx 是否足够且正确?”
- 输出三类标签:
- Correct:信息充分且一致;
- Ambiguous:有信息但不完备/互相矛盾/指向多答案;
- Incorrect:明显答非所问或关键信息缺失。
- 输出三类标签:
-
实现要点(可多信号融合):
- 支持度/蕴含:NLI 或 LLM 判断“文档是否支持对问题的关键断言”;
- 一致性:对每篇文档独立问答,做多数表决/一致性分;
- 覆盖度:是否包含回答所需槽位(人名、时间、定义等);
- 阈值:给出
support_score
、conflict_score
、coverage
,映射到三类标签。
- 支持度/蕴含:NLI 或 LLM 判断“文档是否支持对问题的关键断言”;
-
-
Knowledge Refinement(内部证据精炼 → kink_{in}kin)
-
触发:Ambiguous(也可在 Correct 时做轻度压缩)。
- 图中管线:Decompose → Strip → Filter → Recompose
- Decompose:把文档切为“原子事实/句子/三元组”;
- Strip:去脚注、例子、广告、非答案句;
- Filter:按“与 xxx 的相关性 + 互证一致性”筛选片段(Cross-Encoder/MMR/LLM 判别);
- Recompose:把保留片段重组为查询聚焦摘要(带引用),即 kink_{in}kin。
- 图中管线:Decompose → Strip → Filter → Recompose
-
作用:降噪、去冲突、补充上下文完整性,但只用内部检索到的材料。
-
-
Knowledge Searching(外部搜证 → kexk_{ex}kex)
-
触发:Incorrect(信息错位/缺失严重),有时 Ambiguous 也会补搜。
-
图中管线:Rewrite → Web Search → Select
- Rewrite:把 xxx 改写成可检索性强的查询(补关键词/限定来源,如“site:wikipedia 关键字”),必要时做分解查询;
- Web Search:到外部(Web/API/新库)检索一批候选 k1,k2,…k_1,k_2,…k1,k2,…
- Select:重排+核验(与 kexk_{ex}kex一致性、来源可信度、时效),选出 kexk_{ex}kex。
- Rewrite:把 xxx 改写成可检索性强的查询(补关键词/限定来源,如“site:wikipedia 关键字”),必要时做分解查询;
-
-
Generation(最终生成)
-
Correct: 直接用x+dx+dx+d或轻度kink_{in}kin生成;
-
Ambiguous: 用x+kinx+k_{in}x+kin (内部精炼)生成;
-
Incorrect: 用x+kin+kexx+k_{in}+k_{ex}x+kin+kex (内部精炼 + 外部新证)生成。。
-
模型提示会要求引用证据、避免无依据推断。
-
四、代码示例
import os
from langchain_community.vectorstores import FAISS
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_deepseek import ChatDeepSeek# 导入ColBERT重排器需要的模块
from langchain.retrievers.document_compressors.base import BaseDocumentCompressor
from langchain.retrievers.document_compressors import DocumentCompressorPipeline
from langchain_core.documents import Document
from typing import Sequence
import torch
from transformers import AutoTokenizer, AutoModel
import torch.nn.functional as Fclass ColBERTReranker(BaseDocumentCompressor):"""ColBERT重排器"""def __init__(self, **kwargs):super().__init__(**kwargs)model_name = "bert-base-uncased"# 加载模型和分词器object.__setattr__(self, 'tokenizer', AutoTokenizer.from_pretrained(model_name))object.__setattr__(self, 'model', AutoModel.from_pretrained(model_name))self.model.eval()print(f"ColBERT模型加载完成")def encode_text(self, texts):"""ColBERT文本编码"""inputs = self.tokenizer(texts,return_tensors="pt",padding=True,truncation=True,max_length=128)with torch.no_grad():outputs = self.model(**inputs)embeddings = outputs.last_hidden_stateembeddings = F.normalize(embeddings, p=2, dim=-1)return embeddingsdef calculate_colbert_similarity(self, query_emb, doc_embs, query_mask, doc_masks):"""ColBERT相似度计算(MaxSim操作)"""scores = []for i, doc_emb in enumerate(doc_embs):doc_mask = doc_masks[i:i+1]# 计算相似度矩阵similarity_matrix = torch.matmul(query_emb, doc_emb.unsqueeze(0).transpose(-2, -1))# 应用文档maskdoc_mask_expanded = doc_mask.unsqueeze(1)similarity_matrix = similarity_matrix.masked_fill(~doc_mask_expanded.bool(), -1e9)# MaxSim操作max_sim_per_query_token = similarity_matrix.max(dim=-1)[0]# 应用查询maskquery_mask_expanded = query_mask.unsqueeze(0)max_sim_per_query_token = max_sim_per_query_token.masked_fill(~query_mask_expanded.bool(), 0)# 求和得到最终分数colbert_score = max_sim_per_query_token.sum(dim=-1).item()scores.append(colbert_score)return scoresdef compress_documents(self,documents: Sequence[Document],query: str,callbacks=None,) -> Sequence[Document]:"""对文档进行ColBERT重排序"""if len(documents) == 0:return documents# 编码查询query_inputs = self.tokenizer([query],return_tensors="pt",padding=True,truncation=True,max_length=128)with torch.no_grad():query_outputs = self.model(**query_inputs)query_embeddings = F.normalize(query_outputs.last_hidden_state, p=2, dim=-1)# 编码文档doc_texts = [doc.page_content for doc in documents]doc_inputs = self.tokenizer(doc_texts,return_tensors="pt",padding=True,truncation=True,max_length=128)with torch.no_grad():doc_outputs = self.model(**doc_inputs)doc_embeddings = F.normalize(doc_outputs.last_hidden_state, p=2, dim=-1)# 计算ColBERT相似度scores = self.calculate_colbert_similarity(query_embeddings,doc_embeddings,query_inputs['attention_mask'],doc_inputs['attention_mask'])# 排序并返回前5个scored_docs = list(zip(documents, scores))scored_docs.sort(key=lambda x: x[1], reverse=True)reranked_docs = [doc for doc, _ in scored_docs[:5]]return reranked_docs# 初始化配置
hf_bge_embeddings = HuggingFaceBgeEmbeddings(model_name="BAAI/bge-large-zh-v1.5"
)llm = ChatDeepSeek(model="deepseek-chat", temperature=0.1, api_key=os.getenv("DEEPSEEK_API_KEY")
)# 1. 加载和处理文档
loader = TextLoader(r"all-in-rag\data\C4\txt\ai.txt", encoding="utf-8")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
docs = text_splitter.split_documents(documents)# 2. 创建向量存储和基础检索器
vectorstore = FAISS.from_documents(docs, hf_bge_embeddings)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})# 3. 设置ColBERT重排序器
reranker = ColBERTReranker()# 4. 设置LLM压缩器
compressor = LLMChainExtractor.from_llm(llm)# 5. 使用DocumentCompressorPipeline组装压缩管道
# 流程: ColBERT重排 -> LLM压缩
pipeline_compressor = DocumentCompressorPipeline(transformers=[reranker, compressor]
)# 6. 创建最终的压缩检索器
final_retriever = ContextualCompressionRetriever(base_compressor=pipeline_compressor,base_retriever=base_retriever
)# 7. 执行查询并展示结果
query = "AI还有哪些缺陷需要克服?"
print(f"\n{'='*20} 开始执行查询 {'='*20}")
print(f"查询: {query}\n")# 7.1 基础检索结果
print(f"--- (1) 基础检索结果 (Top 20) ---")
base_results = base_retriever.get_relevant_documents(query)
for i, doc in enumerate(base_results):print(f" [{i+1}] {doc.page_content[:100]}...\n")# 7.2 使用管道压缩器的最终结果
print(f"\n--- (2) 管道压缩后结果 (ColBERT重排 + LLM压缩) ---")
final_results = final_retriever.get_relevant_documents(query)
for i, doc in enumerate(final_results):print(f" [{i+1}] {doc.page_content}\n")
流程分析:
- 加载向量模型/LLM
HuggingFaceBgeEmbeddings("BAAI/bge-large-zh-v1.5")
:中文领域强的稠密向量,用来建库+粗召回。ChatDeepSeek("deepseek-chat")
:给LLMChainExtractor
做抽取式压缩(从候选文档中只保留与查询相关的句/段)。
- 加载原始文档并切块
TextLoader(...).load()
读入 ai.txt;RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
语义/字符混合的“滑窗切块”。
- 建立向量库与基础检索器
FAISS.from_documents(docs, hf_bge_embeddings)
:把每个 chunk 编码进 FAISS;vectorstore.as_retriever(k=20)
:第一阶段召回(粗召回),返回 Top-20 个 chunk。
- 构建压缩流水线
- 你自定义的 ColBERTReranker(Token 级 MaxSim 晚交互)对 Top-20 做重排,保留前 5;
LLMChainExtractor
对这 5 个 chunks 做查询聚焦的抽取式压缩(剔掉不相关内容)。- 这两步被
DocumentCompressorPipeline([reranker, compressor])
串起来。
- 最终检索器
ContextualCompressionRetriever(base_retriever=..., base_compressor=pipeline)
:
每次get_relevant_documents(query)
→ 先粗召回 20 → 交给重排+压缩管道 → 输出更“干净”的上下文。
- 打印对比
- 先打印粗召回(Top-20)的前 100 字预览;
- 再打印重排+压缩后的最终文本(内容已被 LLM 抽取过,更短更相关)。
结果:
[1] 行业巨头谷歌公司也没闲着。该公司在5月推出整体性能和智能推理能力均较以往版本大幅提升的多个“双子座2.5”系列模型,并发布了多个多模态模型,如图像生成模型Imagen 4和视频生成模型Veo 3,具备...[2] 一个比较明显的问题是,AI生成内容虽然已非常流畅,但提供的信息很多时候 还是不准确。5月,日本研究人员在德国《先进科学》杂志发表的一项研究成果中指出,这一问题与人类的语言障碍——失语症类似。...[3] 业界也确实在努力从不同角度去寻求优化大模型的解决方案。中国科学院自动 化研究所联合鹏城实验室提出了一种高效推理策略AutoThink,可让大模型实现自主切换思考模式,避免“过度思考”。据研究...[4] 一些国家已在积极尝试通过优化政策、法规来营造更好的AI创新环境。日本参 议院全体会议5月28日以多数赞成票通过该国首部专门针对AI的法律,旨在促进AI相关技术研发和应用并防止其滥用。依据这部《人工智能相...[5] 5月,全球多家科技公司发布新的大模型,它们在语义理解、多模态等方面进一步提升,人工智能(AI)的能力边界在不断扩大。随着无人驾驶、机器人等技术借助AI快速进化并逐步投入市场,不少国家通过推进法规建设、...--- (2) 管道压缩后结果 (ColBERT重排 + LLM压缩) ---[1] 一个比较明显的问题是,AI生成内容虽然已非常流畅,但提供的信息很多时候
还是不准确。
大模型在出现严重错误时仍表达流畅,这与感觉性失语症的症状有相似之处,即说话 流利却总说不出什么意思。
它们可能被锁定在一种僵化的内部模式中,限制其灵活运用所储存知识。[2] AutoThink提供了一种简单而有效的推理新范式——通过省略号提示配合三阶段强化学习,引导大模型不再“逢题必深思熟虑”,而是根据问题难度自主决定“是否思[3] AI仍有不少缺陷需克服尽管当前AI应用已相当广泛,但不少缺陷还是会影 响其实用性。研究人员正努力分析导致这些缺陷的原因 并寻求新的解决方法,从而改善AI的性能。一个比较明显的问题是,AI生成内容虽然已非常流 畅,但提供的信息很多时候还是不准确。
参考文献
1.检索进阶技术