跳至主要内容

Day 19:RAG(二)檢索端 — 混合檢索、重排序與查詢改寫

· 閱讀時間約 7 分鐘
Willis Chen
Tech Instructor

昨天說明文件的索引,今天處理:怎麼把對的東西找出來。

今天說明的混合檢索、重排序、查詢改寫等3個技術是實務上 CP 值最高的三個優化,而且都不需要換模型。

今天要解決的問題​

在您常識建立 RAG 系統之時,很有可能也有以下經驗:

失敗一:精確關鍵字漏掉。 使用者問「A-2350 這個型號的保固多久」,向量檢索取回了一堆「保固政策」的通則段落,就是沒有 A-2350 那一頁。因為在語意空間裡,「A-2350」和「B-1180」長得幾乎一樣。

失敗二:對的文件排在第 8 名。 你設 top_k=5,正確答案排第 8,就這樣被切掉了。

失敗三:代名詞查詢。 對話進行到第三輪,使用者問「那它的保固呢?」 檢索系統拿到的字面就是「那它的保固呢」,它完全不知道「它」是什麼。

📊 職缺訊號

這是 RAG(48.1%)任務中最需要工程判斷的部分。面試裡常見的問法是:「您的 RAG 答不出某個問題,該怎麼查?」——能講出「先確認是檢索問題還是生成問題」,再往下分層排查的人,就贏過八成的候選人(明天會給完整的排查流程)。


一、現象:三種檢索方式各有盲區​

先把今天要組裝的檢索管線畫出來,四個步驟各自解決一種失敗:

image

圖 19-1:檢索管線的四個步驟與各自解決的失敗(示意流程)。注意最後那個分支——能回傳空結果,是拒答功能的前提。

方式原理強項盲區
向量檢索(dense)語意相似度同義詞、換句話說、跨語言精確詞(型號、法條編號、人名)
關鍵字檢索(BM25/sparse)詞頻與逆文件頻率精確詞、罕見詞同義詞、語意相近但用詞不同
混合檢索(hybrid)兩者結果融合兼顧兩者需要多維護一套索引、多一次查詢

兩者的盲區恰好互補,這就是混合檢索的理由。

二、原理:每加一層,值多少​

這張圖是今天的核心,它回答「該做到哪一層」:

RAG 檢索品質與延遲的取捨

圖 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)。

三、動手:三個優化的實作​

3.1 混合檢索:RRF 融合​

"""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 特有的工作,英文教學不會提。

3.2 重排序:用貴的模型排少量候選​

"""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 很關鍵。 沒有它,即使知識庫裡完全沒有相關內容,檢索仍會回傳「最不差的四筆」,然後模型就會基於不相關的脈絡開始編。能夠回傳空結果的檢索器,是拒答功能的前提(明天詳談)。

3.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 智慧門鎖的保固期是多久?」 → 檢索成功

另外兩個值得知道的改寫技巧:

  • Multi-query:讓模型把一個問題拆成 2–3 個不同角度的子查詢,分別檢索後合併。適合複合問題(「A 和 B 的差別,以及哪個比較適合小型企業」)。
  • HyDE(Hypothetical Document Embeddings):先讓模型「假裝回答」,再用這個假答案去檢索。原理是假答案在語意空間裡比問題更接近真答案。適合問題與文件用語落差大的場景。

四、取捨:加到哪一層就好​

你的情況建議配置預期延遲
原型驗證階段純向量 top-5~180 ms
文件含大量專有名詞/型號/法條+混合檢索~360 ms
使用者抱怨「答非所問」+重排序~620 ms
多輪對話應用+查詢改寫(幾乎必加)+200–300 ms
延遲要求極嚴(< 300 ms)混合檢索,捨棄重排序,改用更好的切塊~360 ms

三個常見的誤區:

  1. 無腦調大 top_k。 從 5 調到 20,看似取回更多,實際上是把更多雜訊塞進脈絡——Lost in the Middle 會讓效果不升反降,成本還上升。先做重排序,再考慮調 k。
  2. 以為換更強的生成模型能救檢索。 檢索取回錯的東西,再強的模型也只能基於錯的東西回答。這是本系列反覆強調的分層思維。
  3. 忘記量測。 上面每一個優化都該有前後對照的數字。沒有評測集,你只是在憑感覺調參——明天就處理這件事。

4.1 一個真實的優化順序範例​

某企業知識助理上線後,答對率只有六成。團隊的優化順序與各自的效果:

順序動作答對率變化花費時間
1建立 30 題評測集(先量測)0.61(基線)半天
2加入查詢改寫0.61 → 0.682 小時
3改用結構感知切塊、重建索引0.68 → 0.791 天
4加入混合檢索(型號查詢改善明顯)0.79 → 0.85半天
5加入重排序0.85 → 0.882 小時

注意第 1 步與第 3 步。 第 1 步不改善任何東西,卻是後面每一步能被驗證的前提;第 3 步是效果最大的一項,而它屬於昨天的索引端——這再次說明「八成的 RAG 失敗在提問之前就決定了」。

也請注意這裡沒有出現「換更強的生成模型」。因為在檢索還沒做好之前,換模型的邊際效益極低。

📌 一個排查的順序

RAG 答不好時,永遠先看檢索結果,不要先改提示。具體做法:把該問題的檢索結果原文印出來,問自己一句話——「正確答案在不在這幾段裡?」

  • 不在 → 檢索問題(今天的內容)
  • 在,但模型沒用好 → 生成問題(明天的內容)

這個二分法能省下你大量的瞎猜時間。


今日小結​

  • 三種檢索方式盲區互補:向量檢索漏精確詞、關鍵字檢索漏同義詞,混合檢索(RRF 融合)兩者兼顧。
  • 重排序讓 top-1 命中率從 0.52 跳到 0.72——因為 Lost in the Middle,讓最相關的排第一,比多塞五筆有效。
  • 重排序要設絕對分數門檻,能回傳空結果的檢索器,才有辦法做拒答。
  • 多輪對話幾乎必做查詢改寫,否則代名詞查詢必然失敗。
  • 中文 RAG 的特有工作:BM25 的斷詞與自訂詞典(型號、內部術語要加進去)。
  • 三個誤區:無腦調大 top_k、以為換模型能救檢索、忘記量測。
  • 排查順序:先看檢索結果,問「正確答案在不在這幾段裡」——這個二分法能省下大量瞎猜。

明天預告​

RAG 三部曲的最後一天:文件找到了,怎麼讓模型只根據它們回答、在沒有依據時老實說不知道、以及最重要的是如何量化你的 RAG 到底有多好。

明天也會提供 RAG 排查決策樹,可以直接存起來當工作手冊參考。

延伸閱讀​


← Day 18 | Day 20 →

本文同步發布於 iThome 鐵人賽;Blog 版本為站內閱讀與系列導覽的主要版本。