Day 9 我们通过 Multi-Query 极大提升了检索的广度,但也引入了无关文档的“噪音”。今天是 Day 10,我们将引入 Re-ranking (重排序) 技术,这是 RAG 系统从“可用”迈向“高精度”的关键分水岭。本文将深度解析 Bi-Encoder 与 Cross-Encoder 的区别,并使用 HuggingFace CrossEncoder 构建重排序器,清洗检索结果,确保大模型只看最精准的“干货”。

一、 项目进度:Day 10 启动

根据项目路线图,今天是 Phase 3 的最后一天。
完成这一步,我们的 RAG 知识库就是一个完全体了。


二、 核心原理:精细化筛选——Re-ranking (重排序) 技术

在 Day 9,我们要么搜不到,要么利用 Multi-Query 搜回了一大堆文档。这就好比撒网捕鱼,网是撒出去了,但捞上来的不仅有鱼,还有靴子、水草和石头。

如果把这些“噪音”一股脑塞给大模型,不仅浪费 Token,还会导致 “迷失中间” (Lost in the Middle) 现象——大模型因为干扰信息太多,反而找不到正确答案。

我们需要一个严苛的**“质检员”**。

1. 深度解析:Bi-Encoder vs Cross-Encoder

为什么向量库(Bi-Encoder)不够准,而重排序(Cross-Encoder)准但慢?

(1) Bi-Encoder (双塔模型) —— 向量库在用的
  • 原理:它像两个独立的画师。一个画师画“问题”的像(向量),另一个画师画“文档”的像(向量)。最后比较两幅画像不像(计算余弦相似度)。

  • 局限:因为画的时候是各画各的,“问题”和“文档”没有发生交互

    • 例子:用户问“我不喜欢吃苹果”,向量库可能会把“我喜欢吃苹果”排在很前面,因为它们共享了大部分词汇,向量很像,但含义完全相反。

(2) Cross-Encoder (交互模型) —— 重排序在用的
  • 原理:它像一个阅卷老师。它把“问题”和“文档”拼接在一起 ([CLS] 问题 [SEP] 文档),作为一个整体输入给 BERT 模型进行深层注意力计算。

  • 优势:模型能看到每一个字在另一个句子中的上下文。它能精准识别“不”、“但是”这种逻辑词。

    • 例子:Cross-Encoder 会发现“不”字否定了喜欢,给“我喜欢吃苹果”打一个极低的分数。

  • 代价:计算量大。如果你有 100 万条数据,不能用它全库扫描,只能用来处理粗排后的几十条数据。

2. 漏斗架构:从“海选”到“精选”

成熟的 RAG 系统通常采用 “两阶段检索” (Two-Stage Retrieval) 的漏斗架构:

  1. 第一阶段:粗排 (Retrieval / Recall)

    • 工具:ChromaDB (向量搜索)。

    • 目标召回率 (Recall) 优先。宁可错杀一千,不可放过一个。从 100,000 条数据中快速捞出 Top 50 及其相似的文档。

    • 特点:速度极快(毫秒级),但精度较低,只看大致语义。

  2. 第二阶段:精排 (Re-ranking / Precision)

    • 工具:Cross-Encoder 模型 (如 BGE-Reranker)。

    • 目标准确率 (Precision) 优先。对这 50 条文档进行逐一精读打分,只保留得分最高的 Top 3。

    • 特点:速度较慢,但精度极高,能理解复杂的逻辑关系。

这张流程图展示了引入 Re-ranking(重排序) 后,RAG 系统如何像漏斗一样,将海量文档层层过滤,最终只把精华留给大模型:

1. 顶部入口:用户提问

  • 一切始于用户的提问:“阿强对Python的态度”。这是一个典型的语义查询,既包含了实体(阿强、Python),也包含了意图(态度)。

2. 第一阶段:粗排 (Stage 1 - Recall)

  • 动作ChromaDB 出马。它使用的是 Bi-Encoder 技术,追求速度。它迅速计算向量距离,从成千上万条数据中,粗略地捞出了 Top 10 条候选文档。

  • 现状:这一步是“广撒网”。捞上来的结果中,既有真正的答案(阿强喜欢写代码),也混入了噪音(比如“阿强讨厌蟒蛇”——因为蟒蛇英文也是 Python,向量距离可能很近)。

