【AI】Agent 专项进阶|RAG 混合检索策略

简介: 本文介绍混合检索在知识召回中的应用,结合向量检索(语义匹配)与BM25关键词检索(精确匹配),克服单一方法的局限。通过建立双索引、并行召回、统一排名(采用RRF融合)及可选精排,显著提升召回准确率。实战测试显示,混合检索在顶1、顶3命中率上优于单一方案,尤其擅长处理术语、编号等关键信息。代码实现基于LangChain,强调领域词典加载与分词优化,适用于垂直场景下的高质量问答系统构建。

200x200

  ◆ 博主名称: QuZhengRong

  AI生产者,样式苦手

⭐️ Agent专栏Agent

⭐️ LuckReport专栏LuckReport

⭐️ SpringBoot专栏SpringBoot


目录

image

文档切完之后,下一步是从切片里召回上下文。 向量检索 是上下文召回的主流方案,但仅靠向量检索容易召回语义相近但事实无关的切片,影响后续回答质量,因此生产环境普遍采用混合检索的方案来提高召回效果,这一篇要讲的就是混合检索。

一、概念:什么是混合检索

混合检索就是同时采用向量检索和关键词检索,将两者检索的结果汇总成一份候选列表。

image

向量检索:使用嵌入 模型 将文本转换为向量,在向量数据库中做相似度匹配,依据余弦相似度做结果排序。

关键词检索(主流为 BM25):基于字面词语重合度打分,擅长处理编号、专有名词、数字与固定术语。

两种检索都有短板:向量检索对精确字面不敏感,容易丢失业务 ID、标识符等关键字;BM25 则没法理解语义。

因此采用双路并行检索的策略,向量补语义,BM25 补精确匹配。代价是要建两套索引、做一次融合,延迟和实现复杂度都比单路高。

二、混合检索实现方案

混合检索可以分成四步:建立索引 → 双路召回 → 统一排名 → 精排(可选)。

1、建立索引

BM25 和向量共用同一批切片,chunk id 保持一致,这样两路检索的结果合并时才能识别出哪些 chunk 是重复的。

向量路用 embedding 入库,关键字路则需要建立倒排索引,

image

重点讲下关键字路的步骤,关键字路在切片前要先分词。在垂直领域场景下,Jieba 默认词典缺少大量行业专有术语,会出现错误单字拆分,例如将「动线」切分为「动」「线」。单字词文档频率 DF 普遍偏高、IDF 权重极低,导致核心术语失效,实质等同于关键词丢失。因此垂直知识库在建索引前,必须提前导入领域术语自定义词典,保证专业词正确分词。

分完词写入倒排索引:词 → 出现过该词的文档列表(以及词频)。另外存每篇文档长度、语料平均长度、每个词的 DF,打分时直接取。demo 可以全放内存;生产一般交给 Elasticsearch / OpenSearch 维护倒排,文档更新时同步写入或重建索引。部分向量库也带稀疏检索,本质仍是倒排 + 类似 BM25 的打分。

2、双路召回

查询时两路并行,各出一份 top-k ,再交给后面融合。向量路用余弦相似度;BM25 路把查询同样分词,按词去倒排取候选,再算分。

BM25 用于计算查询词与文档之间的字面相关性得分,得分主要由三部分构成:

  1. 词频(TF):查询词在文档中出现次数越多,得分越高。
  2. 逆文档频率(IDF):词语在语料库中越稀有,命中后贡献的分数越高。
  3. 长度归一化:相同词频条件下,长文档会做分数折扣,避免长文档天然获得更高分数。

3、统一排名

两路分数计算方式不同,所以不能直接拿来比较排名,常见的融合方式有以下两种:

归一化加权:将两路得分通过 min‑max 归一化至 0~1 区间,再按权重求和。希望偏向语义匹配则调高向量检索权重;文档中术语、编号较多时,则调高 BM25 权重。缺点是归一化结果依赖当前候选集合,候选集发生变化,同一文档归一化后的分数也会变动,不同查询之间分数不具备可比性。

RRF(Reciprocal Rank Fusion):舍弃原始得分,仅依靠结果排名计算分数:

score = Σ 1 / (k + rank)

rank 从 1 起,k 常取 60。此时第 1 名 ≈ 0.0164,第 10 名 ≈ 0.0143。

无明确偏向需求时优先选用 RRF;业务需要刻意倾斜某一路检索结果,再选择归一化加权。

4、精排

融合仅完成粗排阶段。RRF 算法 本身不会解析 Query 和文档的实际内容,如果两路检索都召回了无关切片,依然会被排在靠前位置。

