前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯10189
  • 编码 Agent 实测:成本之外看交付细节
  • Mercury 3 发布:主打高速生成与百万上下文
  • 用 Node 拆解向量池化与检索质量
  • 补齐 Oracle 方言约束,轻量模型首答率跃升
  • 拆解 Claude Code 的完整智能体循环
  • 邮件 Agent 重试前先校验退信
  • 用信号队列约束 Agent 等待邮件
  • 用 Cloudflare 搭建 Agent 收信中枢
  • 一次字符串替换如何让线上文章翻倍
  • AI 搭项目失败,先确认代码是否拉取成功
  • 检索匹配再高,也救不了过期知识库
  • 本地模型帮你给常用命令起快捷名
  • MCP 无状态改版涉及哪些迁移工作
  • 检查 Supabase 服务端鉴权的信任来源
  • 流式 AI 接口延迟该如何拆分测量
  • 模型故障切换如何追踪实际调用路径
  • 百灵开放五款语音模型供开发者组合
  • Drex Max 为电脑操作智能体输出决策概率
  • 无人值守的 Claude 循环如何耗尽额度
  • Bedrock为Claude提供严格结构化输出
  • 错误码搜不准?给向量检索补上 BM25
  • 让 Agent 崩溃后续跑,并处理重复副作用
  • 用 TypeScript 搭建带人工审批的客服智能体
  • Copilot 模型策略支持 REST API 管理
  • Copilot多种使用场景启动模型弃用
  • 把框架说明随包交付,让编程智能体可用
  • Nx 恶意包如何借编程智能体窃取秘密
  • 用分层防护限制 Agent 越权与误操作
  • 在表格中用自然语言分析完整BigQuery数据
  • 亚马逊推 TorchNeuron,拓宽 PyTorch 芯片选择
  • OpenCode平台遭入侵,密钥被盗刷
  • Cloudflare如何将AI审查成本降68%
  • 将Copilot任务无缝接续到VS Code
  • Gemini 4 Argon 主打多 Agent 三维制作
  • 用关联 ID 串起 Agent 日志与异常
  • MiniMax新模型以半价提升编程表现
  • 用ADK消除语音代理调用工具时的冷场
  • 62行Bash拆解编程代理的运行机制
  • 从调用轨迹定位代理的延迟与费用问题
  • AI 迁移脚本删库:备份失败为何未阻断
  • DeepSeek 推出 Agent 搜索与网页抓取接口
  • Bedrock新增长视频结构化解析模型
  • 语音识别迁移 Trainium2 的优化实战
  • vLLM 推送式 KV 传输降低推理等待
  • vLLM 双向复用 KV 缓存,降低多轮首字延迟
  • 英伟达发布面向企业与个人电脑的开放模型
  • Agent 工具报错,先判断副作用再重试
  • 在常驻虚拟机上搭建持久化编程 Agent 团队
  • 用DGX Spark承载本地n8n AI工作流
  • 训练数据权限如何随流水线变换传播
  • 用 AI 在 PR 中自动补齐架构决策记录
  • 已加载 51 / 10189
8.0
热点
AI SCORE
技术实践2026-10-20 07:30

错误码搜不准?给向量检索补上 BM25

dev.to · AI#混合检索#RAG#BM25
Editor brief · 编辑速览

文档助手将 E4012 的正确页面排到第 14 位,暴露了纯向量检索对精确标识符的不足。文章建议并行运行 BM25 与向量检索,用 RRF 合并排名,并检查分词器是否拆散错误码和版本号。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

一位用户在我的文档助手里输入了 E4012。排名第一的结果是 E4021 的页面,第二是 E4013。真正解释 E4012 的页面排在第 14 位,远远超出了我只取前 5 条结果的范围。于是,LLM 根本没看到正确页面,却信心十足地解释了另一个错误。

一张截图,就把 BM25 与 embeddings 的问题说清楚了。Dense vector search 很擅长回答“这是什么意思”,却不擅长“找到这个确切的字符串”。错误码、SKU、函数名、工单 ID 和版本号,恰好都落在这个缺口里。

我花了一个晚上,一直认定是索引坏了。其实没有。Embedding 模型只是在老老实实地做它原本被设计来做的事。

Embedding 模型会把文本压缩成语义。在它们看来,E4012 和 E4021 的意思几乎一样,都是“这个产品里的某个错误码”,所以它们的向量会落在彼此附近。

BM25 根据 token 的精确匹配来打分,再按 token 的稀有程度加权。如果一个错误码只出现在一篇文档里,它就会获得很高的 IDF 权重,BM25 因而会把那篇文档排在第一位。

解决办法是混合检索:并行运行 BM25 和向量搜索,再用 Reciprocal Rank Fusion(RRF,倒数排名融合)合并结果。

记得检查 BM25 的 tokenizer。如果默认 analyzer 把 ERR_CONN_RESET 或 v2.3.1 拆成碎片,你为了获得精确匹配能力而引入 BM25 的优势,也就被它拆没了。

