<?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/%E7%9F%A5%E8%AF%86%E5%BA%93/</link><description>Recent content in 知识库 on 老朱的赛博工位</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Wed, 14 Oct 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.zjkun.top/tags/%E7%9F%A5%E8%AF%86%E5%BA%93/index.xml" rel="self" type="application/rss+xml"/><item><title>RAG 怕问得含糊？Query 改写四步直接抄</title><link>https://blog.zjkun.top/posts/rag-query-rewrite-4-steps/</link><pubDate>Wed, 14 Oct 2026 09:00:00 +0800</pubDate><guid>https://blog.zjkun.top/posts/rag-query-rewrite-4-steps/</guid><description>多轮对话里「那第二步呢」「申请要怎么做」直接进检索就是捞垃圾。我的 Nuxt3 知识库 Query 改写全链路：改写只用于检索、生成仍用原话，两道成本短路省 token，失败全回 null 降级。实测 BM25 命中从 0 条到 11 条。</description><content:encoded><![CDATA[<p>用户不会替你斟酌提问。多轮对话里「那第二步呢」「申请要怎么做?」这种句子，索引里没有任何指代对应物，原话直接进检索只能捞垃圾。</p>
<p>我给自己的企业知识库（Nuxt3 my-nuxt-app）加了 Query 改写环节，实测同一个问题改写后 BM25 命中从 0 条到 11 条。这篇讲链路和设计，代码真实在跑。</p>
<h2 id="链路四步">链路四步</h2>
<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>前端 RagChat 完整 messages 历史 + mode 一起 POST
</span></span><span style="display:flex;"><span>  → ask.post.ts 透传 answerWithRAGStream(query, mode, messages)
</span></span><span style="display:flex;"><span>  → 步骤0：取「最后一条 user 之前」的内容为 history，调 rewriteQuery
</span></span><span style="display:flex;"><span>  → 改写结果只用于检索（hybrid/bm25/vector 三模式全受益）
</span></span><span style="display:flex;"><span>  → 生成侧仍用用户原话
</span></span></code></pre></div><p>第四条是关键分域：<strong>改写给检索看，原话给生成看。</strong> 最终喂 LLM 的 user message 必须还是用户的原始表达——不然模型会去回答「你改出来的问题」，曲解用户意图。检索要语义明确，生成要忠于原话，两边目标本来就不一样。</p>
<h2 id="真实代码改写器长什么样">真实代码：改写器长什么样</h2>
<p>核心服务 <code>server/service/rag/query-rewrite.service.ts</code>，system prompt 直接贴原文（这就是「先分类再改写」的实现）：</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">system</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`你是一个 RAG 检索 query 改写器。根据对话历史，把用户最新问题
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">改写成一个独立、完整、语义明确的检索 query。
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">改写规则：
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">1. 指代消解：把&#34;它&#34;、&#34;这个&#34;、&#34;那个&#34;等指代词替换为历史对话中明确的实体
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">2. 省略补全：把省略主语/宾语的短句补全为完整表述
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">3. 模糊词具体化：把&#34;东西&#34;、&#34;那个&#34;等模糊词替换为具体指代
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">4. 反问句转正向意图：如&#34;难道不是 A 更好吗&#34; → &#34;A 相比其他方案的优势&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">5. 多意图聚焦：保留最核心的检索意图，用逗号分隔次要意图
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">6. 不要添加历史中不存在的信息，不要编造实体
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">先判断 query 类型（从以下选一个）：
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">- context_dependent / comparative / vague_reference
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">- multi_intent / rhetorical / standalone
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">只输出 JSON，不要解释，不要 markdown：
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">{&#34;type&#34;: &#34;类型&#34;, &#34;rewritten&#34;: &#34;改写后的完整query&#34;}`</span>
</span></span></code></pre></div><p>调用参数（为什么这么配）：</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">resp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">client</span>.<span style="color:#a6e22e">chat</span>.<span style="color:#a6e22e">completions</span>.<span style="color:#a6e22e">create</span>({
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">model</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;deepseek-chat&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">messages</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">temperature</span>: <span style="color:#66d9ef">0.1</span>, <span style="color:#75715e">// 改写要确定性，温度低一点
</span></span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">stream</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">response_format</span><span style="color:#f92672">:</span> { <span style="color:#66d9ef">type</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;json_object&#39;</span> }, <span style="color:#75715e">// 强制 JSON，防自由发挥
</span></span></span><span style="display:flex;"><span>})
</span></span></code></pre></div><p>历史截最近 10 条（<code>history.slice(-10)</code>）——再长 prompt 膨胀，收益不大。</p>
<h2 id="两道成本短路别白花-token">两道成本短路，别白花 token</h2>
<p>改写要多调一次 LLM，不能什么query都傻改。两道闸：</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:#75715e">// 闸一：首条消息（无历史）直接跳过
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">userMsgCount</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">history</span>.<span style="color:#a6e22e">filter</span>((<span style="color:#a6e22e">m</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">m</span>.<span style="color:#a6e22e">role</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;user&#39;</span>).<span style="color:#a6e22e">length</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">userMsgCount</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">null</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 闸二：启发式 shouldRewrite——不含指代信号且长度合理就不改
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">shouldRewrite</span>(<span style="color:#a6e22e">query</span>: <span style="color:#66d9ef">string</span>)<span style="color:#f92672">:</span> <span style="color:#66d9ef">boolean</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">query</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">5</span>) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>          <span style="color:#75715e">// ≤5字几乎一定是省略/指代
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">REFERENCE_PATTERNS</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">query</span>)) <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span> <span style="color:#75715e">// 命中「它/这个/怎么做/什么功能…」
</span></span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>                                 <span style="color:#75715e">// 表达清晰，省一次调用
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="失败路径全回-null">失败路径全回 null</h2>
<p>空返回、JSON 解析失败、缺 <code>rewritten</code> 字段、改写结果和原话一样、调用异常——五种失败全部 <code>return null</code>，上层 fallback 用原 query。<strong>链路永不因为改写挂掉</strong>：改写是增益环节，不是必经环节。</p>
<p>每步都有 🔧 Query改写调试日志（原 query/历史/两个 prompt/耗时/token），排查时控制台一目了然。</p>
<h2 id="实测">实测</h2>
<p>「申请要怎么做?有没有步骤」+ 2 条婚假相关历史：</p>
<ul>
<li>改写输出：<code>{&quot;type&quot;: &quot;context_dependent&quot;, &quot;rewritten&quot;: &quot;婚假申请的具体步骤和流程是什么&quot;}</code></li>
<li>耗时 <strong>709ms</strong>，<strong>565 token</strong></li>
<li>该 query 进 BM25：命中 <strong>11 条</strong>（索引状态 docs=38, vocab=1936）；原话直查基本捞不到东西</li>
</ul>
<p>一次 565 token 的改写调用（deepseek-chat 计价下约几厘钱），换检索质量从不可用到可用，这笔账怎么算都划算——前提是有上面那两道短路，不该调的时候别调。</p>
<h2 id="速查表">速查表</h2>
<table>
	<thead>
			<tr>
					<th>项</th>
					<th>值</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>位置</td>
					<td>检索第一站，排在混合检索之前</td>
			</tr>
			<tr>
					<td>输出</td>
					<td>JSON <code>{type, rewritten}</code>，temp 0.1，json_object</td>
			</tr>
			<tr>
					<td>类型</td>
					<td>context_dependent/comparative/vague_reference/multi_intent/rhetorical/standalone</td>
			</tr>
			<tr>
					<td>短路</td>
					<td>无历史跳过；≤5 字必改；命中指代词才改</td>
			</tr>
			<tr>
					<td>兜底</td>
					<td>任何异常回原 query</td>
			</tr>
			<tr>
					<td>分域</td>
					<td>改写给检索，原话给生成</td>
			</tr>
			<tr>
					<td>自测</td>
					<td>对比改写前后命中条数（我从 0 到 11 条）</td>
			</tr>
	</tbody>
</table>
<p>已知边界：目前只处理纯文本指代；<code>retrieveOnly</code> 调试接口不经过改写（只看原始检索）。</p>
<p>下一篇把 6 条规则和 6 种类型配成对照表——一类问题开一剂药。</p>
<blockquote>
<p>本篇最早于 2026 年 10 月 14 日发布于小红书，此为含完整代码与数据的博客版。</p>
</blockquote>
]]></content:encoded></item><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>