EP07. "RAG 进阶:混合检索、重排序与上下文检索"
🔒 登录后可标记已读- 语意搜索有个天生盲点:查一个具体编号(比如
INC-2023-Q4-011),语意搜索看的是「意思」不是「精确文字」,反而可能捞不到这个编号所在的段落 - 解法是混合检索——语意搜索(embedding)和关键字检索(BM25)同时跑,用 Reciprocal Rank Fusion(RRF) 这个只看排名、不看具体分数的排名合并法把两边结果揉在一起
- 混合检索排出来的顺序不一定精准,再加一层用 Claude 重排序(re-ranking)——把候选文档丢给 Claude,让它照跟问题的相关度重新排一次,准确度会再上一台阶,代价是多一次 API 调用、多一点延迟
- 切块最大的副作用是每一块都丢失了它在整份文档里的上下文,contextual retrieval 让 Claude 在存进数据库前,先给每一块补一段「这块在整份文档里处在什么位置」的说明
- 前置知识:EP06 的切块、embedding、向量数据库、cosine similarity
重点内容
语意检索的盲点:精确匹配靠不住
搜索一个具体的事件编号,比如 INC-2023-Q4-011,语意搜索常常找不准——它看的是"意思相不相近",不是"文字一不一样"。就算这个编号在真正相关的段落里出现了好几次,语意搜索也可能捞不到,因为它没有从"语意"角度特别突出这个编号。
BM25:关键字检索找精确匹配
解法是混合方案:语意搜索(embedding,抓上下文相关性)+ 词汇检索(BM25 算法,抓精确字词匹配)+ 合并结果,三管齐下取得全面覆盖。
BM25(Best Match 25) 算法流程:
- 把查询语句拆成一个个词
- 统计每个词在各文档里出现的频率
- 稀有词的权重比常见词高很多
- 返回含有高权重词的切块
📌 核心洞察:像 "INC-2023-Q4-011" 这种稀有词,对搜索的重要性远高于 "a"、"the" 这类常见词——BM25 正是靠这个「稀有词更重要」的原则找到语意搜索漏掉的精确匹配。
# 建立一个 BM25 索引
store = BM25Index()
# 把文档加进去
for chunk in chunks:
store.add_document({"content": chunk})
# 搜索
results = store.search("What happened with INC-2023-Q4-011?", 3)
💡 语意检索和词汇检索用的是同一套 API(add_document() / search()),这个设计选择是后面能顺利做「多索引整合」的关键。
多索引 RAG Pipeline:合并两种检索结果
因为语意索引和 BM25 索引共用同一套 API,可以用一个统一的 Retriever 协调类,同时把文档加进两个索引、同时对两个索引发查询。
怎么合并两边排名,而不是硬凑分数:语意分数(cosine similarity)和 BM25 分数量纲完全不同,直接加权平均没有意义。Reciprocal Rank Fusion(RRF) 改成只看「排名位置」,不看具体分数:
RRF_score(d) = Σ(1 / (k + rank_i(d)))
k 是一个常数(通常取 60),rank_i(d) 是这份文档在第 i 个检索方法结果里的排名位置。
示例:假设「Section 2」在向量搜索排第 1 名、在 BM25 排第 2 名:
Score = 1/(1+1) + 1/(1+2) = 0.833
Retriever 协调类的职责:
- 接受多个索引实例(语意索引、BM25 索引……)
- 透过
add_document()把文档同时路由进所有索引 - 对所有索引同时执行查询
- 用 RRF 分数合并各索引的结果
- 按合并后的分数排序,返回 top-k 文档
📌 混合方案能拿到比单纯用语意搜索更好的结果,因为它同时结合了「理解意思」和「精确关键字匹配」两种能力;而且这套架构保持模块化,以后想加第三种检索方式(比如另一种排序算法)也不用改动核心检索逻辑,接口一致就能接进来。
用 Claude 重排序(Re-ranking)
混合检索排出来的顺序不一定精准,Re-ranking 是检索完之后再加一层的后处理技巧,靠 LLM 对结果重新排序,能明显改善检索准确度。
流程分四步:
- 跑混合检索(语意 + BM25)
- 照常合并结果(RRF)
- 把合并后的候选文档连同一个重排序 prompt 一起丢给 Claude
- 拿回 Claude 重新排好顺序的文档列表
重排序 prompt 的结构——让 Claude 从候选文档里挑出最相关的 k 个:
You are about to be given a set of documents, along with an id of each.
Your task is to select the {k} most relevant documents to answer the user's question.
Here is the user's question:
<question>
{query_text}
</question>
Here are the documents to select from:
<documents>
{joined_docs}
</documents>
Respond in the following format:
{ "document_ids": str[] # List document ids, {k} elements long, sorted in order of decreasing relevance }
💡 效率优化:不用把完整文档文字发过去又发回来——提前给每个切块分配唯一 ID,只要求 Claude 返回排好顺序的 ID 列表就够了,省下大量不必要的 token。
方法怎么选
| 做法 | 好处 | 代价 |
|---|---|---|
| 只做混合检索(语意 + BM25) | 已经比单一检索方式准 | 排序不一定完全精准 |
| 再加一层 Claude 重排序 | 准确度进一步提升,能理解更细腻的上下文相关性 | 多一次 API 调用,延迟和成本都增加 |
课程实测:针对「工程团队参与了什么」这类查询,加了重排序后能正确把 Software Engineering 那个段落排到最前面,比没重排序时更准。
Contextual Retrieval:给切块补回丢失的上下文
标准切块流程有个副作用:每个切块都失去了跟整份文档之间的连结,这会让检索准确度打折扣。
解法是在存进数据库前先加一道预处理:拿「单独的切块」+「原始完整文档」一起给 Claude,让它生成一段简短的上下文说明,交代这个切块在整份文档里处于什么位置。
def add_context(text_chunk, source_text):
prompt = """
Write a short and succinct snippet of text to situate this chunk within the
overall source document for the purposes of improving search retrieval of the chunk.
Here is the original source document:
<document>
{source_text}
</document>
Here is the chunk we want to situate within the whole document:
<chunk>
{text_chunk}
</chunk>
Answer only with the succinct context and nothing else.
"""
messages = []
add_user_message(messages, prompt)
result = chat(messages)
return result["text"] + "\n" + text_chunk
📌 文档太长、超过 token 上限时,不要把整份原文塞进去——改成只给「文档开头的几个切块」加上「这个切块前面紧邻的几个切块」当参考,而不是硬塞全文。
最适合用的场景:内部有大量交叉引用的复杂文档、技术报告、学术论文——这类文档特别依赖「结构性理解」才能检索准确,简单切块很容易丢失这层关系。
常见错误
- ❌ 只依赖语意搜索处理所有查询——碰到查具体编号/代码/专有名词这类需要精确匹配的问题,语意搜索天生找不准,要搭配 BM25
- ❌ 合并语意分数和 BM25 分数时直接加权平均——两者量纲不同,没有共同基准,应该用 RRF 只比较排名位置
- ❌ 重排序时把完整文档全文都发给 Claude 来回传——应该提前给切块分配 ID,只让 Claude 返回排序后的 ID 列表,省下大量 token
- ❌ 文档太长时,给 contextual retrieval 塞整份原文——应该只给开头几块加上紧邻的前文当参考,避免超出 token 上限
Sources
官方文档:
- Claude with Google Cloud's Vertex AI(Claude Academy 课程)— https://academy.claude.com/courses/claude-with-google-cloud-s-vertex-ai