精排把候选和 query 配对,按对回答有没有用重打分。有专用 rerank 模型最好;没有的话,可以把候选一次性塞进 大模型 打 0~10 分再排序。

三、实战

做一个餐饮门店经营避坑手册的混合检索实战:手册切成 23 块,自拟 12 道题,每题标一个答案片段里必须出现的关键词,对比向量、BM25、混合三路在 top1 / top3 的命中率。

向量:命中率@1 8/12 = 67%,命中率@3 12/12 = 100%
BM25:命中率@1 9/12 = 75%,命中率@3 10/12 = 83%
混合:命中率@1 9/12 = 75%,命中率@3 12/12 = 100%

混合把 BM25 漏掉的改写题补回来了;@1 没有超过 BM25,样本只有 12 条,当参考。

1、切片与词典

from langchain_text_splitters import RecursiveCharacterTextSplitter

CN_SEPARATORS = ["\n## ", "\n### ", "\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]

def load_chunks() -> list[str]:
    text = DOC_PATH.read_text(encoding="utf-8")
    splitter = RecursiveCharacterTextSplitter(
        separators=CN_SEPARATORS,
        chunk_size=250,
        chunk_overlap=40,
        length_function=len,
    )
    return splitter.split_text(text)
# 不加会切成单字,BM25 基本废掉
for word in ["动线", "坪效", "扣点", "档口", "毛利", "复购", "引流款", "利润款", "形象款", "SKU"]:
    jieba.add_word(word)

2、BM25

class BM25:
    def __init__(self, corpus: list[str], k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.doc_count = len(corpus)
        self.doc_terms = [tokenize(doc) for doc in corpus]
        self.doc_lens = [len(terms) for terms in self.doc_terms]
        self.avg_len = sum(self.doc_lens) / self.doc_count
        self.term_freqs = [Counter(terms) for terms in self.doc_terms]

        doc_freq: Counter = Counter()
        for terms in self.doc_terms:
            doc_freq.update(set(terms))
        self.doc_freq = doc_freq

    def _idf(self, term: str) -> float:
        df = self.doc_freq.get(term, 0)
        return math.log(1 + (self.doc_count - df + 0.5) / (df + 0.5))

    def scores(self, query: str) -> list[float]:
        terms = tokenize(query)
        result = []
        for i in range(self.doc_count):
            score = 0.0
            for term in terms:
                tf = self.term_freqs[i].get(term, 0)
                if tf == 0:
                    continue
                # 长文档同样词频要打折
                norm = 1 - self.b + self.b * self.doc_lens[i] / self.avg_len
                score += self._idf(term) * tf * (self.k1 + 1) / (tf + self.k1 * norm)
            result.append(score)
        return result

3、融合

def min_max_normalize(scores: dict[int, float]) -> dict[int, float]:
    if not scores:
        return {
   }
    values = list(scores.values())
    low, high = min(values), max(values)
    if high == low:
        return {
   key: 1.0 for key in scores}
    return {
   key: (value - low) / (high - low) for key, value in scores.items()}

def weighted_fusion(vector_hits, bm25_hits, vector_weight=0.5, bm25_weight=0.5):
    vector_scores = min_max_normalize(dict(vector_hits))
    bm25_scores = min_max_normalize(dict(bm25_hits))

    fused = {
   }
    for idx in set(vector_scores) | set(bm25_scores):
        fused[idx] = (
            vector_weight * vector_scores.get(idx, 0.0)
            + bm25_weight * bm25_scores.get(idx, 0.0)
        )
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)

def rrf_fusion(vector_hits, bm25_hits, k: int = 60):
    fused = {
   }
    for hits in (vector_hits, bm25_hits):
        for rank, (idx, _) in enumerate(hits):
            fused[idx] = fused.get(idx, 0.0) + 1 / (k + rank + 1)
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)

问 “1.2 租金红线” 时,两路第一名一样,差在二三名:

问题:1.2 租金红线
  向量路召回 8 条,BM25 路召回 4 条
  纯向量排序
    1. [分 0.6568] ### 1.2 租金红线
    2. [分 0.5076] ## 二、成本结构:三条红线
    3. [分 0.5032] ## 一、选址:先看动线,再谈租金
  纯 BM25 排序
    1. [分 6.9532] ### 1.2 租金红线
    2. [分 3.5932] ## 一、选址:先看动线,再谈租金
    3. [分 2.6597] ### 1.1 三种铺位类型
  归一化加权融合
    1. [分 1.0000] ### 1.2 租金红线
    2. [分 0.3826] ## 一、选址:先看动线,再谈租金
    3. [分 0.2353] ### 1.1 三种铺位类型
  RRF 融合
    1. [分 0.0328] ### 1.2 租金红线
    2. [分 0.0320] ## 一、选址:先看动线,再谈租金
    3. [分 0.0318] ## 二、成本结构:三条红线