在我自己的 120 条查询测试集上,混合检索补回了纯 dense 检索漏掉的所有标识符查询,同时没有影响自然语言查询的效果。

为什么向量搜索找不准错误码这类精确标识符?

向量搜索在标识符上失手,是因为 embedding 编码的是一段文本在讲什么,而不是它包含哪些确切字符。两篇介绍相邻错误码的页面,讲的事情差不多,因此得到的向量也几乎一样。

来看看 E4012 会经历什么:

  • Tokenization。 Embedding 模型的 subword tokenizer 会把 E4012 拆成片段,比如 E、40、12。这些片段本身都没什么特别。E4021 则会变成 E、40、21。大部分片段是重合的。
  • 上下文混合。 Transformer 会把每个 token 与相邻 token 的信息混合起来。在文档页面里,错误码周围通常是“连接超时时会出现此错误”这样的句子。这些上下文占据了主导地位。
  • Pooling。 Mean pooling(或者 CLS token)会把所有信息压缩成一个包含 384 到 1024 个浮点数的向量。300 个 token 里只有几个数字 token,几乎改变不了平均值。

结果就是:E4012 页面和 E4021 页面在向量空间里只差一丁点。查询 E4012 时,究竟哪个页面排在前面,基本取决于噪声。

查询越短,问题越严重。单独一个 E4012 几乎没有上下文,所以它的 embedding 只是一个模糊的“像是错误码”的向量。它和所有错误页面都比较接近,却没有特别接近其中某一篇。

BM25 的文档排序方式有什么不同?

BM25 会统计词项的精确匹配,再根据每个词项在整个语料库中的稀有程度加权,从而给文档排序。如果一个词项只出现在一篇文档里,它的权重就会很高,那篇文档也就会胜出。

评分公式包含三个组成部分:

  • IDF(逆文档频率): log((N - n + 0.5) / (n + 0.5) + 1),其中 N 是文档总数,n 是包含该词项的文档数。在我大约有 2,300 个 chunk 的语料库里,像“connection”这样的词出现在数百个 chunk 中,而 e4012 只出现在 3 个 chunk 中。
  • 带饱和机制的词频: k1 参数(默认值通常在 1.2 到 1.5 之间)意味着,一个词项出现 10 次,效果并不会是出现 1 次的 10 倍。
  • 长度归一化: b 参数(默认值为 0.75)可以避免长 chunk 仅仅因为篇幅更长就占据优势。

代入这些数字,e4012 的 IDF 大约是 6.5,而“connection”只有 1.5 左右。BM25 并不知道什么是错误码。它只知道这个 token 很少见,而查询里恰好包含它。对于标识符查找,这种判断方式正合适。

下面是使用 rank_bm25 的最小实现:

import re
from rank_bm25 import BM25Okapi