3. 第二阶段:精排 (Stage 2 - Re-rank)

  • 动作Re-ranker (Cross-Encoder) 介入。它像一位严苛的阅卷老师,不再只看大概,而是把“用户问题”和“这 10 个文档”逐一拼接,进行深度阅读和逻辑判断。

  • 打分

    • Doc 1:“阿强最爱用 Python 写脚本” -> 语义高度匹配 -> 得分 0.98

    • Doc 2:“阿强看到蟒蛇(Python)吓得腿软” -> 虽然有关键词,但语义无关 -> 得分 0.12

    • Doc 3:“Python 是他吃饭的家伙” -> 语义相关 -> 得分 0.85

4. 底部出口:截断与生成

  • 过滤:系统根据分数进行排序,大笔一挥,切掉低分文档(Doc 2 被无情淘汰)。

  • 最终输出:只将得分最高的 Top 3 文档(Doc 1 + Doc 3)打包发送给大模型。

  • 结果:通义千问拿到的是纯净的、无歧义的上下文,从而给出了精准的回答,完全避免了被“讨厌蟒蛇”这条干扰信息带偏。

3 核心组件:HuggingFaceCrossEncoder

在理解了 Cross-Encoder 的理论优势后,我们需要一个具体的工具将其落地。在 Day 10 的代码中,我们使用了 LangChain 提供的 HuggingFaceCrossEncoder 类。

(1) 什么是 HuggingFaceCrossEncoder ?

简单来说,它是 LangChain 为 Hugging Face sentence-transformers 库 提供的一个 “连接器” (Wrapper)

  • 身份:它不是一个算法,而是一个模型加载器

  • 功能:它负责从 Hugging Face Hub 下载指定的重排序模型(如 BAAI/bge-reranker-base),并在本地运行推理。

  • 输入输出:你给它一对文本 (用户问题, 候选文档),它吐出一个 0~1 之间的 相关性分数

(2) 为什么要用它?

你可能会问:“为什么不直接让通义千问(LLM)帮我给文档打分?为什么非要下载一个额外的本地模型?”

这里涉及架构设计的三个关键考量:

  1. 术业有专攻 (Specialization)

    • LLM (通义千问) 是“生成式模型”,擅长写文章、续写句子。虽然也能打分,但那是“兼职”。

    • HuggingFaceCrossEncoder 加载的是 BERT 架构 的判别式模型。它生来就是为了做“分类”和“打分”的。在判断“这两句话是否相关”这个任务上,它比通用的 LLM 更敏锐、更专业。

  2. 成本与速度 (Cost & Latency)

    • 用 LLM 打分:你需要把粗排搜到的 20 篇文档全部发给 API。这会消耗大量 Token(钱),而且网络传输+生成等待会让用户等很久。

    • 用本地模型打分:bge-reranker-base 只有几百兆,在本地 CPU/GPU 上运行毫秒级就能出结果,而且 完全免费

总结:HuggingFaceCrossEncoder 是 RAG 系统中 性价比最高 的组件。它用极低的本地算力成本,换取了检索精度的巨大提升。


三、 实战:代码实现

1. 全链路序列图

2. 安装依赖

我们需要安装 sentence-transformers 来运行 Cross-Encoder 模型。

pip install sentence-transformers langchain-community

3. 编写重排序器 (src/core/reranker.py)

我们将封装一个 RerankEngine。为了演示方便且不消耗 API 费用,我们使用 BGE-Reranker(目前开源界最强的重排模型之一)。

新建文件:src/core/reranker.py

# src/core/reranker.py
"""
重排序引擎 - 使用 HuggingFace Cross-Encoder 对检索结果进行精排
使用 sentence-transformers 库直接实现,不依赖 LangChain 的不稳定 API
"""

from sentence_transformers import CrossEncoder
from typing import List
from langchain_core.documents import Document
from src.utils.logger import logger