4、精排

RERANK_PROMPT = """你是检索排序助手。请判断下面每个候选片段对回答用户问题的有用程度,给 0~10 的整数分。
10 分表示片段直接回答问题,0 分表示完全无关。片段之间分数可以相同。
只输出 JSON 数组,不要任何解释文字:形如 [{"id": 0, "score": 8}]

用户问题:{query}

候选片段:
{candidates}
"""

def rerank(query: str, candidates: list[str]) -> list[tuple[int, float]]:
    formatted = "\n".join(f"[{i}] {text[:200]}" for i, text in enumerate(candidates))
    response = llm.invoke(RERANK_PROMPT.format(query=query, candidates=formatted))
    content = re.sub(r"^```json|```$", "", response.content.strip(), flags=re.MULTILINE).strip()

    try:
        scored = json.loads(content)
    except json.JSONDecodeError:
        print("  精排返回解析失败,沿用召回顺序")
        return [(i, 0.0) for i in range(len(candidates))]

    ranked = [(item["id"], float(item["score"])) for item in scored]
    return sorted(ranked, key=lambda x: x[1], reverse=True)

外卖满减这题,RRF 把 “出餐速度” 排进前三;精排后掉出去,“定价错误” 顶上来——满减亏不亏本质是定价,得读内容才分得清:

问题:外卖满减怎么设计才不亏本
  召回候选 10 条
  RRF 融合排序 top3:
    1. [分 0.0328] ## 六、线上运营与外卖
    2. [分 0.0320] ### 2.1 容易被忽略的隐性成本
    3. [分 0.0312] ### 6.1 出餐速度是外卖的生命线
  精排后 top3:
    1. [分 10] ## 六、线上运营与外卖
    2. [分 9] ### 3.1 定价的常见错误
    3. [分 8] ### 2.1 容易被忽略的隐性成本

评测脚本在 recall_eval.py,题集要掺改写问法,不然数字偏向 BM25:

TEST_SET = [
    ("加盟品牌怎么判断靠不靠谱", "品牌直营店"),
    ("1.2 租金红线", "百分之十五"),
    ("金角银边草肚皮", "草肚皮"),
    ("厨师走了配方怎么办", "料包"),
    ("外卖满减怎么设计才不亏本", "反推利润"),
    ("供应商出事了怎么办", "三文鱼"),
    ("店长每天要盯什么", "顾客投诉"),
    ("关店前要算哪些账", "储值卡"),
    # 改写问法,字面对不上,专门打向量路
    ("后厨师傅一走顾客就不来了,怎么防", "料包"),
    ("每天生意看着还行,月底一算没利润", "折旧"),
    ("平台抽完成到手还剩多少", "百分之七十"),
    ("亏到什么程度就该收手了", "一点五倍"),
]

四、总结

混合检索整体流程包含:索引构建、双路召回、结果归并排序,必要时追加精排环节。

向量检索负责语义匹配,BM25 负责字面关键词匹配;结果融合优先选用 RRF 算法,若需要侧重某一路结果,可改用加权融合;精排会读取文本实际内容,用于修正粗排输出的顺序。

五、LuckReport 项目推荐

在这里插入图片描述

导航:LuckReport专栏

1、项目简介

Luck-Report 是一款基于开源项目 UReport2 重构的 Java 高性能报表引擎,通过迭代单元格可以实现任意复杂的中国式报表。相较于 UReport2,Luck-Report 在技术架构上进行了全新升级,后端基于 SpringBoot 框架开发、前端采用 Vue 框架构建,技术选型贴合当下主流项目开发标准,可精准适配各类实际开发需求。

Luck-Report 提供了全新的基于网页的报表设计器,可以在 Chrome、Firefox、Edge 等各种主流浏览器运行(IE 浏览器除外)。使用 Luck-Report,打开浏览器即可完成各种复杂报表的设计制作。

Luck-Report 基于 Apache-2.0 开源协议 开源

2、在线体验

相关文章
|
9天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
9天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
15天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
10天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1907 15
|
8天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1017 1
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
14天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1669 4
|
10天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
16天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1819 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
11天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
821 2
|
9天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
831 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)

热门文章

最新文章