此系列因故中斷,請移駕 RE:從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰 繼續支持。
昨天說明文件的索引,今天處理:怎麼把對的東西找出來。
今天說明的混合檢索、重排序、查詢改寫等3個技術是實務上 CP 值最高的三個優化,而且都不需要換模型。
在您常識建立 RAG 系統之時,很有可能也有以下經驗:
失敗一:精確關鍵字漏掉。 使用者問「A-2350 這個型號的保固多久」,向量檢索取回了一堆「保固政策」的通則段落,就是沒有 A-2350 那一頁。因為在語意空間裡,「A-2350」和「B-1180」長得幾乎一樣。
失敗二:對的文件排在第 8 名。 你設 top_k=5,正確答案排第 8,就這樣被切掉了。
失敗三:代名詞查詢。 對話進行到第三輪,使用者問「那它的保固呢?」 檢索系統拿到的字面就是「那它的保固呢」,它完全不知道「它」是什麼。
📊 職缺訊號
這是 RAG(48.1%)任務中最需要工程判斷的部分。面試裡常見的問法是:「您的 RAG 答不出某個問題,該怎麼查?」——能講出「先確認是檢索問題還是生成問題」,再往下分層排查的人,就贏過八成的候選人(明天會給完整的排查流程)。
先把今天要組裝的檢索管線畫出來,四個步驟各自解決一種失敗:

圖 19-1:檢索管線的四個步驟與各自解決的失敗(示意流程)。注意最後那個分支——能回傳空結果,是拒答功能的前提。
| 方式 | 原理 | 強項 | 盲區 |
|---|---|---|---|
| 向量檢索(dense) | 語意相似度 | 同義詞、換句話說、跨語言 | 精確詞(型號、法條編號、人名) |
| 關鍵字檢索(BM25/sparse) | 詞頻與逆文件頻率 | 精確詞、罕見詞 | 同義詞、語意相近但用詞不同 |
| 混合檢索(hybrid) | 兩者結果融合 | 兼顧兩者 | 需要多維護一套索引、多一次查詢 |
兩者的盲區恰好互補,這就是混合檢索的理由。
這張圖是今天的核心,它回答「該做到哪一層」:

