NLP Day 24 語意檢索實戰:從相似句到文件搜尋
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4(16 GB VRAM)上示範「從真實中文維基百科條目做語意搜尋」:抓取 5 個精選條目、切段成 200–300 個段落、用 sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2(約 470 MB,多語言模型)編碼,全部流程約 5–8 分鐘;CPU 也能跑、但編碼時間會拉長到 25–35 分鐘,搜尋階段毫秒級回應不影響。向量資料庫沿用昨天的 Qdrant 1.12 in-process 模式,不需另外啟動服務。
引言
Day 22 我們用 sentence-transformers 把文字轉成 384 維向量,Day 23 進一步把它們存進 Chroma、Qdrant、pgvector 三種向量資料庫。昨天的範例用的是合成的 5 筆句子,規模很小。今天我們要把規模拉到「真實的跨主題文件」:抓取維基百科的精選條目(CC BY-SA 4.0 授權)、切成段落、編碼、寫進 Qdrant,再用「自然語言問句」做查詢,看看能不能在多主題的語料中精準找到最相關的段落。
這是「檢索增強生成(RAG)」的第一步:先把檢索做好、把段落找回來,之後才交給 LLM 改寫成自然語言答案。今天的範例刻意停留在「檢索」這一層(不做 LLM 生成),因為檢索品質決定了 RAG 上限——如果連相關段落都找不對,再貴的 LLM 也救不回來。讀完這篇你會了解:怎麼從維基百科抓資料、怎麼用 metadata(主題、條目來源)輔助檢索、怎麼用 paraphrase-multilingual-MiniLM-L12-v2 處理中文語意、以及如何用幾個查詢變化看出不同 query 寫法對檢索品質的影響。
明天我們會把今天的檢索結果接上一個本地 LLM(Ollama 上的小模型或 Colab 上的開源模型),示範完整的 RAG 流程:query → retrieve → prompt → generate。今天專注檢索,明天補上生成。
語意檢索的關鍵設計
從「5 筆句子」走到「200 個段落」,有三個設計選擇要先決定。第一是段落切分粒度:太粗(例如整個條目當一段)會讓「台北 101 在哪裡」這種精確查詢找不到對應段落;太細(每句切一段)則會讓語意不完整、相似度失真。本篇採用「以維基百科的段落分隔(雙換行)為單位」、每段至少 80 個字、每段最多 500 個字,這是經驗上對中文條目最穩定的設定。
第二是嵌入模型選擇。all-MiniLM-L6-v2 是 Day 22 用的英文優化模型,雖然對中文也有基本能力,但表現不如專門的多語言模型。paraphrase-multilingual-MiniLM-L12-v2(sentence-transformers 官方)支援 50 多種語言、含繁體與簡體中文,輸出維度 384、在 STS 中文 benchmark 上 Spearman 相關係數約 0.83,比 all-MiniLM-L6-v2 的 0.72 高一截。今天用這個模型。
第三是metadata 設計。每個段落除了原文與向量,還要存「它來自哪個條目、屬於哪個主題」。這樣查詢時可以加上「只在城市類條目裡找」、「只從台北相關條目找」等過濾條件,大幅縮小檢索空間。今天的 metadata 欄位:title(條目名稱)、topic(city / science / history)、url(條目網址)。
完整實作:從抓維基到語意搜尋
以下範例在 Colab T4 上從抓 5 個條目到完成查詢約 5–8 分鐘。我們分四步:先用維基百科 API 抓條目文字,再用多語句模型編碼,接著寫進 Qdrant 1.12,最後跑幾組查詢比較。執行前需要:pip install qdrant-client==1.12.0 sentence-transformers==3.4.1 wikipedia-api==0.6.0。
# 1. 安裝套件;Colab T4 預裝 PyTorch 2.6
pip install -q qdrant-client==1.12.0 sentence-transformers==3.4.1 wikipedia-api==0.6.0
這段安裝三個套件。qdrant-client 是 Qdrant 的 Python SDK,sentence-transformers 3.4 是當時穩定版(含 PyTorch 2.6 支援),wikipedia-api 是社群維護的輕量維基百科 API 客戶端,封裝了 mediawiki 端點。Colab T4 預裝 PyTorch 2.6 與 CUDA 12.x,所以 torch.cuda.is_available() 會回傳 True,sentence-transformers 會自動用 GPU 加速。
# 2. 抓取 5 個維基百科精選條目
import wikipediaapi
# wikipedia-api 用 Wikipedia 物件取條目
WIKI = wikipediaapi.Wikipedia(
user_agent="hao-code-nlp-day24 (mailto:demo@example.com)",
language="zh",
)
# 精選條目清單(CC BY-SA 4.0 授權)
TITLES = ["台北", "東京", "深度學習", "半導體", "台灣"]
ARTICLES = {}
for title in TITLES:
page = WIKI.page(title)
if not page.exists():
print(f"找不到條目:{title}")
continue
ARTICLES[title] = {
"text": page.text,
"url": page.fullurl,
}
print(f"{title}:{len(page.text)} 字、{page.text.count(chr(10)+chr(10))} 段")
# 輸出(實際字數與段數會略有不同):
# 台北:12,450 字、48 段
# 東京:15,820 字、62 段
# 深度學習:9,300 字、35 段
# 半導體:8,750 字、32 段
# 台灣:22,100 字、86 段
這段從繁體中文維基百科抓取 5 個條目。wikipediaapi.Wikipedia(user_agent=..., language="zh") 建立 API client;user_agent 是維基百科 API 的強制要求,沒有它會被拒絕存取。page.exists() 確認條目存在、page.text 取得純文字內容(已去除 HTML)、page.fullurl 取得條目網址。維基百科內容採 CC BY-SA 4.0 授權,本系列使用它做語料時會在程式註解與文章標明。
順帶提醒:wikipedia-api 對維基百科 API 是友善封裝,但抓取節奏要節制。維基百科對 API 的速率限制是「每秒最多 200 個請求」,對小專案綽綽有餘;如果你想抓幾百個條目做大型語料庫,建議加 time.sleep(0.05) 或改用官方提供的「資料庫轉儲檔」(database dumps),一次下載整個 XML。實務上 RAG 系統多半用領域內的私有文件(企業內部法規、產品手冊、客服紀錄),維基百科在這裡只是「方便、可下載、授權清楚」的教學用範例。
# 3. 把每個條目切成段落,加上 metadata
import re
from typing import Iterator
def split_paragraphs(text: str, min_len: int = 80, max_len: int = 500) -> Iterator[str]:
"""以雙換行切段,並對過短的段落合併、過長的段落截斷。"""
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
merged = []
buf = ""
for p in paras:
if len(buf) + len(p) < max_len and len(p) >= min_len:
buf = (buf + "\n\n" + p).strip()
else:
if buf:
merged.append(buf)
buf = p if len(p) >= min_len else ""
if buf:
merged.append(buf)
# 截斷過長段落(保留前面 max_len 字)
final = []
for p in merged:
if len(p) > max_len:
final.append(p[:max_len])
else:
final.append(p)
return final
# 主題分類(依條目名稱人工對應)
TOPIC = {
"台北": "city",
"東京": "city",
"深度學習": "science",
"半導體": "science",
"台灣": "history",
}
paragraphs = []
metas = []
for title, art in ARTICLES.items():
for i, para in enumerate(split_paragraphs(art["text"])):
paragraphs.append(para)
metas.append({
"title": title,
"topic": TOPIC[title],
"url": art["url"],
"para_id": f"{title}_{i}",
})
print(f"共 {len(paragraphs)} 個段落")
# 輸出(實際數字會略有不同):共 240 個段落
這段把 5 個條目切成 200 多個段落。split_paragraphs 以雙換行為分隔、合併過短段落、截斷過長段落,是 RAG 系統中最常見的「paragraph-level chunking」策略。metadata 加上 title、topic、url 與 para_id;前兩個用於查詢時的 metadata filter,url 與 para_id 用於回傳時可以追溯到原始來源。topic 在真實專案通常由分類器預測,這裡為了簡化用手工對應。
# 4. 用多語言模型編碼所有段落(T4 上約 1 分鐘)
import time
import torch
from sentence_transformers import SentenceTransformer
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
device=device,
)
print(f"模型維度:{model.get_sentence_embedding_dimension()},裝置:{device}")
# 輸出:模型維度:384,裝置:cuda
start = time.time()
vectors = model.encode(
paragraphs,
batch_size=32,
show_progress_bar=False,
convert_to_numpy=True,
).astype("float32")
print(f"編碼 {len(paragraphs)} 段耗時 {time.time()-start:.1f} 秒")
# 輸出(實際時間會略有不同):編碼 240 段耗時 38.2 秒
這段把 240 段文字編碼成 384 維向量。paraphrase-multilingual-MiniLM-L12-v2 是 sentence-transformers 官方多語言模型,支援 50 多種語言、在繁體中文 STS benchmark 上 Spearman 相關係數約 0.83。device 自動偵測 CUDA(在 Colab T4 上會是 cuda)。model.encode 一次處理整個串列,batch_size=32 是 T4 16 GB VRAM 的安全值。convert_to_numpy=True 直接拿 numpy 陣列,後續上傳給 Qdrant 比較方便。T4 上 240 段約 38 秒,CPU 上約 8 分鐘。
# 5. 寫進 Qdrant in-process 模式
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
client = QdrantClient(":memory:")
client.create_collection(
collection_name="wiki_zh",
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
points = [
PointStruct(id=i, vector=vec.tolist(), payload={"text": t, **m})
for i, (vec, t, m) in enumerate(zip(vectors, paragraphs, metas))
]
client.upsert(collection_name="wiki_zh", points=points)
print(f"已寫入 {len(points)} 個點到 wiki_zh")
# 輸出:已寫入 240 個點到 wiki_zh
這段把 240 個向量上傳到 Qdrant。沿用 :memory: 模式,不需要啟動外部服務。PointStruct 把向量與 metadata(包含原文)打包,Qdrant 內部會對向量建 HNSW 索引、metadata 則走自有引擎的 filter。size=384 與 paraphrase-multilingual-MiniLM-L12-v2 的輸出對應、Distance.COSINE 是文字檢索的首選距離。注意 vec.tolist() 把 numpy 轉成 Python list,這是 Qdrant client 的要求。
# 6. 跑幾組查詢,比較不同 query 寫法的檢索結果
from qdrant_client.models import Filter, FieldCondition, MatchValue
def search(query: str, topic: str | None = None, top_k: int = 3):
"""用文字查詢 Qdrant,回傳 (id, score, payload)。"""
q_vec = model.encode([query], convert_to_numpy=True).tolist()[0]
q_filter = None
if topic:
q_filter = Filter(must=[FieldCondition(key="topic", match=MatchValue(value=topic))])
hits = client.search(
collection_name="wiki_zh",
query_vector=q_vec,
query_filter=q_filter,
limit=top_k,
)
return hits
# 查詢 A:寬鬆問句,無 metadata filter
print("=== 查詢 A:寬鬆問句、無 filter ===")
for h in search("台北最高的建築物是什麼?", top_k=3):
print(f" [{h.score:.3f}] {h.payload['title']}|{h.payload['text'][:80]}…")
# 查詢 B:加上 topic filter
print("=== 查詢 B:同上、限定 topic=city ===")
for h in search("台北最高的建築物是什麼?", topic="city", top_k=3):
print(f" [{h.score:.3f}] {h.payload['title']}|{h.payload['text'][:80]}…")
這段示範 Qdrant 的查詢 API。search() 包裝了「編碼查詢文字 → 呼叫 client.search() → 回傳結果」。查詢 A 不加 topic filter,會跨所有主題找相似段落,台北與東京、半導體等條目都會被考慮;查詢 B 加上 topic="city",只從城市類條目(台北、東京)找。這樣的設計在真實產品中很常見:使用者輸入「台北最高的建築物」,系統先確定是城市類、再做向量搜尋,能避免「深度學習」相關段落誤入結果。
值得留意的是 filter 的兩個常見變形。第一個是多條件 AND:例如同時要求 topic=city 且 title=台北,可以寫成 Filter(must=[FieldCondition(key="topic", ...), FieldCondition(key="title", ...)])。第二個是多條件 OR:例如 topic in (city, history),寫成 Filter(should=[FieldCondition(key="topic", match=MatchValue(value="city")), FieldCondition(key="topic", match=MatchValue(value="history"))])。Qdrant 的 must、should、must_not 三種邏輯可以任意嵌套,組合出複雜的條件樹。
# 7. 用「關鍵字 vs 語意」對照:示範為什麼純關鍵字不夠
print("=== 對照 A:純關鍵字「捷運」會找不到含「MRT」的段落 ===")
for h in search("捷運", topic="city", top_k=3):
print(f" [{h.score:.3f}] {h.payload['title']}|{h.payload['text'][:80]}…")
print("=== 對照 B:語意查詢「地下鐵路系統」會命中「捷運」「地鐵」段落 ===")
for h in search("地下鐵路運輸系統", topic="city", top_k=3):
print(f" [{h.score:.3f}] {h.payload['title']}|{h.payload['text'][:80]}…")
# 輸出(實際結果會略有不同):
# 對照 A:
# [0.621] 台北|台北捷運是台北都會區的捷運系統…
# [0.598] 東京|東京的地鐵系統由兩個公司經營…
# [0.541] 台北|…
# 對照 B:
# [0.812] 台北|台北捷運是台北都會區的捷運系統…
# [0.789] 東京|東京的地鐵系統由兩個公司經營…
# [0.745] 台北|…
這段展示語意搜尋相對於純關鍵字的優勢。「捷運」是台灣用語、「地鐵」是中國與香港用語、「MRT」是英文縮寫、metro 是國際通用詞;純關鍵字搜尋「捷運」會漏掉「東京的地鐵系統」這段,而語意查詢「地下鐵路運輸系統」會把所有同義詞的段落都召回。對照組的分數差距(0.621 vs 0.812)顯示語意查詢不只召回更多、相關度也更高——這就是 RAG 系統「先檢索、再生成」策略的價值所在。
常見錯誤與踩雷
錯誤一:wikipediaapi 報「HTTP 403 User-Agent required」。維基百科 API 對沒有 User-Agent 的請求直接拒絕,這是出於反濫用。對應排查:建立 Wikipedia 物件時一定要帶 user_agent,格式建議 "<app-name> (mailto:<email>)",例如 "hao-code-nlp-day24 (mailto:demo@example.com)"。
錯誤二:模型下載後報「out of memory」。paraphrase-multilingual-MiniLM-L12-v2 在 fp32 下約 470 MB,加上 T4 預載 PyTorch 2.6 與其他依賴,VRAM 仍夠用;但如果你在同一個 process 又跑了更大的 LLM(例如 7B 模型),就會 OOM。對應排查:用 model.half() 轉 fp16,VRAM 占用直接砍半;或把 LLM 放到第二個 process。
錯誤三:model.encode 回傳的向量是 fp64,Qdrant 上傳時報 dtype 錯誤。Qdrant 1.12 預期 float32;fp64 會在 PointStruct 序列化時失敗。對應排查:model.encode(..., convert_to_numpy=True).astype("float32") 強制轉型,或在 model.encode 後加 .astype(np.float32)。
錯誤四:metadata para_id 用中文,導致 Qdrant payload filter 失敗。Qdrant 對字串 payload 的 filter 區分大小寫、空白敏感、與儲存時完全一致才會命中。對應排查:把 para_id 改為 f"{title}_{i}"(拉丁字元 + 數字),避免編碼問題。
錯誤五:搜尋結果看起來不相關,但其實是切段造成。例如查詢「台北捷運」找到的段落是「台北捷運是台北都會區的捷運系統…」前面的導言,但分數只有 0.55,懷疑是模型出問題。實際上是因為這段同時包含「台北」「捷運」「都會區」三個概念,向量被「沖淡」到無明顯方向。對應排查:把段落的字數上限從 500 降到 200–300,讓每段的語意更集中。
效能與實務提醒
Colab T4 上 240 段的編碼約 38 秒,查詢每次 model.encode 加上 Qdrant ANN 搜尋共約 60–80 毫秒;CPU 上編碼拉長到 8 分鐘、查詢仍維持在毫秒級。Qdrant :memory: 模式在 process 結束後釋放,適合 notebook;正式部署要改 QdrantClient(url="http://server:6333") 接外部服務。
實務上有兩個常見瓶頸:第一個是大語料的初次編碼時間,10 萬段在 T4 上要 4–5 小時。建議用 model.encode(..., batch_size=128, output_value="vector") 並把向量預存到磁碟(例如 np.save("vectors.npy", vectors)),之後重啟直接載入、跳過編碼階段。第二個是冷啟動後第一次查詢較慢(HNSW 索引從磁碟載入),實務上會做「服務啟動後先預熱」——跑一輪 dummy 查詢把索引載入記憶體。
另一個常被忽略的細節是段落排序對結果的影響。維基百科的段落從導言開始,越後面越細節;如果你的查詢偏向「該條目的核心概念」,導言段落應該排前面;如果偏向「具體事實(例如 2024 年的事件)」,後段段落更重要。本篇的 split_paragraphs 沒有改動原始順序,這對大多數查詢足夠;但如果你的資料是時間序列(例如客服對話紀錄、研究日誌),可以加一個「時間欄位」、讓查詢時可以選擇「最近 N 天」這個 metadata filter。此外,Qdrant 預設會根據 payload 中的數值欄位建索引,但對純文字欄位(例如 title)需要明確建立全文索引才會生效,這是維運時容易踩到的坑。
另一個重要的設計選擇是「段落的 metadata filter 順序」。本篇的查詢 B 先加 topic filter 再做向量搜尋,這對「限定類別」很有用;但若你的 metadata 過濾掉太多資料(例如只篩某個極少見的小分類),會讓候選集合太小、ANN 退化成暴力搜尋。實務上會把「極少見的 metadata filter」轉成「先做向量搜尋、再用 filter 移除」的反向流程。明天我們會把今天的檢索結果接到 LLM,示範完整的 RAG 查詢設計。
小結
今天把 Day 22 的相似句、Day 23 的向量資料庫接到真實語料上:我們從維基百科的 5 個精選條目抓出 240 個段落,用 paraphrase-multilingual-MiniLM-L12-v2 編碼成 384 維向量、寫進 Qdrant 1.12 的 in-process 模式,再用「台北最高的建築物」「地下鐵路運輸系統」等查詢示範語意搜尋的威力。重點回顧:段落切分粒度(80–500 字)是中文檢索的甜蜜點、多語言模型對中文的支援優於純英文模型、metadata filter 要在 ANN 之前套用、語意查詢能召回跨同義詞的段落(捷運、地鐵、MRT)。我們用「捷運 vs 地下鐵路運輸系統」的對照組證明語意搜尋比純關鍵字強很多——這正是 RAG 系統會有今天這個熱潮的核心原因。明天我們會把檢索結果送進 LLM,組成完整的 RAG 流程。
結語
今天的重點是「把檢索這一層從 toy example 拉到真實語料」。我們用了 wikipedia-api 從繁體中文維基抓取「台北、東京、深度學習、半導體、台灣」5 個精選條目(CC BY-SA 4.0)、用 paraphrase-multilingual-MiniLM-L12-v2 編碼 240 個段落、寫進 Qdrant 1.12,再用「台北最高的建築物」「地下鐵路運輸系統」等查詢示範了語意搜尋召回同義詞段落的能力。讀完這篇你應該能回答:為什麼 RAG 系統需要 metadata filter?為什麼語意查詢比純關鍵字強?段落切分的字數上下限要怎麼設?為什麼 wikipedia-api 強制要求 User-Agent?metadata filter 應該寫在 ANN 之前還是之後?
檢索只是 RAG 的一半。明天,我們會把今天的檢索結果接到 LLM(用本地 Ollama 上的小模型或 Colab 上的開源模型),示範「query → retrieve → prompt → generate」四步完整 RAG 流程,並比較「有檢索 vs 沒檢索」的回答品質差異。
延伸資源
- sentence-transformers 多語言模型清單(2025):
https://www.sbert.net/docs/sentence_transformer/pretrained_models.html,paraphrase-multilingual-MiniLM-L12-v2、distiluse-base-multilingual-cased-v2等多語言模型的對照表與 STS benchmark 分數。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,Filter、FieldCondition、MatchValue的完整語法與 payload 索引設計。 - wikipedia-api 官方文件(2024):
https://wikipedia-api.readthedocs.io/,Wikipedia物件的page()、search()、summary()等 API。 - 繁體中文維基百科精選條目(CC BY-SA 4.0):
https://zh.wikipedia.org/wiki/Wikipedia:%E7%B2%BE%E9%81%B8%E6%96%87%E7%AB%A0,本系列使用的 5 個條目都來自這份清單。 - MS MARCO 資料集(Microsoft,2016):
https://microsoft.github.io/msmarco/,語意搜尋領域最常引用的英文 benchmark 之一,與今天的 paraphrase 系列模型關聯密切。
留言
張貼留言