<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>重排序 on 老朱的赛博工位</title><link>https://blog.zjkun.top/tags/%E9%87%8D%E6%8E%92%E5%BA%8F/</link><description>Recent content in 重排序 on 老朱的赛博工位</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Tue, 13 Oct 2026 18:53:00 +0800</lastBuildDate><atom:link href="https://blog.zjkun.top/tags/%E9%87%8D%E6%8E%92%E5%BA%8F/index.xml" rel="self" type="application/rss+xml"/><item><title>RAG 检索总跑偏？混合检索四步直接抄</title><link>https://blog.zjkun.top/posts/rag-hybrid-search-4-steps/</link><pubDate>Tue, 13 Oct 2026 18:53:00 +0800</pubDate><guid>https://blog.zjkun.top/posts/rag-hybrid-search-4-steps/</guid><description>RAG 效果差，九成不是模型的锅，是检索没捞对料。拆解我 Nuxt3 企业知识库 my-nuxt-app 的混合检索全链路：BM25 手写实现（k1=1.5、b=0.75）、RRF 融合（k=60）、精排 top5、三模式对照调试，参数和代码全给。</description><content:encoded><![CDATA[<p>RAG 效果差，九成不是大模型的锅，是检索没捞对料。模型只负责说好话，检索决定它能看到什么。</p>
<p>这篇拆我自己项目（Nuxt3 企业知识库，Nitro + TiDB + Chroma）里跑着的混合检索：BM25 怎么打分、RRF 的 k 为什么是 60、有向量召回了为什么还要精排。代码全部真实在跑，参数直接抄。</p>
<h2 id="为什么要两路">为什么要两路</h2>
<p>向量检索单干有毛病：换个说法问很准，但专有名词、条款号、缩写型号就拉胯——搜型号给你一堆「看着很像」的，偏偏不是原文。关键词检索反过来：字面命中稳，近义表达没辙。</p>
<p>所以两路都要：<strong>向量路（Chroma bge-m3，强在近义表达）+ 关键词路（手写 BM25，强在专有名词/术语）</strong>，各取所长。</p>
<h2 id="第-1-步并行粗召">第 1 步：并行粗召</h2>
<p>两路并行，各捞 20 条（<code>COARSE_TOP_K = 20</code>），用 <code>Promise.allSettled</code>——一路挂了另一路照常出，向量服务抖动不拖垮整个问答。向量路带 <code>status=published</code> 过滤，关键词路召回后回查 TiDB <code>document_chunks</code> 补全内容（<code>listChunksByIds</code> + Map 保序）。</p>
<h2 id="第-2-步bm25-这一路的两个旋钮">第 2 步：BM25 这一路的两个旋钮</h2>
<p>BM25 公式（对查询 q 中每个词项 qi 累加对文档 d 的得分）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>score(q, d) = Σ IDF(qi) · tf(qi,d)·(k1+1) / (tf(qi,d) + k1·(1 - b + b·|d|/avgdl))
</span></span><span style="display:flex;"><span>IDF(qi)     = ln((N - df(qi) + 0.5) / (df(qi) + 0.5) + 1)
</span></span></code></pre></div><ul>
<li><strong>k1=1.5 管词频饱和</strong>：一个词在文档里出现 10 次，不代表比出现 1 次相关 10 倍，得分要收敛</li>
<li><strong>b=0.75 管长文惩罚</strong>：长文档天然词多，不打折就被大段废话霸榜</li>
</ul>
<p>我的真实打分代码（<code>server/utils/bm25.ts</code>，零依赖手写，倒排索引 <code>token → (chunkId, tf)</code>）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">df</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">posting</span>.<span style="color:#a6e22e">size</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// IDF：用 +1 平滑避免负值；罕见词区分度高、IDF 大
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">idf</span> <span style="color:#f92672">=</span> Math.<span style="color:#a6e22e">log</span>((<span style="color:#a6e22e">N</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">df</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span>) <span style="color:#f92672">/</span> (<span style="color:#a6e22e">df</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">0.5</span>) <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 词频饱和项：tf 越大得分越高，但受 k1 限制避免高频词刷分
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// 文档长度归一化：长文档天然 tf 高，用 b 参数抑制
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">norm</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">-</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">b</span> <span style="color:#f92672">+</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">b</span> <span style="color:#f92672">*</span> (<span style="color:#a6e22e">doc</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">/</span> (<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">avgdl</span> <span style="color:#f92672">||</span> <span style="color:#ae81ff">1</span>))
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">tfComponent</span> <span style="color:#f92672">=</span> (<span style="color:#a6e22e">tf</span> <span style="color:#f92672">*</span> (<span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">k1</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>)) <span style="color:#f92672">/</span> (<span style="color:#a6e22e">tf</span> <span style="color:#f92672">+</span> <span style="color:#66d9ef">this</span>.<span style="color:#a6e22e">k1</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">norm</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">score</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">idf</span> <span style="color:#f92672">*</span> <span style="color:#a6e22e">tfComponent</span>
</span></span></code></pre></div><p><strong>为什么手写不上 ES</strong>：我这量级（全库 172 个 chunk）引 Elasticsearch 纯属找罪受。启动插件从 TiDB 全量捞 published 分块，分词建倒排一次预热，零冷启动；失败也不阻塞启动，BM25 降级空结果，向量路仍可用。avgdl 用增量公式维护（增删文档各一行数学），不为一个平均值遍历全库。</p>
<h2 id="第-3-步rrf-融合扔掉分数只排名次">第 3 步：RRF 融合，扔掉分数只排名次</h2>
<p>余弦分卡在 0~1，BM25 分没有上限（能到 30+），直接加权就是关公战秦琼。RRF（Reciprocal Rank Fusion）只看名次：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>score(d) = Σ 1/(k + rank_i(d))    // k=60，某路未命中则不参与累加
</span></span></code></pre></div><p>直觉算例（k=60）：</p>
<ul>
<li>两路都排第 1：1/61 + 1/61 ≈ <strong>0.0328</strong></li>
<li>只有一路排第 1：1/61 ≈ <strong>0.0164</strong></li>
<li>一路第 1、另一路第 20：1/61 + 1/80 ≈ <strong>0.0289</strong></li>
</ul>
<p>→ <strong>两路都认的才浮上来。</strong> k 是平滑常数：k 越小头部名次优势越大，k=60 是论文经验值，把第 1 和第 2 名的差距抹平，不让某一路一票定生死；候选池小的话 k 可调低。真实实现（<code>server/utils/rrf.ts</code>）：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">rrf</span>(<span style="color:#a6e22e">rankedLists</span>: <span style="color:#66d9ef">string</span>[][], <span style="color:#a6e22e">k</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">60</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">FusedResult</span>[] {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">scores</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>&lt;<span style="color:#f92672">string</span>, <span style="color:#a6e22e">number</span>&gt;()
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ranksMap</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>&lt;<span style="color:#f92672">string</span>, <span style="color:#a6e22e">Array</span>&lt;<span style="color:#f92672">number</span> <span style="color:#960050;background-color:#1e0010">|</span> <span style="color:#a6e22e">null</span>&gt;&gt;()
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">rankedLists</span>.<span style="color:#a6e22e">forEach</span>((<span style="color:#a6e22e">list</span>, <span style="color:#a6e22e">listIndex</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">list</span>.<span style="color:#a6e22e">forEach</span>((<span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">idx</span>) <span style="color:#f92672">=&gt;</span> {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rank</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">idx</span> <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span> <span style="color:#75715e">// 名次从 1 开始
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">contribution</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span> <span style="color:#f92672">/</span> (<span style="color:#a6e22e">k</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">rank</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">scores</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">id</span>, (<span style="color:#a6e22e">scores</span>.<span style="color:#66d9ef">get</span>(<span style="color:#a6e22e">id</span>) <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">+</span> <span style="color:#a6e22e">contribution</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#75715e">// ranks 记录每路名次，未出现记 null，便于调试观察
</span></span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">ranks</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">ranksMap</span>.<span style="color:#66d9ef">get</span>(<span style="color:#a6e22e">id</span>)
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">ranks</span>) { <span style="color:#a6e22e">ranks</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> Array(<span style="color:#a6e22e">rankedLists</span>.<span style="color:#a6e22e">length</span>).<span style="color:#a6e22e">fill</span>(<span style="color:#66d9ef">null</span>); <span style="color:#a6e22e">ranksMap</span>.<span style="color:#66d9ef">set</span>(<span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">ranks</span>) }
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">ranks</span>[<span style="color:#a6e22e">listIndex</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">rank</span>
</span></span><span style="display:flex;"><span>    })
</span></span><span style="display:flex;"><span>  })
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// ...按融合分降序输出
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>融合取 top-20（<code>FUSE_TOP_K = 20</code>）；一路为空自动退化单路。</p>
<h2 id="第-4-步精排最不能省的一步">第 4 步：精排，最不能省的一步</h2>
<p>入库时分块是<strong>单独</strong>算向量的，跟你的问题从没同框比过——粗召本质是盲选。reranker（cross-encoder，硅基流动 bge-reranker-v2-m3）把「问题+每条候选」拼成一对喂进模型逐条打分，慢，但准。</p>
<p>只对融合后的 20 条精排（成本可控），取前 5 进上下文（<code>FINAL_TOP_K = 5</code>）。rerank 服务不可用就自动降级按 RRF 分排——链路永不整条挂掉。</p>
<p>然后生成：DeepSeek deepseek-chat SSE 流式，<strong>先推 sources（来源文档+页码）再推 delta</strong>，前端引用卡片比答案先渲染。</p>
<h2 id="调试方法三模式对照">调试方法：三模式对照</h2>
<p>这是整条链路我最推荐抄的东西——检索模式做成 <code>hybrid / bm25 / vector</code> 三档开关，<strong>召回策略是唯一变量</strong>，后面统一走精排+生成。同一个问题三档各跑一遍，控制台按模式打印两路名次/得分，谁拖后腿一眼看见。另有 <code>/api/rag/keyword-search</code>、<code>/api/rag/vector-search</code> 单路调试接口和 <code>retrieveOnly</code>（只看检索不生成）。</p>
<h2 id="速查表">速查表</h2>
<table>
	<thead>
			<tr>
					<th>项</th>
					<th>值</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>流程</td>
					<td>两路并行各召 20 → RRF 融合取 20 → 精排取 5 → 生成</td>
			</tr>
			<tr>
					<td>BM25</td>
					<td>k1=1.5，b=0.75，IDF=ln((N-df+0.5)/(df+0.5)+1)</td>
			</tr>
			<tr>
					<td>RRF</td>
					<td>score=Σ1/(60+rank)，只看名次不看分</td>
			</tr>
			<tr>
					<td>降级</td>
					<td>单路挂→另一路照常；rerank 挂→按 RRF 分</td>
			</tr>
			<tr>
					<td>自测</td>
					<td>同一问题跑纯向量/纯BM25/混合三遍</td>
			</tr>
	</tbody>
</table>
<p>RAG 的上限在检索，不在大模型。下一篇讲检索的第一站：Query 改写。</p>
<blockquote>
<p>本篇最早于 2026 年 10 月 13 日发布于小红书，此为含完整代码与数据的博客版。</p>
</blockquote>
]]></content:encoded></item></channel></rss>