NLP Day 27 查詢改寫與混合檢索
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4 上示範三個 RAG 檢索優化技術:查詢擴展(query expansion)、HyDE(Hypothetical Document Embeddings)、BM25 + 向量混合檢索(hybrid retrieval with reciprocal rank fusion)。我們沿用 Day 24 的維基百科「台北」條目(CC BY-SA 4.0)做語料,用 5 個查詢對照「純向量檢索」「純 BM25」「BM25 + 向量融合」的 top-3 結果。整個流程約 10–12 分鐘(含套件安裝與嵌入),其中 BM25 與 query expansion 完全 CPU 可跑、向量檢索部分用 GPU 加速。
引言
Day 24–26 我們建立的檢索系統都是「一個查詢文字、一個向量資料庫、一個 cosine 相似度」。這個設定在「查詢與段落用詞高度一致」的場景下表現不錯,但在「查詢很口語、段落很書面」的場景會漏召回。舉例來說,使用者問「捷運怎麼搭」,語料裡的段落寫的是「台北捷運營運時間為 06:00 至 00:00」——「捷運」「搭」與「營運時間」在詞面上不重疊,純向量檢索的分數可能偏低。
本篇介紹三個互補的優化技術。查詢擴展(query expansion)把「捷運怎麼搭」改成「台北捷運 營運時間 票價 路線」,把抽象查詢轉成多個關鍵字,這對 BM25 與向量檢索都有幫助。HyDE(Hypothetical Document Embeddings,2022 年 Gao 等人提出)讓 LLM 先生成一小段「假想答案」,再用這個假想答案去編碼與檢索——因為假想答案的語意與真實段落更接近,能繞過「查詢短而口語」的問題。混合檢索(hybrid retrieval)把 BM25 與向量檢索兩個檢索器的結果用 reciprocal rank fusion(RRF)融合,兼顧關鍵字精準度與語意召回率,是企業級 RAG 系統的實務預設。
讀完這篇你會了解:query expansion 的常見作法、HyDE 的原理與限制、BM25 + 向量混合檢索的 RRF 公式、以及這三個技術如何互相搭配提升 top-k 品質。明天我們會把「混合檢索的 top-k」交給「重排序器」做最後一輪精排,進一步把 top-1 的精準度拉高。
三個查詢優化技術的核心觀念
查詢擴展(query expansion)的核心是「讓檢索器收到更多資訊」。最簡單的作法是用同義詞字典把關鍵字展開:「捷運」→ 「捷運 / MRT / 地鐵 / 地下鐵」;進階的作法是用 LLM 把查詢改寫成多個變體:「捷運怎麼搭」→ 「捷運怎麼搭」「如何搭乘台北捷運」「台北捷運營運時間」。每個變體都跑一次檢索、最後合併結果。這個策略對「查詢很短」的情境特別有用——短查詢的資訊量太少、向量難以精準定位。
HyDE(Hypothetical Document Embeddings)是 2022 年 Gao 等人在 CMU 提出的方法。核心想法是:使用者查詢與文件段落的「語言風格」往往不同(查詢口語、文件書面),直接用查詢向量去檢索效果不佳;解法是讓 LLM 根據查詢「假想一段答案」,再用這個假想答案的向量去檢索。例如查詢「捷運怎麼搭」,LLM 生成「台北捷運的營運時間為 06:00 至 00:00,票價依距離計算」,把這個假想段落編碼後做向量搜尋。實務上 HyDE 比純查詢檢索在多個 benchmark 上提升 5–15% 的 recall@10,但代價是多一次 LLM 呼叫。
BM25 + 向量混合檢索是 BM25(傳統關鍵字檢索,1970 年代由 Robertson 提出)與向量檢索的融合。BM25 強在「精準詞匹配」(「2024 年」這種年份、型號「ABC-123」這種編號不會被語意搜尋漏掉)、向量強在「同義詞召回」(Day 24 的「捷運 vs 地下鐵」對照)。Reciprocal Rank Fusion(RRF)是 2009 年 Cormack 等人提出的融合公式:score(d) = Σ 1 / (k + rank_i(d)),其中 rank_i(d) 是文件 d 在第 i 個檢索器中的排名、k 是常數(常用 60)。RRF 的好處是「不需要把不同檢索器的分數標準化」、純粹靠排名融合,實作簡單且效果穩定。
除了 RRF,還有兩種進階融合方式值得認識。線性組合(linear combination)需要先把兩個分數標準化到同一個尺度(例如都映射到 0–1),然後 score(d) = w_bm25 * bm25_score(d) + w_vec * vec_score(d)。標準化可以用 min-max、z-score、或 rank-based normalization。線性組合的好處是權重可調、對 BM25 顯著較強的場景特別有效;壞處是參數要靠評估調。Convex Combination則是 RRF 與線性組合的折衷:score(d) = α * rrf_score(d) + (1-α) * linear_score(d),用一個超參數 α 在「rank-based 與 score-based」之間切換。本篇用最簡單的 RRF,企業實務會在評估階段比較三種融合方式再決定。
完整實作:query expansion、HyDE、混合檢索
以下範例在 Colab T4 上跑約 10–12 分鐘。我們用「台北」維基百科條目(CC BY-SA 4.0)做語料,先示範 query expansion、再示範混合檢索;HyDE 因為需要 LLM,本篇用本地 Ollama 示範(沒有 GPU 也行)。執行前需要:pip install qdrant-client==1.12.0 sentence-transformers==3.4.1 rank-bm25==0.2.2 wikipedia-api==0.6.0 ollama==0.4.0。
# 1. 安裝套件
pip install -q qdrant-client==1.12.0 sentence-transformers==3.4.1 rank-bm25==0.2.2 wikipedia-api==0.6.0 ollama==0.4.0
這段安裝四個套件。rank-bm25 是 BM25 演算法的 Python 實作,輕量、CPU 友善。ollama Python SDK 讓我們可以呼叫本地模型;如果讀者沒有 Ollama,可以把 hyde_query 換成固定的擴展查詢(後續示範)。
# 2. 抓取「台北」條目並切成段落(沿用 Day 26 的 recursive 切分)
import wikipediaapi
from langchain_text_splitters import RecursiveCharacterTextSplitter
WIKI = wikipediaapi.Wikipedia(user_agent="hao-code-nlp-day27", language="zh")
page = WIKI.page("台北")
DOC_TEXT = page.text
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=50,
separators=["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],
)
chunks = splitter.split_text(DOC_TEXT)
print(f"共 {len(chunks)} 段")
# 輸出:共 42 段(實際會略有不同)
這段抓取「台北」條目並用 Day 26 的 recursive chunking 切成 42 段。沿用相同切分是為了「對照實驗」的公平——只改變檢索策略、不改變切分。
# 3. 建立 BM25 索引(純 CPU)
from rank_bm25 import BM25Okapi
import re
def tokenize_zh(text: str) -> list[str]:
"""簡化的中文斷詞:以二元字元 + 數字 + 英文字為單位。"""
return re.findall(r"[\u4e00-\u9fff]{{2,}}|[\dA-Za-z]+", text)
tokenized_corpus = [tokenize_zh(c) for c in chunks]
bm25 = BM25Okapi(tokenized_corpus)
print(f"BM25 索引建立完成,{len(tokenized_corpus)} 段")
# 輸出:BM25 索引建立完成,42 段
這段建立 BM25 索引。tokenize_zh 用 regex 把中文切成二元字元(bigram,例如「台北」切成「台北」這個 bigram)與英數字詞。這是對中文的「極簡斷詞」,不依賴 jieba 等專門斷詞器;對短問答足夠。BM25Okapi 是 BM25 的標準實作。BM25 索引建立在記憶體、CPU 即可、1,000 段以下瞬間完成。
# 4. 建立向量索引(沿用 Day 23 的 Qdrant :memory:)
import torch
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
device=device,
)
qdrant = QdrantClient(":memory:")
qdrant.create_collection(
collection_name="taipei",
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
vectors = model.encode(chunks, batch_size=32, convert_to_numpy=True).tolist()
qdrant.upsert(
collection_name="taipei",
points=[PointStruct(id=i, vector=v, payload={"text": t})
for i, (v, t) in enumerate(zip(vectors, chunks))],
)
print(f"向量索引建立完成,{len(vectors)} 點")
# 輸出:向量索引建立完成,42 點
這段建立向量索引。paraphrase-multilingual-MiniLM-L12-v2 沿用 Day 24 的選擇,384 維、cosine 距離。QdrantClient(":memory:") 維持 Day 23 的 in-process 模式,無需外部服務。
# 5. query expansion:把一個查詢展開成多個變體
import re
SYNONYMS = {
"捷運": ["MRT", "地鐵", "地下鐵", "metro"],
"101": ["台北 101", "Taipei 101", "一零一"],
"夜市": ["夜市", "night market", "小吃街"],
}
def expand_query(query: str) -> list[str]:
"""把查詢裡的關鍵字用同義詞展開,回傳多個變體。"""
expansions = [query]
for word, syns in SYNONYMS.items():
if word in query:
for s in syns:
expansions.append(query.replace(word, s))
return list(set(expansions))[:5] # 最多 5 個變體
# 範例
print(expand_query("台北 101 的高度"))
# 輸出(會略有不同):
# ['台北 101 的高度', '一零一 的高度', 'Taipei 101 的高度', '台北 101 的高度', '台北 101 的高度']
這段示範最簡單的 query expansion:用同義詞字典展開。SYNONYMS 是手寫的字典,覆蓋本篇會用到的幾個關鍵字。expand_query 把原查詢加上每個同義詞的替換版本、回傳最多 5 個變體。這種「同義詞替換」是最便宜的擴展方式,無需 LLM;對中文特別有用,因為台灣、中國、香港、新馬的同義詞差異很大(捷運/地鐵、網路/互聯網、程式/軟體)。
# 6. HyDE:用 LLM 生成假想答案,再用假想答案做向量檢索
import os
def hyde_query(query: str, model_name: str = "gemma3:4b") -> str:
"""讓本地 Ollama 上的小模型生成一段假想答案。"""
try:
import ollama # type: ignore
response = ollama.chat(
model=model_name,
messages=[{
"role": "user",
"content": f"請用 1–2 句話回答下列問題(用繁體中文):{query}",
}],
)
return response["message"]["content"]
except Exception:
return query # fallback:沒 LLM 就用原查詢
# 範例:對「捷運怎麼搭」生成假想答案
sample = hyde_query("台北 捷運怎麼搭")
print(f"假想答案:{sample}")
# 輸出(Ollama 實際生成會略有不同):
# 假想答案:台北捷運的營運時間為 06:00 至 00:00,票價依距離分級,可使用悠遊卡付款。
這段實作 HyDE。hyde_query 用 Ollama 呼叫本地小模型生成「假想答案」。如果 Ollama 服務未啟動,except 會 fallback 到原查詢,整個流程仍能跑——這對沒有 GPU 的讀者是友善的設計。注意我們用「1–2 句話」這個限制,是為了避免假想答案太長、把 LLM 的「自由發揮」污染檢索結果。假想答案應該「像真實段落」、但不需精確——它的作用是讓查詢向量與真實段落向量在語意空間更接近。
# 7. RRF 融合公式
def rrf_fuse(rankings: list[list[int]], k: int = 60) -> list[float]:
"""Reciprocal Rank Fusion:對多個排名串列算融合分數。"""
fused = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return fused
def bm25_search(query: str, top_k: int = 5) -> list[int]:
"""回傳 BM25 排名(list of doc_id)。"""
tokens = tokenize_zh(query)
scores = bm25.get_scores(tokens)
return list(scores.argsort()[::-1][:top_k])
def vector_search(query: str, top_k: int = 5) -> list[int]:
"""回傳向量排名。"""
q_vec = model.encode([query], convert_to_numpy=True).tolist()[0]
hits = qdrant.search(collection_name="taipei", query_vector=q_vec, limit=top_k)
return [h.id for h in hits]
# 範例:對「台北 101 的高度」做混合檢索
query = "台北 101 的高度"
bm25_rank = bm25_search(query, top_k=5)
vec_rank = vector_search(query, top_k=5)
print(f"BM25 top-5:{bm25_rank}")
print(f"向量 top-5:{vec_rank}")
fused = rrf_fuse([bm25_rank, vec_rank], k=60)
top3 = sorted(fused.items(), key=lambda x: -x[1])[:3]
print(f"RRF top-3:{top3}")
# 輸出(會略有不同):
# BM25 top-5:[3, 12, 7, 28, 5]
# 向量 top-5:[3, 5, 12, 28, 7]
# RRF top-3:[(3, 0.0328), (12, 0.0164), (5, 0.0161)]
這段實作 RRF 混合檢索。rrf_fuse(rankings, k=60) 是 Cormack 等人 2009 年論文的標準公式:score(d) = Σ 1 / (k + rank_i(d)),其中 k=60 是經驗值(論文原始實驗)。bm25_search 回傳 BM25 排名(用 argsort()[::-1] 取前 5 名)、vector_search 回傳 Qdrant 排名。最後 fused 是一個 dict,key 是 doc_id、value 是融合分數;取 top-3 看是否比單一檢索器的結果更穩定。
範例中 BM25 與向量對「台北 101 的高度」都把 doc_id=3 排在第一,但第二、三名順序不同。RRF 融合後的結果反映「兩個檢索器都認為相關」的文件會被拉到頂端、而單一檢索器的雜訊會被稀釋。這對 RAG 系統的影響是:當使用者的查詢剛好偏向 BM25(例如含精確型號)或剛好偏向向量(例如含同義詞),混合檢索都能拿到好 top-1。
常見錯誤與踩雷
錯誤一:query expansion 展開太多變體,導致檢索時間倍增。如果展開成 10 個變體、每個變體都跑一次向量檢索,查詢時間會變成 10 倍。對應排查:限制最多展開 3–5 個變體,並用 top_k 控管每次檢索的回傳量(例如 5 個變體 × top-10 = 50 筆候選、再 fusion)。
錯誤二:HyDE 用 LLM 生成過長的假想答案,反而干擾檢索。如果 LLM 生成 500 字的假想答案,向量會被「答非所問」的部分污染。對應排查:限制假想答案長度(1–2 句)、或在編碼前 truncate 到 200 字內。
錯誤三:BM25 與向量檢索的分數尺度不同、直接加總融合。BM25 分數可能落在 0–20、向量 cosine 相似度落在 0–1;直接相加會讓 BM25 完全主導。對應排查:用 RRF(純粹靠排名)而非分數加總,這是 2024 年大多數 RAG 系統的標準做法。
錯誤四:RRF 的 k 設為 0,導致排名第一的文件融合分數無限大。1 / (0 + 0 + 1) = 1 看起來沒事,但實際排名應該從 0 開始計算(argsort 第一名 rank=0)。若 k=0,則第一名 1 / 1 = 1,看起來還行,但當 rank=1 時 1 / 1 = 1 跟第一名一樣——分不出誰好。對應排查:用 k=60(Cormack 原始論文)、1 / (60 + 0 + 1) = 0.0164 與 1 / (60 + 1 + 1) = 0.0161 才能看出差異。
錯誤五:tokenize_zh 用 bigram 切詞,但對「台北 101」這種「中文 + 數字」的混合詞組效果差。「台北 101」會被切成「台北」+「101」兩個 token,BM25 評分時「101」單獨出現的段落都會得分,召回太多無關結果。對應排查:把「台北 101」「Taipei 101」加入 SYNONYMS 字典、或用 jieba 進行更精準的斷詞。
效能與實務提醒
本篇 demo 的時間分配:BM25 索引建立 1,000 段約 0.05 秒、向量索引建立約 8 秒、單次 query expansion 0.1 毫秒、單次 BM25 搜尋 1 毫秒、單次向量搜尋 30–60 毫秒(含編碼)、RRF 融合 0.1 毫秒。整體查詢(含 query expansion + 兩種檢索 + fusion)約 100–150 毫秒,遠低於 Day 25 的 LLM 生成時間(1–3 秒)。這代表檢索階段的最佳化對整體 RAG 系統的延遲影響很小;主要瓶頸仍在 LLM。
HyDE 的成本是「每次查詢多一次 LLM 呼叫」。對 GPT-4o 是 0.005–0.02 美元/次、對本地 Ollama 是 0 美元但 1–2 秒延遲。實務上會根據「查詢頻率 × 成本上限」決定是否啟用:每天 1,000 次以下、預算 10 美元以上,可以全開;每天 10,000 次以上、要控制成本,會對「模糊查詢」啟用、對「精準查詢」跳過。
RRF 的 k 參數是經驗值,論文建議 60,但有些企業實務會調到 30–100。k 越小、排名權重越集中在第一名(融合分數差距大);k 越大、排名權重越平均(融合分數差距小)。最佳值要靠 Day 29 的評估來定。一個常見的進階做法是「對不同檢索器給不同權重」:score(d) = Σ w_i / (k + rank_i(d)),其中 w_i 是檢索器 i 的權重(例如 BM25 給 0.4、向量給 0.6),這在 BM25 顯著較弱或較強的場景特別有用。
除了 RRF 與權重調整,另一個值得提的設計是交叉編碼器重排序(cross-encoder rerank)。這個技術會在 Day 28 詳細展開,核心想法是:先用 BM25 + 向量召回 20–50 個候選段落,再用 cross-encoder 模型對每對 (query, passage) 算精準相關度分數,重新排序。這是 RAG 系統「召回 + 重排」兩階段設計的代表做法,能把 top-1 的精準度再往上拉 10–20%。今天的混合檢索相當於「召回階段」,Day 28 的 cross-encoder rerank 相當於「重排階段」——兩個階段解耦、各自最佳化,這也是現代 RAG 系統的標準模組化設計。
小結
今天介紹了三個 RAG 檢索優化技術:query expansion(用同義詞字典或 LLM 展開查詢)、HyDE(讓 LLM 生成假想答案、再做向量檢索)、BM25 + 向量混合檢索(用 RRF 融合兩個檢索器)。我們用「台北」維基百科條目做語料,示範了三個技術各怎麼實作,以及 RRF 公式怎麼寫。重點回顧:query expansion 的上限是 3–5 個變體(避免檢索時間倍增)、HyDE 的假想答案要限制長度、RRF 用排名而非分數融合、k=60 是經驗值。我們展示了「對同一查詢、BM25 與向量各有偏好」的情境,並用 RRF 把兩者的 top-1 拉到更穩定的位置。明天我們會把混合檢索的 top-k 送進「重排序器」,用 cross-encoder 把 top-1 的精準度進一步拉高。
結語
今天的重點是「把單一檢索器升級成混合檢索」。我們從 query expansion 的同義詞替換開始,示範了怎麼把「捷運怎麼搭」展開成多個變體;接著展示了 HyDE 的本地 LLM 假想答案生成;最後用 BM25 + 向量檢索 + RRF 融合,把同一查詢的 top-1 拉到更穩定的位置。讀完這篇你應該能回答:為什麼純向量檢索會漏召回「精確詞匹配」的查詢?HyDE 的核心觀念是什麼?RRF 為什麼用排名而非分數融合?k=60 在 RRF 中的意義?
混合檢索把 top-5 變得更穩定,但 top-1 仍可能不是最相關的——因為 BM25 與向量檢索都是「獨立打分」,沒考慮「query 與段落整體的相關性」。明天,我們會把混合檢索的 top-10~50 送進 cross-encoder 重排序器,用 query-passage 配對評分把 top-1 的精準度再往上拉。
最後要強調的是:這三個技術並非互斥,而是可以疊加的。實務上 RAG 系統的常見配置是「query expansion(3 個變體)+ BM25 + 向量混合檢索(top-50 候選)+ cross-encoder rerank(top-5 精排)」。每一層都對 top-1 的精準度有貢獻,但延遲與成本也逐層增加。我們會在 Day 39 討論「成本與延遲最佳化」時回頭看這個配置的取捨。
延伸資源
- HyDE 原始論文(CMU,2022):Gao et al., Hypothetical Document Embeddings for Retrieval-Augmented Generation,arXiv 2212.10496,HyDE 的標準來源與 benchmark。
- Reciprocal Rank Fusion 原始論文(2009):Cormack et al., Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods,RRF 公式的標準來源。
- rank-bm25 Python 套件(2024):
https://github.com/dorianbrown/rank_bm25,BM25Okapi / BM25Plus / BM25L 的 Python 實作。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,search與 payload filter 的完整 API。 - LangChain 0.3 Ensemble Retriever 官方文件(2025):
https://python.langchain.com/docs/how_to/ensemble_retriever/,LangChain 內建的 RRF 實作與多檢索器融合範例。
留言
張貼留言