
检索增强生成(RAG)已成为缓解大模型幻觉、引入私有知识的主流方案。然而,许多开发者初版 RAG 仅依赖向量相似度检索,在实际业务中常面临:
本文将深入 RAG 的检索侧优化,从基础向量检索出发,逐步引入关键词检索(BM25) 与重排序(Rerank) ,并通过实验数据对比各策略的效果。读完本文,你将掌握一套可落地、可量化的 RAG 优化方法论,且所有代码均基于开源工具实现,可轻松迁移至生产环境。
适合读者:具备 Python 基础,了解大模型 API 调用,对 RAG 有初步实践经验的开发者。
我们将构建一个面向技术文档问答的 RAG 系统,整体流程如下:
核心组件:
pip install langchain chromadb sentence-transformers rank_bm25 openai tiktoken pypdf我们使用一份公开的 LangChain 官方文档(约 200 个页面)作为知识库,已预先下载为 PDF 文件。读者可替换为自己的文档集。
from langchain.document_loaders import PyPDFDirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
loader = PyPDFDirectoryLoader("./docs/")
raw_docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)
chunks = text_splitter.split_documents(raw_docs)
print(f"共生成 {len(chunks)} 个文本块")分块策略:
chunk_size=512平衡语义完整性与检索粒度;chunk_overlap避免上下文断裂。针对中文文档,分隔符优先级需调整,我加入了中文标点。
我们选用 BAAI/bge-large-zh-v1.5 作为嵌入模型,其在 MTEB 中文基准上表现优异,且支持 1024 维。
from sentence_transformers import SentenceTransformer
import chromadb
from chromadb.utils import embedding_functions
# 初始化嵌入模型(本地加载)
embed_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-large-zh-v1.5"
)
# 创建 Chroma 向量库
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
name="langchain_docs",
embedding_function=embedding_fn
)
# 批量插入(为演示,简化为循环)
for i, chunk in enumerate(chunks):
collection.add(
ids=[f"chunk_{i}"],
documents=[chunk.page_content],
metadatas=[chunk.metadata]
)BM25 是经典的概率检索模型,对专有名词、缩写等词汇级匹配非常有效。我们基于 rank_bm25 实现内存索引(数据量不大,适合快速实验)。
from rank_bm25 import BM25Okapi
import jieba # 中文分词
# 对所有文本块进行分词
tokenized_corpus = [list(jieba.cut(chunk.page_content)) for chunk in chunks]
bm25 = BM25Okapi(tokenized_corpus)
# 保存原始文本列表供后续使用
corpus_texts = [chunk.page_content for chunk in chunks]若文档量极大(>10万),建议将 BM25 迁移至 Elasticsearch 等生产级引擎。
单纯向量检索可能漏掉包含关键字的短文本,BM25 又缺乏语义泛化。我们采用 RRF(Reciprocal Rank Fusion) 对二者结果进行合并,公式如下:
RRF(d)=∑r∈rankings1k+rankr(d)RRF(d)=r∈rankings∑k+rankr(d)1
其中 k 为常数(通常取 60)。RRF 无需调参,且对不同检索器的得分尺度不敏感。
def hybrid_search(query: str, top_k: int = 20, alpha: float = 0.5):
# 1. 向量检索
vec_results = collection.query(
query_texts=[query],
n_results=top_k * 2 # 多召回一些用于融合
)
vec_ids = vec_results['ids'][0]
vec_scores = vec_results['distances'][0] # 距离越小越相似
# 2. BM25 检索
tokenized_query = list(jieba.cut(query))
bm25_scores = bm25.get_scores(tokenized_query)
# 取 top_k*2 个索引
bm25_top_indices = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:top_k*2]
bm25_top_scores = [bm25_scores[i] for i in bm25_top_indices]
# 3. 构建排序字典 (doc_id -> RRF score)
rrf_scores = {}
k = 60
# 处理向量结果
for rank, doc_id in enumerate(vec_ids):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)
# 处理 BM25 结果(需要将索引映射为 doc_id,这里我们使用 "chunk_{i}" 格式)
for rank, idx in enumerate(bm25_top_indices):
doc_id = f"chunk_{idx}"
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank + 1)
# 排序取 top_k
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
final_ids = [doc_id for doc_id, _ in sorted_docs]
# 获取对应文本
final_texts = []
for doc_id in final_ids:
idx = int(doc_id.split("_")[1])
final_texts.append(corpus_texts[idx])
return final_texts, final_ids混合检索返回的 Top-20 候选仍可能包含噪声,我们使用 交叉编码器(Cross-Encoder) 对候选集逐一计算相关性得分。与双编码器(向量检索)不同,CE 将问题和文档拼接后通过 Transformer 进行全连接交互,精度更高但计算开销较大,因此只用于精排阶段。
我们选用 BAAI/bge-reranker-large,专为中文重排序设计。
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
rerank_model_name = "BAAI/bge-reranker-large"
tokenizer = AutoTokenizer.from_pretrained(rerank_model_name)
model = AutoModelForSequenceClassification.from_pretrained(rerank_model_name)
model.eval()
def rerank(query: str, candidate_texts: list, top_k: int = 5):
pairs = [[query, text] for text in candidate_texts]
with torch.no_grad():
inputs = tokenizer(pairs, padding=True, truncation=True, return_tensors="pt", max_length=512)
scores = model(**inputs, return_dict=True).logits.view(-1, ).float()
# 得分越高越相关
sorted_indices = torch.argsort(scores, descending=True).tolist()
sorted_texts = [candidate_texts[i] for i in sorted_indices]
sorted_scores = [scores[i].item() for i in sorted_indices]
return sorted_texts[:top_k], sorted_scores[:top_k]将精排后的 Top-5 文档拼接至 Prompt,调用大模型(以 OpenAI GPT-4 为例)生成答案。注意控制上下文长度,避免超出模型限制。
import openai
def generate_answer(query: str, contexts: list):
context_str = "\n\n".join([f"[文档{i+1}] {text}" for i, text in enumerate(contexts)])
prompt = f"""你是一个专业的技术助手,请基于以下参考文档回答用户问题。如果文档中没有相关信息,请明确告知。
参考文档:
{context_str}
用户问题:{query}
请给出简洁、准确的回答(不超过200字):"""
response = openai.ChatCompletion.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
)
return response.choices[0].message.content为验证各策略的有效性,我构建了 50 个测试问题,涵盖概念解释、代码示例、配置参数等类型。采用 MRR(Mean Reciprocal Rank) 和 Hit@5 作为检索评估指标,并人工评估生成答案的准确性(正确/部分正确/错误)。
策略 | MRR | Hit@5 |
|---|---|---|
仅向量检索 (top-20) | 0.62 | 0.74 |
仅 BM25 | 0.55 | 0.68 |
混合检索(RRF) | 0.71 | 0.82 |
混合检索 + 重排序 | 0.79 | 0.89 |
可见,混合检索在召回率上明显优于单一检索器,而重排序进一步提升排序质量,对最终生成答案的准确性至关重要。
策略 | 正确率 | 部分正确 | 错误 |
|---|---|---|---|
仅向量检索 | 54% | 32% | 14% |
混合检索 | 66% | 26% | 8% |
混合+重排序 | 78% | 18% | 4% |
重排序减少了“关键文档排位靠后”导致生成遗漏的情况,错误率显著下降。
对于实时性要求较高的场景,可适当减少重排序候选数(如 top-3)或使用更轻量的 reranker。
根据文档结构(如标题层级)进行语义分块,而非固定长度,能提升块内信息完整性。
对用户问题进行扩写或分解,例如“如何配置 LangChain 的 memory?”可改写为“LangChain memory 配置方法”,提升检索命中率。
对高频问题缓存答案,降低 API 成本与延迟。
建立测试集,使用 LLM-as-a-Judge 自动评估答案质量,便于持续迭代。
本文从实战角度,详细拆解了一个企业级 RAG 系统的检索优化流程。通过引入 BM25 混合检索 和 Cross-Encoder 重排序,我们显著提升了召回精度与生成质量,并给出了量化实验数据。
未来,随着大模型上下文窗口的增大(如 1M tokens),RAG 可能会向“全量上下文 + 精细排序”方向演进,但检索的精准性依然是降低幻觉的核心。希望本文能为你构建生产级 RAG 应用提供切实的参考。
欢迎在评论区交流你的实践经验或遇到的问题!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。