class RerankEngine:
    def __init__(self, model_name="BAAI/bge-reranker-base", top_n=3):
        """
        初始化重排序引擎
        
        Args:
            model_name: Cross-Encoder 模型名称,默认使用 BAAI 的中文重排序模型
            top_n: 返回前 N 个最相关的文档
        """
        logger.info(f"⚡ 正在加载 Re-ranking 模型: {model_name}...")
        
        try:
            self.model = CrossEncoder(model_name, max_length=512)
            self.top_n = top_n
            logger.info(f"✅ Re-ranking 模型加载成功 (top_n={top_n})")
        except Exception as e:
            logger.error(f"❌ 模型加载失败: {e}")
            raise
    
    def rerank(self, query: str, documents: List[Document]) -> List[Document]:
        """
        对文档列表进行重排序
        
        Args:
            query: 用户查询
            documents: 待排序的文档列表
            
        Returns:
            按相关度排序后的前 top_n 个文档
        """
        if not documents:
            return []
        
        # 准备输入对 (query, doc_content)
        pairs = [[query, doc.page_content] for doc in documents]
        
        # 计算相关度分数
        scores = self.model.predict(pairs)
        
        # 按分数排序,获取 top_n
        sorted_indices = scores.argsort()[::-1][:self.top_n]
        
        # 返回排序后的文档
        reranked_docs = [documents[i] for i in sorted_indices]
        
        logger.debug(f"🎯 重排序完成: {len(documents)} -> {len(reranked_docs)} 个文档")
        return reranked_docs
    
    def get_rerank_function(self):
        """
        返回一个可调用的重排序函数,用于 LCEL 链
        """
        def rerank_wrapper(inputs):
            """
            包装器函数,用于在 LCEL 链中使用
            预期输入: {'query': str, 'documents': List[Document]}
            """
            query = inputs.get('query', '')
            documents = inputs.get('documents', [])
            return self.rerank(query, documents)
        
        return rerank_wrapper

4. 升级主程序 (main.py)

我们需要在 Day 9 的基础上,把 retriever 替换为经过 RerankEngine 包装后的增强版检索器。

修改文件:main.py

# ==============================================================================
# Project Echo Day 9: Multi-Query RAG Integration
# 集成特性: Redis记忆 + 情绪识别 + Multi-Query知识库检索
# ==============================================================================

# LangChain LCEL 核心组件
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from operator import itemgetter  # 【修复】用于从字典中提取字段

# 项目核心模块
from src.core.llm import LLMClient
from src.core.prompts import PROMPTS
from src.utils.logger import logger
from src.config.settings import settings
from src.core.emotion import EmotionEngine   # Day 5: 情绪
from src.core.knowledge import KnowledgeBase # Day 7-9: 知识库
from src.core.reranker import RerankEngine   # Day 10: 重排序

# --- Redis 历史记录工厂 (Day 6) ---
def get_session_history(session_id: str) -> BaseChatMessageHistory:
    return RedisChatMessageHistory(
        session_id=session_id,
        url=settings.REDIS_URL,
        ttl=3600 * 24 * 7 # 记忆保留 7 天
    )

# --- 辅助函数:格式化文档 ---
def format_docs(docs):
    return "\n\n".join([d.page_content for d in docs])

