AI TOOLS

EP07. "RAG 进阶:混合检索、重排序与上下文检索"

首页 › AI 工具 › Claude · Developer and Platform · Amazon Bedrock · EP07
约 21 分钟· #EP07#Claude#Amazon Bedrock
🔒 登录后可标记已读
  • 语意搜索有个天生盲点:查一个具体编号(比如 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,语意搜索常常找不准——它看的是"意思相不相近",不是"文字一不一样"。就算这个编号在真正相关的段落里出现了好几次,语意搜索也可能捞出概念上听起来相关、但实际上根本没提到这个编号的段落。

解法:混合检索(hybrid search)——语意搜索和关键字检索并行跑,再把结果合并:

  • 语意搜索(semantic):靠 embedding 找概念相关的内容
  • 关键字检索(lexical):靠传统文字检索找精确的关键字匹配
  • 合并结果:两种方法一起用,整体相关度更高

BM25:关键字检索算法

BM25(Best Match 25)是 RAG 系统里常用的关键字检索算法,流程:

  1. Tokenize the query:把用户问题拆成一个个词
  2. Count term frequency:统计每个词在各文档里出现的频率
  3. Weight terms by rarity:越少见的词权重越高
  4. Score documents:含更多高权重词的文本块分数更高

📌 核心洞察:像 INC-2023-Q4-011 这种罕见词,对搜索相关度的贡献,远比 "a"、"the" 这类常见词重要得多。

实作上 BM25 的接口刻意跟向量数据库保持一致,都是 add_document() / search(),方便两套系统混着用:

# 建一个 BM25 store
store = BM25Index()

# 把文档加进去
for chunk in chunks:
    store.add_document({"content": chunk})

# 搜索
results = store.search("What happened with INC-2023-Q4-011?", 3)

用 BM25 之后,含精确事件编号的章节(软件工程、网络安全)会被正确排到前面,而不是语意上沾边但其实无关的财务分析章节。


混合检索管道:用 RRF 合并两套排名

VectorIndex 跟 BM25Index 接口几乎一样,都有 add_document()/search(),可以包进一个统一的 Retriever 类来协调两边:

  • 接收用户问题
  • 同时转发给 VectorIndex 和 BM25Index
  • 收集两边的结果
  • 用排名算法合并成一份结果

问题:两套系统打分机制完全不一样,直接比较分数没有意义。Reciprocal Rank Fusion(RRF) 解决了这个问题——不比较原始分数,只看「排名」,对每套系统一视同仁。

举例:VectorIndex 排出 Section 2、7、6;BM25Index 排出 Section 6、2、7。

RRF 公式:

RRF_score(d) = Σ(1 / (k + rank_i(d)))

k 通常取 60(想让差距更明显也可以取 1),rank_i(d) 是文档 d 在第 i 套系统里的排名。

算出来:

  • Section 2:1.0/(1+1) + 1.0/(1+2) = 0.833
  • Section 7:1.0/(1+2) + 1.0/(1+3) = 0.583
  • Section 6:1.0/(1+3) + 1.0/(1+1) = 0.75

最终排名:Section 2 → Section 6 → Section 7

实作骨架:

class Retriever:
    def __init__(self, *indexes):
        self._indexes = list(indexes)

    def add_document(self, document):
        for index in self._indexes:
            index.add_document(document)

    def search(self, query_text, k=1, k_rrf=60):
        # 从所有 index 拿结果
        all_results = []
        for idx, results in enumerate(all_results):
            for rank, (doc, _) in enumerate(results):
                # 记录每份文档在各系统里的排名
                # 套用 RRF 公式
                # 回传合并、排序后的结果

💡 RRF 的精髓:综合考虑一份文档在所有检索系统里表现得怎么样,而不是依赖单一打分机制。用同样那句「INC-2023-Q4-011」的问题测试,混合检索能把最相关的网络安全事件报告排在最前面,效果超过单用任何一种方法。

📌 这套设计天生可扩展——只要新的检索方法实现同样的接口(add_document/search),就能自然接进这条管道,不用改动其他部分。


用 Claude 重排序(Re-ranking)

混合检索已经不错,但碰到含专有名词/缩写的问题,排序还是可能不够准。例如问「eng team 对 INC-2023-Q4-011 做了什么」,可能会把网络安全那节排得比真正相关的软件工程那节还前面。

Re-ranking 是合并 vector + BM25 结果之后再加的一道后处理:把结果丢给 Claude,让它按跟问题的相关度重新排序。

流程:跑混合检索(vector + BM25)→ 合并结果(跟前面一样)→ 把合并后的结果交给一个 re-ranker 函数 → re-ranker 把所有内容连同一段针对性的 prompt 一起发给 Claude → Claude 回传按相关度重新排好的文档列表。

Prompt 结构:

You are tasked with finding the documents most relevant to a user's question.

<user_question>
What happened with INC-2023-Q4-011?
</user_question>

Here are documents that may be relevant:
<documents>
<document>Section 10...</document>
<document>Section 2...</document>
<document>Section 7...</document>
<document>Section 6...</document>
</documents>

Return the 3 most relevant docs, in order of decreasing relevance.

📌 效率上的关键取舍:让 Claude 直接复制整段文字回来很浪费时间——等它把一大段文字抄一遍效率很低。正确做法是事先给每个文本块一个唯一 ID,只要求 Claude 回传排好序的 ID 列表,比如 ["1p5g", "51n3", "ab83"],比让它复制整份文档内容快得多。

Re-ranker 函数实作骨架:

def reranker_fn(docs, query_text, k):
    # 给文档加上 ID
    joined_docs = "\n".join([
        f"<document><document_id>{doc['id']}</document_id>"
        f"<document_content>{doc['content']}</document_content></document>"
        for doc in docs
    ])

    # 组装带用户问题和文档的 prompt
    prompt = f"""You are about to be given a set of documents...
    {query_text}
    {joined_docs}
    """

    # 拿 Claude 的回应,解析出文档 ID 列表
    result = chat(messages, stop_sequences=["```"])
    return json.loads(result["text"])["document_ids"]

这个 re-ranker 会在初步的混合检索结果出来后,被你的 retriever 自动调用一次。

再测「eng team 对 INC-2023-Q4-011 做了什么」这类问题,排序会明显改善——Claude 能理解「工程团队」这个语境,正确判断该把软件工程那节排到只是提了一下这个编号的段落前面。

⚠️ 取舍:Re-ranking 会因为要多等 Claude 一次回应而增加延迟,但换来的准确度提升——靠的是 Claude 对语境的理解和相关度判断能力——通常很值得。


Contextual Retrieval:切块前先补上下文

「把文档切成一块块」这个做法本身有个根本问题:每一块被切出来后,就跟它在整份文档里的位置和前后关系脱钩了。

Contextual retrieval 的做法:在把每一块存进检索数据库之前,先让 Claude 给它补一段简短的上下文说明,交代这一块在整份文档里处于什么位置。

把这块和原始文档一起发给 Claude:

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.

生成的这段上下文会跟原始块拼在一起,变成一个「contextualized chunk」,存进向量和 BM25 索引。

📌 文档太大时:不用塞整份文档进去,给缩减版上下文就够——通常是文档开头几块(一般带摘要)+ 紧邻目标块前面的几块(局部上下文),既够 Claude 判断,又不会把 prompt 撑爆。

实作:

def add_context(text_chunk, source_text):
    prompt = f"""
    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

批次处理每一块(开头几块 + 前面几块拼成局部上下文):

# 给每块补上下文,再加进 retriever
for i, chunk in enumerate(chunks):
    # 用开头几块 + 前面几块组出上下文
    context_parts = []
    context_parts.extend(chunks[:min(num_start_chunks, len(chunks))])

    start_idx = max(0, i - num_prev_chunks)
    context_parts.extend(chunks[start_idx:i])

    context = "\n".join(context_parts)

    contextualized_chunk = add_context(chunk, context)
    retriever.add_document({"content": contextualized_chunk})

💡 Claude 生成的上下文会说明这一块在文档里的位置、跟其他部分的关系——文档结构越复杂、章节之间交叉引用越多,这一层补充信息的价值就越大。


方法怎么选:RAG 检索要加到哪一层

遇到的问题该加的技巧
查具体编号/专有名词,语意搜索找不到混合检索(语意 + BM25),用 RRF 合并
混合检索结果排序还是不够准加一层 LLM re-ranking
每块脱离原文档上下文,检索精度受影响Contextual retrieval,存进数据库前先补上下文

三层可以叠加使用:先切块补上下文 → 存进语意+BM25 双索引 → 混合检索合并 → 再用 Claude 重排一次,一层比一层更准,也一层比一层更贵。

常见错误

  • ❌ 只靠语意搜索处理所有查询——查精确编号/专有名词这类场景,语意搜索先天就容易找不准,要搭配 BM25
  • ❌ 直接比较 vector 和 BM25 的原始分数来合并结果——两套系统打分机制不同,不能直接比,要用 RRF 这类只看排名的方法
  • ❌ 让 Claude 在 re-ranking 时直接复制整段文档内容回来——应该先给每块一个 ID,只要求它回传排好序的 ID 列表
  • ❌ 给 contextual retrieval 塞整份原始文档当上下文——文档一大就会把 prompt 撑爆,该用开头摘要 + 局部前文这种缩减版上下文

Sources

官方文档:

  1. Claude with Amazon Bedrock(Claude Academy 课程)— https://academy.claude.com/courses/claude-with-amazon-bedrock