def tokenize(text: str) -> list[str]:
    # keep codes like E4012, ERR_CONN_RESET, v2.3.1 as single tokens
    return re.findall(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*[A-Za-z0-9_]|[A-Za-z0-9]", text.lower())

corpus_tokens = [tokenize(c.text) for c in chunks]
bm25 = BM25Okapi(corpus_tokens, k1=1.2, b=0.75)

scores = bm25.get_scores(tokenize("E4012"))

为什么我第一次尝试 BM25 也没成功?

我第一次尝试 BM25 失败,是因为 tokenizer 在建立索引之前,就把标识符拆成了碎片。一旦 ERR_CONN_RESET 变成 err、conn、reset 这三个常见词,稀有词项的优势就消失了。

我一开始用的是 text.split()。它会把带尾随逗号的 E4012, 保留下来,视为与 E4012 不同的 token,导致我有一半的匹配悄无声息地丢了。后来我换成了一个会去掉标点的“正规”分词器,结果它把 v2.3.1 拆成 v2、3、1,又把 ERR_CONN_RESET 拆成了三个普通词。

无论你使用哪种 BM25 实现,都要检查下面这些问题,包括 Elasticsearch、OpenSearch、Postgres full-text、Tantivy 和 rank_bm25:

  • Analyzer 会不会在 _、. 或 - 处分词?拿一个真实的标识符跑一下,看看结果。
  • Stemming 会不会处理错误码?把 errors 变成 error 的 stemmer 没问题,但会把 HTTP_403s 弄坏的就不行。
  • 查询和文档是否经过同一个 tokenizer?Analyzer 不一致,是导致零命中的经典 bug。
  • 对错误码来说,转小写通常没问题。但如果你的产品把 E4012 和 e4012 当成两个不同的东西,就不能这么做了——希望你们没有这样的设计。

上面的正则表达式很粗糙,而这恰恰是它的用意。它会把任何看起来像标识符的内容保留为一个整体。在信任它之前,先拿你那些最难处理的 ID 测一遍。

如何结合 BM25 和 embeddings?使用 Reciprocal Rank Fusion

同时运行两种搜索,再用 Reciprocal Rank Fusion 合并两份排序列表,就能把它们结合起来。RRF 不看原始分数,只看排名位置。这一点很重要,因为 BM25 分数和 cosine similarity 完全不在同一个量级上。

公式是:对每篇文档,在所有包含它的列表中,分别计算 1 / (k + rank),然后求和。原始 RRF 论文中常用的默认值是 k = 60。

def rrf(result_lists: list[list[str]], k: int = 60, top_n: int = 5) -> list[str]:
    scores: dict[str, float] = {}
    for results in result_lists:
        for rank, doc_id in enumerate(results, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)[:top_n]

dense_ids = vector_search(query, limit=50)
sparse_ids = bm25_search(query, limit=50)
context_ids = rrf([dense_ids, sparse_ids])

为什么这对 E4012 有效?BM25 把正确页面排在第 1 位,得分为 1/61。Dense 检索把它排在第 14 位,得分为 1/74。总分大约是 0.030。E4021 页面虽然在 dense 检索里排第 1,却根本没有出现在 BM25 的结果列表里,所以它只有 1/61,也就是大约 0.016 分。正确页面的分数几乎是它的两倍。

而面对“为什么我的上传一直卡着不动”这样的模糊问题,BM25 返回的匹配往往比较弱,也比较分散,dense 检索却能准确找到答案。RRF 让每次查询中更强的信号发挥作用,你无需再写一个 router。

每一侧都取 50 个候选结果,别只取 5 个。如果正确文档在某份列表里排第 14,而你只取了前 10 个,融合阶段就永远看不到它。

混合检索给我的测试集带来了什么变化?

在我自己的测试集上,混合检索找回了纯 dense 检索漏掉的所有标识符查询,自然语言查询的表现则基本保持不变。这些数字来自我的语料库和 embedding 模型,并不是通用 benchmark。

测试设置是:从支持工单中抽取 120 条真实查询,为每条查询标注能够回答它的 chunk。其中 38 条包含标识符,例如错误码、配置键、CLI flag 或版本字符串。我衡量的是正确 chunk 是否进入前 5 名。

  • 仅使用 dense 检索: 38 条标识符查询命中了 21 条。自然语言查询表现正常。
  • 仅使用 BM25,搭配简单 tokenizer: 标识符查询的表现比我预期还差,原因就是前面提到的拆碎问题。
  • 仅使用 BM25,搭配保留标识符的 tokenizer: 38 条标识符查询命中了 36 条,但面对换一种说法的问题,效果就不行了。
  • 混合检索搭配 RRF: 38 条标识符查询全部命中;在这个规模的测试集上,自然语言查询没有出现我能测出的损失。

120 条查询确实不多。把它理解为“方向是对的”,别把它当成“效果就一定是这个数字”。用真实查询建立你自己的评测集;只有它才能告诉你,你的语料库实际表现如何。

直接加一个 reranker 行不行?

Cross-encoder reranker 能改善排序,但它只能重排检索阶段交给它的结果。如果 dense 检索把正确 chunk 埋在第 14 位,而你只取了 10 个候选结果,再好的 reranker 也看不到它。

Reranker 读到的同样是那些 subword 片段,所以虽然它们处理标识符的能力比 bi-encoder 强,也并非不会出错。把 reranker 放在混合检索之后,而不是用它替代混合检索。

我现在会做的几项低成本检查

  • 用 grep 在查询日志中查找匹配 [A-Z]+\d{3,}、\w+_\w+ 或 v\d+\.\d+ 的内容,由此估算标识符查询在流量中的占比。我的占比大约是三分之一。
  • 对每条标识符查询,检查包含那个确切字符串的文档是否进入前 5 名。如果没有,说明 retriever 是在猜。
  • 把 5 个真实 ID 送进 BM25 analyzer,直接看看生成的 token。
  • 在融合前记录两份排序列表。答案出错时,我就能看出是哪一侧掉了链子。

所以,BM25 和 embeddings 应该选哪个?

两个都用。Embeddings 找的是与查询含义相近的段落;BM25 找的是包含相同稀有 token 的段落。向量搜索会漏掉 E4012 这样的错误码,是因为 embedding 模型把错误码中的数字压缩进了一个由周围语义主导的向量,让相邻错误码的向量几乎重叠。BM25 则会给这个稀有的精确 token 很高的 IDF 权重,把匹配文档排在第一。运行两种 retriever,每一侧取大约 50 个候选结果,用 k = 60 的 Reciprocal Rank Fusion 合并,并确保 BM25 tokenizer 能完整保留标识符。这套组合既能处理“这个错误是什么意思”,也能处理“查找错误 E4012”。

本文作者是面试准备平台 Preterview 背后的开发者。

如果需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
Bedrock为Claude提供严格结构化输出
下一篇
让 Agent 崩溃后续跑,并处理重复副作用