圖 19-2:RAG 檢索品質與延遲的取捨(示範性評測數據,用於說明趨勢)。左圖是四種檢索策略的 Recall@k,右圖是逐層疊加優化的品質與延遲變化。
左圖的關鍵讀法:混合檢索在 top-5 就達到純向量 top-10 的水準;加上重排序後,top-1 的命中率從 0.52 跳到 0.72。
top-1 命中率為什麼重要?因為 Day 16 的 Lost in the Middle 告訴我們,放在最前面的內容被用得最好。讓最相關的那一筆真的排第一,比多塞五筆進去有效得多。
右圖的關鍵讀法:忠實度從 0.71 推到 0.91,但延遲也從 180 ms 漲到 640 ms。這不是「全部都要加」的清單,是一份取捨表,多數企業 RAG 的最佳落點是「混合檢索 + 重排序」(品質 0.88、延遲 620 ms)。
"""hybrid.py —— 用 Reciprocal Rank Fusion 融合兩種檢索結果。"""
from rank_bm25 import BM25Okapi
import jieba # 中文斷詞:BM25 需要先斷詞才有意義
# 建 BM25 索引(與向量索引並存)
tokenized = [list(jieba.cut(c["text"])) for c in chunks]
bm25 = BM25Okapi(tokenized)
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60):
"""RRF:只看名次不看分數,因此不需要處理兩套分數的尺度差異。
這是它比「加權平均分數」更受歡迎的原因——不用調權重。
"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores, key=scores.get, reverse=True)
def hybrid_search(query: str, top_k: int = 20):
# 1) 向量檢索
vec_hits = collection.query(
query_embeddings=embed([query], is_query=True).tolist(),
n_results=top_k)["ids"][0]
# 2) 關鍵字檢索
bm25_scores = bm25.get_scores(list(jieba.cut(query)))
bm25_hits = [chunk_ids[i] for i in bm25_scores.argsort()[::-1][:top_k]]
# 3) 融合
return reciprocal_rank_fusion([vec_hits, bm25_hits])
💡 中文的特別提醒
BM25 依賴斷詞,而中文斷詞品質直接影響效果。專有名詞、產品型號、內部術語要加進自訂詞典(
jieba.load_userdict()),否則「A-2350」可能被切成「A」「2350」,效果大打折扣。這是中文 RAG 特有的工作,英文教學不會提。
"""rerank.py —— 交叉編碼器重排序。粗召回多、精排少。"""
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 支援中文
def retrieve_and_rerank(query: str, candidates_k: int = 20, final_k: int = 4):
candidate_ids = hybrid_search(query, top_k=candidates_k)
docs = fetch_documents(candidate_ids)
# 交叉編碼器同時看 query 與 doc,比向量相似度準得多,但也慢得多
scores = reranker.predict([(query, d["text"]) for d in docs])
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
# 除了取前 k 筆,也用絕對分數過濾——沒有夠相關的就該回報「找不到」
return [d for d, s in ranked[:final_k] if s > 0.3]
最後那個 if s > 0.3 很關鍵。 沒有它,即使知識庫裡完全沒有相關內容,檢索仍會回傳「最不差的四筆」,然後模型就會基於不相關的脈絡開始編。能夠回傳空結果的檢索器,是拒答功能的前提(明天詳談)。
"""rewrite.py —— 把對話脈絡補進查詢,讓它能獨立被檢索。"""
REWRITE_PROMPT = """根據對話歷史,把使用者最新的問題改寫成一個「不需要看歷史也能理解」的完整問句。
只輸出改寫後的問句,不要有其他文字。若問題本身已經完整,原樣輸出。
對話歷史:
{history}
使用者最新問題:{question}
改寫後:"""
async def rewrite_query(history, question):
# 用便宜的小模型即可,這是高頻但簡單的任務
resp = await client.chat.completions.create(
model=SMALL_MODEL,
messages=[{"role": "user", "content": REWRITE_PROMPT.format(
history=format_history(history[-4:]), question=question)}],
max_tokens=100, temperature=0,
)
return resp.choices[0].message.content.strip()
# 效果:
# 原始查詢:「那它的保固呢?」 → 檢索必然失敗
# 改寫後:「A-2350 智慧門鎖的保固期是多久?」 → 檢索成功
另外兩個值得知道的改寫技巧:
| 你的情況 | 建議配置 | 預期延遲 |
|---|---|---|
| 原型驗證階段 | 純向量 top-5 | ~180 ms |
| 文件含大量專有名詞/型號/法條 | +混合檢索 | ~360 ms |
| 使用者抱怨「答非所問」 | +重排序 | ~620 ms |
| 多輪對話應用 | +查詢改寫(幾乎必加) | +200–300 ms |
| 延遲要求極嚴(< 300 ms) | 混合檢索,捨棄重排序,改用更好的切塊 | ~360 ms |
三個常見的誤區:
某企業知識助理上線後,答對率只有六成。團隊的優化順序與各自的效果:
| 順序 | 動作 | 答對率變化 | 花費時間 |
|---|---|---|---|
| 1 | 建立 30 題評測集(先量測) | 0.61(基線) | 半天 |
| 2 | 加入查詢改寫 | 0.61 → 0.68 | 2 小時 |
| 3 | 改用結構感知切塊、重建索引 | 0.68 → 0.79 | 1 天 |
| 4 | 加入混合檢索(型號查詢改善明顯) | 0.79 → 0.85 | 半天 |
| 5 | 加入重排序 | 0.85 → 0.88 | 2 小時 |
注意第 1 步與第 3 步。 第 1 步不改善任何東西,卻是後面每一步能被驗證的前提;第 3 步是效果最大的一項,而它屬於昨天的索引端——這再次說明「八成的 RAG 失敗在提問之前就決定了」。
也請注意這裡沒有出現「換更強的生成模型」。因為在檢索還沒做好之前,換模型的邊際效益極低。
📌 一個排查的順序
RAG 答不好時,永遠先看檢索結果,不要先改提示。具體做法:把該問題的檢索結果原文印出來,問自己一句話——「正確答案在不在這幾段裡?」
- 不在 → 檢索問題(今天的內容)
- 在,但模型沒用好 → 生成問題(明天的內容)
這個二分法能省下你大量的瞎猜時間。
RAG 三部曲的最後一天:文件找到了,怎麼讓模型只根據它們回答、在沒有依據時老實說不知道、以及最重要的是如何量化你的 RAG 到底有多好。
明天也會提供 RAG 排查決策樹,可以直接存起來當工作手冊參考。