ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

1500 行代码,召回率翻 3.4 倍:用 BM25+RRF+Rerank 搭一套可复现的生产级 RAG 系统

1500 行代码,召回率翻 3.4 倍:用 BM25+RRF+Rerank 搭一套可复现的生产级 RAG 系统 1. 从 Recall5 只有 0.175 说起一套 RAG 检索链路到底卡在哪如果你正在做 RAG检索增强生成大概率遇到过这种场景知识库文档明明就在那里用户问的问题也不刁钻但系统返回的 5 条结果里就是找不到那条最相关的。你换了更大的 Embedding 模型调了 chunk size甚至把温度参数来回改召回率还是上不去。我最近在 772 篇 Markdown 文档、8337 个章节的知识库上搭了一套检索服务第一版上线后跑评测Recall5 只有 0.175。什么意思给用户看 5 条结果平均命中不到 1 条相关文档。这不叫 RAG这叫随机推荐。后来经过 7 轮优化其中 3 轮是失败的最终把 Recall5 拉到 0.600MRR 从 0.564 提升到 0.806Hit Rate5 达到 1.0平均延迟控制在 826ms 以内。整套系统约 1500 行 Python 代码依赖只有 FastAPI ChromaDB rank_bm25 jieba没有用 LangChain 或 LlamaIndex。这篇文章会把检索链路的每个环节拆开讲BM25 稀疏召回怎么配、RRF 多路融合的权重怎么定、Rerank 精排的候选集设多大、父子节点注入的阈值卡在哪。同时会给出可复制的检索参数、融合权重和评测脚本并演示如何用 TaoToken 统一 Key/API 通道跑通 LLM 生成环节的验证动作。适合谁看正在做 RAG 系统但检索效果不理想的工程师想从零手写一套可复现检索链路的开发者对 BM25、RRF、Rerank 这些概念知道名字但没实际调过参数的人。先说结论RAG 效果不好往往不是模型不行而是工程设计没到位。下面按检索链路的顺序从数据模型开始讲。2. 数据模型与前置准备章节树 TaoToken 统一通道2.1 为什么不用纯文本切块传统 RAG 把文档按固定 token 数切块比如 512 tokens然后索引每个 chunk。问题在于切块后你丢失了三层信息它属于哪篇文档、它在文档中的什么位置、它的父子章节是什么。我的做法是按 Markdown 标题H1-H6构建章节树。每个节点携带面包屑路径、LLM 增强摘要、源文件行号、父子关系。这样带来三个直接好处搜索结果能精确溯源到源文件行号检索到某个章节后可以自动返回它的子章节Embedding 文本里拼了面包屑路径比如RAG 关键设计点 切块策略比单纯的“切块策略”携带更多语义信息。2.2 技术选型组件选型理由EmbeddingBGE-M3中英混合首选1024 维是精度/成本甜点向量数据库ChromaDB8337 条数据完全够用零配置关键词检索BM25 jieba向量擅长语义BM25 擅长精确匹配RerankQwen3-Reranker-8B中文优化好API 调用免 GPU摘要生成Qwen2.5-7B-Instruct7B 对摘要绰绰有余Web 框架FastAPI异步、类型安全、自带文档一个重要的选型教训Embedding 和 BM25 一定要同时用。向量检索擅长“理解意思”“怎么切分文档”能匹配到“切块策略”BM25 擅长“精确匹配”搜“MCP”就是“MCP”不会被向量模型和“API”搞混。两者通过 RRF 融合效果远好于单通道。2.3 TaoToken 前置统一 Key/API 通道检索链路跑通后LLM 生成环节需要一个稳定的 API 通道。我用 TaoToken 来统一管理 Key 和 API 地址好处是摘要生成、Rerank、最终生成可以走同一个通道不用在多个平台之间切换配置。先拿到 API Key访问https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite创建一个 Key 并保存。Base URL 统一用https://taotoken.net/api注意这个地址不加 UTM 参数。如果你用的是 Claude Code 做开发辅助可以在配置里指定 Base URL 和 Key如果用的是 Cline 或 Codex同样在对应的 settings 或 auth.json 里填入三件套Base URL、API Key、Model ID。这三件套缺一不可后面排障章节会详细讲。模型对话调试可以用https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite对应的对话入口先验证 Key 是否可用。长期做编码或 Agent 任务的话Coding Plan 入口在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。前置准备做完下面进入可复制的配置环节。3. 可复制配置BM25 RRF Rerank 的完整参数3.1 检索参数配置先看核心检索参数。这些值是我在 7 轮优化后稳定下来的你可以直接复制到自己的 config 里# config/retrieval.py RETRIEVAL_CONFIG { vector_top_k: 60, # 向量通道召回数 bm25_top_k: 60, # BM25 通道召回数 rrf_k: 60, # RRF 常数通常取 60 rrf_weights: { vector: 2.0, # 向量通道权重 bm25: 1.0 # BM25 通道权重 }, rerank_top_n: 20, # Rerank 候选集大小 rerank_model: Qwen3-Reranker-8B, rerank_instruct: 根据用户查询检索与查询语义最相关的文档段落。, parent_child: { min_score: 0.8, # 触发父子注入的最低分 max_children: 2, # 最多注入子节点数 discount: 0.5 # 子节点分数折扣 }, query_expansion: { enabled: True, top_synonyms: 3, # 自动发现同义词数量 similarity_threshold: 0.75 } }几个关键点解释一下。vector_top_k和bm25_top_k都设 60是因为 RRF 融合需要足够的候选池太小会导致融合后候选不足太大则增加 Rerank 负担。rrf_k取 60 是 RRF 论文里的经典值实际调优时在 40-80 之间波动不大。rrf_weights里向量给 2.0、BM25 给 1.0是因为这个知识库以中文技术文档为主语义匹配的需求略高于精确匹配。3.2 RRF 融合实现RRFReciprocal Rank Fusion的核心公式很简单每个文档的融合分数等于各通道权重的加权和权重除以k 排名。实现如下def rrf_fusion(vector_results, bm25_results, k60, weightsNone): vector_results: [(doc_id, score), ...] 按分数降序 bm25_results: [(doc_id, score), ...] 按分数降序 if weights is None: weights {vector: 2.0, bm25: 1.0} fused {} for rank, (doc_id, _) in enumerate(vector_results, start1): fused[doc_id] fused.get(doc_id, 0) weights[vector] / (k rank) for rank, (doc_id, _) in enumerate(bm25_results, start1): fused[doc_id] fused.get(doc_id, 0) weights[bm25] / (k rank) return sorted(fused.items(), keylambda x: x[1], reverseTrue)注意这里用的是排名而不是原始分数。这是 RRF 的精髓不同通道的分数尺度不一样向量相似度是 0-1BM25 分数可能是 0-30直接加权会出问题。用排名就统一了尺度。3.3 Rerank 调用配置Rerank 走 TaoToken 通道配置如下import httpx RERANK_CONFIG { base_url: https://taotoken.net/api, model: Qwen3-Reranker-8B, instruct: 根据用户查询检索与查询语义最相关的文档段落。, top_n: 20 } async def rerank(query: str, candidates: list[dict]) - list[dict]: async with httpx.AsyncClient() as client: resp await client.post( f{RERANK_CONFIG[base_url]}/rerank, headers{Authorization: fBearer {API_KEY}}, json{ model: RERANK_CONFIG[model], query: query, documents: [c[text] for c in candidates], top_n: RERANK_CONFIG[top_n], instruct: RERANK_CONFIG[instruct] }, timeout30.0 ) resp.raise_for_status() results resp.json()[results] return [candidates[r[index]] | {rerank_score: r[relevance_score]} for r in results]Reranker 的 instruct 从英文改成中文后中文场景的 MRR 有明显提升。英文版“Given a web search query, retrieve relevant passages”效果一般中文版“根据用户查询检索与查询语义最相关的文档段落”更贴合中文语义。3.4 父子节点注入Rerank 后对高分结果 0.8的父节点注入最多 2 个子节点子节点分数打 5 折def inject_children(reranked_results, min_score0.8, max_children2, discount0.5): injected [] for item in reranked_results: injected.append(item) if item[rerank_score] min_score and item.get(children): for child in item[children][:max_children]: child_copy child.copy() child_copy[rerank_score] item[rerank_score] * discount child_copy[injected_from] item[doc_id] injected.append(child_copy) return injected关键词是“保守”。后面会讲到激进注入是失败的。配置片段到这里下面进入验证环节。4. 验证请求与成功结果跑通完整链路4.1 评测脚本先写一个评测脚本用 30 条标注数据跑 Recall5、MRR、Hit Rate5# eval/run_eval.py import json from retrieval import hybrid_search def evaluate(eval_file: str, top_k: int 5): with open(eval_file, r, encodingutf-8) as f: cases json.load(f) recall_hits, mrr_scores, hit_rates [], [], [] for case in cases: query case[query] gold_ids set(case[relevant_doc_ids]) results hybrid_search(query, top_ktop_k) retrieved_ids [r[doc_id] for r in results] # RecallK hits len(set(retrieved_ids) gold_ids) recall_hits.append(hits / len(gold_ids)) # MRR mrr 0.0 for rank, doc_id in enumerate(retrieved_ids, start1): if doc_id in gold_ids: mrr 1.0 / rank break mrr_scores.append(mrr) # Hit RateK hit_rates.append(1.0 if hits 0 else 0.0) n len(cases) return { Recall5: sum(recall_hits) / n, MRR: sum(mrr_scores) / n, Hit Rate5: sum(hit_rates) / n, num_cases: n } if __name__ __main__: metrics evaluate(eval/golden_set.json) print(json.dumps(metrics, indent2, ensure_asciiFalse))4.2 成功结果对照优化前后的指标对照指标优化前优化后提升Recall50.1750.6003.4xMRR0.5640.80643%Hit Rate50.6671.00050%平均延迟—826ms 1 秒跑完评测脚本你会看到类似这样的输出{ Recall5: 0.600, MRR: 0.806, Hit Rate5: 1.000, num_cases: 30 }4.3 用 TaoToken 跑通 LLM 生成环节检索链路验证通过后接上 LLM 生成。用 TaoToken 统一通道先验证 Key 是否可用import httpx async def verify_llm_channel(): async with httpx.AsyncClient() as client: resp await client.post( https://taotoken.net/api/chat/completions, headers{Authorization: fBearer {API_KEY}}, json{ model: Qwen2.5-7B-Instruct, messages: [ {role: user, content: 用一句话解释 RRF 融合的原理} ], max_tokens: 100 }, timeout30.0 ) print(resp.status_code) print(resp.json()[choices][0][message][content])如果返回 200 并且有正常的文本输出说明通道没问题。如果报 401检查 Key 是否正确如果报local proxy failed检查 Base URL 是否写成了https://taotoken.net/api不要加多余路径如果报reading choices相关错误检查响应解析逻辑是否匹配返回结构。完整链路跑通后一次搜索的流程是查询 → 同义词扩展 → 向量检索 BM25 → RRF 融合 → Rerank → 父子节点注入 → LLM 生成。整个过程在 1 秒内完成。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth5.1 401 Unauthorized最常见的报错。原因通常是 Key 没填对、Key 过期、或者请求头格式不对。检查三件事请求头是否是Authorization: Bearer 你的KeyKey 是否从https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite正确复制Base URL 是否是https://taotoken.net/api。如果你用的是 Claude Code配置里需要同时填 Base URL、API Key、Model ID 三件套。缺任何一个都会导致 401 或连接失败。Cline 的 MCP 配置同理在 settings 里把这三项填全。Codex 的 auth.json 里也要确保这三个字段都存在。5.2 local proxy failed这个报错通常出现在 Base URL 配置错误时。比如把地址写成了https://taotoken.net/api/v1或者多了斜杠。正确的 Base URL 是https://taotoken.net/api不要加额外路径。另外检查网络环境是否能正常访问该地址可以用 curl 先测一下curl -X POST https://taotoken.net/api/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:Qwen2.5-7B-Instruct,messages:[{role:user,content:test}]}如果 curl 能通但代码里报local proxy failed检查代码里是否误设了代理相关环境变量。5.3 reading choices 报错这个报错说明请求发出去了但响应解析失败。常见原因是返回结构和你代码里解析的字段不匹配。比如你按resp[choices][0][message][content]解析但实际返回可能是流式格式或者错误结构。先打印完整响应体看看resp await client.post(...) print(resp.status_code) print(resp.text) # 先看原始返回如果是流式请求需要按 SSE 格式逐行解析不能直接取choices。5.4 OAuth 相关报错如果你用的是 Claude Code 或类似工具可能会遇到 OAuth 认证失败。这类工具通常支持两种认证方式OAuth 和 API Key。用 TaoToken 通道时选择 API Key 方式在配置里填入 Base URL、Key、Model ID。如果工具强制走 OAuth检查是否有切换到 API Key 模式的选项。5.5 检索效果不达预期如果评测指标偏低按这个顺序排查先确认 Embedding 模型名配置正确我最大的提升就来自修复一行配置错误Recall5 从 0.175 跳到 0.461再确认 BM25 分词器是否加载了 jieba然后检查 RRF 权重是否合理最后看 Rerank 候选集是否太小建议 20 起步。三个失败实验的教训也值得记住多阶段检索在 Rerank 前注入子节点R5 反而降到 0.589给 Reranker 更丰富的上下文R5 暴跌到 0.549激进父子注入5 个子节点不打折会挤占原本相关结果的位置。每个组件都有最优工作点超过就会退化。6. 语义一致 CTA把检索链路和生成通道都跑起来整套系统跑下来检索侧的核心就是三件事BM25 和向量双通道召回保证覆盖率RRF 融合统一分数尺度Rerank 把对的文档提到前面。生成侧用 TaoToken 统一 Key/API 通道摘要生成、Rerank、最终生成走同一个入口配置管理简单很多。如果你正在排障或接入阶段先去https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite拿 Key然后对照https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite的接入文档把 Base URL、Key、Model ID 三件套配全。想先验证模型对话是否正常用https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite对应的对话入口测一条请求。如果你长期做编码或 Agent 任务Coding Plan 在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。最后分享一个实用技巧评测先行。没有评测数据所有优化都是盲猜。先花 1 天标注 30 条评测数据比花 1 周调参数更有效。失败实验的价值不亚于成功实验知道什么不能做和知道什么能做一样重要。
返回列表