def main():
    logger.info("🚀 --- Project Echo: Day 9 集成版启动 ---")
    
    # ==========================================
    # 1. 初始化组件
    # ==========================================
    # 1.1 大模型 (Brain)
    client = LLMClient()
    llm = client.get_client()
    
    # 1.2 情绪引擎 (Heart)
    emotion_engine = EmotionEngine()
    
    # 1.3 知识库 (Book)
    kb = KnowledgeBase()
    
    # 【Day 9 核心】获取多重查询检索器
    # 这里我们将 LLM 传进去,让检索器具备"思考"能力
    base_retriever = kb.get_multiquery_retriever(llm)
    
    # 【Day 10 新增】重排序引擎:对粗排结果进行精排
    reranker = RerankEngine(model_name="BAAI/bge-reranker-base", top_n=3)
    
    # 封装成 LCEL Runnable:先粗排,再精排
    def retriever_with_rerank(query: str):
        docs = base_retriever.invoke(query)
        return reranker.rerank(query, docs)
    
    retriever = RunnableLambda(retriever_with_rerank)

    # ==========================================
    # 2. 构建 Prompt 模板
    # ==========================================
    sys_prompt_base = PROMPTS["tsundere"]
    
    prompt = ChatPromptTemplate.from_messages([
        ("system", sys_prompt_base),                   # 1. 基础人设 (傲娇)
        ("system", "{emotion_context}"),               # 2. 情绪指令 (动态注入)
        ("system", "【参考资料(必须基于此回答)】:\n{context}"), # 3. 知识库资料 (RAG)
        MessagesPlaceholder(variable_name="history"),  # 4. 历史记忆 (Redis)
        ("human", "{input}")                           # 5. 用户输入
    ])

    # ==========================================
    # 3. 组装 LCEL 流水线
    # ==========================================
    rag_chain = (
        {
            # 分支 A: 智能检索 (用户输入 -> 裂变3个问题 -> 并行检索 -> 汇总 -> 格式化)
            "context": itemgetter("input") | retriever | format_docs,  # 【修复】使用 itemgetter 提取 input
                
            # 分支 B: 透传参数 (直接传递给 Prompt)
            "input": itemgetter("input"),              # 【修复】提取 input 字段
            "emotion_context": itemgetter("emotion_context"),  # 【修复】提取 emotion_context 字段
            "history": itemgetter("history")           # 【修复】提取 history 字段
        }
        | prompt  # 填入模板
        | llm     # 大模型推理
    )

    # ==========================================
    # 4. 挂载持久化记忆
    # ==========================================
    final_chain = RunnableWithMessageHistory(
        rag_chain,
        get_session_history,
        input_messages_key="input",
        history_messages_key="history",
    )

    print("\n✨ 系统就绪!试试问得模糊一点,比如“那个写代码的人爱吃啥?”\n")
    session_id = "user_day9_demo"

    # ==========================================
    # 5. 对话循环
    # ==========================================
    while True:
        user_input = input("You: ")
        if user_input.lower() in ["quit", "exit"]:
            break
            
        if user_input.strip():
            # --- Phase A: 情绪侦探 ---
            current_emotion = emotion_engine.analyze(user_input)
            
            emotion_instruction = "用户情绪平稳。"
            if "[愤怒]" in current_emotion:
                emotion_instruction = "⚠️ 警告:用户很生气!请示弱道歉。"
            elif "[悲伤]" in current_emotion:
                emotion_instruction = "⚠️ 提示:用户很难过。请温柔安慰。"

            try:
                # --- Phase B: 执行 RAG 主链 ---
                # 这一步会自动触发 Multi-Query 检索
                logger.info("🔍 正在进行多重检索与思考...")
                
                response = final_chain.invoke(
                    {
                        "input": user_input, 
                        "emotion_context": emotion_instruction
                    },
                    config={"configurable": {"session_id": session_id}}
                )
                
                print(f"Bot ({current_emotion}): {response.content}\n")
                
            except Exception as e:
                logger.error(f"❌ 调用失败: {e}")

if __name__ == "__main__":
    main()

四、 效果验证:去伪存真

为了验证 Re-rank 的效果,我们需要一个干扰项

  1. 准备数据:在 resources 里加两句话:

    • A: "阿强最喜欢的编程语言是 Python。" (真答案)

    • B: "阿强非常讨厌蟒蛇 (Python),看到就跑。" (干扰项,字面包含 Python)

  2. 运行 ETL (ingest.py) 入库。

  3. 提问:“阿强对 Python 的看法?”

  • 没有 Re-rank (Day 9)

    • 可能同时返回 A 和 B。

    • AI 可能会困惑:“资料说他喜欢 Python,又说他讨厌蟒蛇...”

  • 有 Re-rank (Day 10)

    • Cross-Encoder 会分析语义:

      • (问题, A) -> 分数 0.98 (非常相关)

      • (问题, B) -> 分数 0.12 (虽然有 Python 这个词,但语义是动物,不相关)

    • 结果:B 被过滤掉,只会把 A 发给大模型。

    • AI 回答:非常坚定地说他喜欢 Python 语言。


五、 总结与预告

今天我们完成了 Phase 3 的最后一块拼图。
现在的“傲娇酱”,拥有了 Multi-Query (广度) 和 Re-rank (精度) 的双重加持,在聊天上变得更加的准确,接下来将给她装上更多的高端能力。

Phase 4 预告 (Day 11)
文字聊得再开心,终究是冷冰冰的屏幕。
明天我们将进入激动人心的 Phase 4:多模态交互。我们要给 AI 装上 TTS (语音合成) 模块,让傲娇酱不仅能打字,还能开口说话

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