AI-LECTURER 速報課|2026-08-27|約 28 分鐘

別再讓搜尋引擎把重點洗掉:微調你自己的逐字比對檢索模型

📍 真實場景
阿哲,32 歲,醫院資訊室的系統工程師

正在幫醫院的臨床決策支援系統,加裝一個「查詢舊病歷與臨床指引」的語意搜尋功能,方便值班護理師快速找到處置建議

😖 卡住的地方:他先接上一個現成的通用嵌入模型(general-purpose embedding model),結果護理師輸入「胸痛合併冒冷汗的處置」,排名最前面的幾筆卻是「胸痛的常見病因分類」之類不相干的衛教文件——因為通用模型把整句話壓成一個向量,「冒冷汗」「處置」這些關鍵細節被平均掉了,護理師抱怨「還不如自己用 Ctrl+F 找」
💡 這堂課會帶阿哲認識 Sentence Transformers 新推出的 MultiVectorEncoder,學會怎麼微調一個保留「逐字向量」的 ColBERT 風格檢索模型——部落格作者親自示範,自己微調的醫療檢索模型只花 14.5 小時訓練,就打贏了市面上所有現成的通用檢索模型。

🧭 這到底是什麼(白話版)

你平常用的搜尋引擎、公司內部知識庫,背後大多是一種叫 dense embedding model把一整段文字壓縮成一個向量的做法,你可以想成是把一整本書濃縮成一句話的摘要 的技術。它把一句話、一篇文件通通壓成一串固定長度的數字(向量),要比對兩份文件像不像,只要算這兩串數字的內積就好,又快又省空間。

但「壓縮」是有代價的,細節會被平均掉。這篇 Hugging Face 文章要教的是另一種做法——多向量模型(multi-vector model,又稱 late-interaction 或 ColBERT 風格模型)不是把整段話壓成一句摘要,而是幫句子裡每一個字都保留一個屬於自己的小向量,查詢的時候逐字去比對。查詢和文件比對時,用的是 MaxSim讓查詢裡的每一個字,去文件裡找出跟它最像的那個字,把所有「最佳配對分數」加總起來,當作這份文件的總分 這個計分方式,而不是一次性的內積。

這篇文章的重點不是介紹這個技術「是什麼」(那是姊妹文的工作),而是教你怎麼 微調(finetune)拿一個已經訓練好的模型,用你自己領域的少量資料再訓練一次,讓它更懂你這行的專業用語與「什麼算相關」的判斷標準 一個屬於自己領域的多向量模型。不管是法律、醫療還是程式碼搜尋,「什麼詞彙常出現」「使用者怎麼下查詢」「什麼叫相關」,每個領域的答案都不一樣,通用模型很難通吃。

Sentence Transformers 這個套件把整套微調流程拆成六個可以各自替換的積木:模型本身、資料集、loss 函數訓練時用來告訴模型「這次猜得有多離譜」的計分方式,模型會朝著讓這個分數變小的方向,一步步調整自己內部的參數、訓練參數、evaluator(評估器)在訓練過程中,定期把模型抓出來做小考,量化它是進步了還是退步了,還有負責把這一切兜起來執行的訓練器(trainer)。搞懂這六塊積木怎麼組合,你就能自己動手微調一個檢索模型。

🎯 為什麼值得你花時間

通用模型在你的專業領域上會失準文章明講:網頁搜尋、法律檢索、程式碼搜尋、學術文獻回顧,彼此的用字習慣、查詢方式、「什麼算相關」的標準都不一樣。通用嵌入模型是在網路上的大雜燴資料訓練出來的,遇到你這行的專業術語與判斷標準,很容易誤判。
逐字比對留住了單向量會抹掉的訊號MaxSim 讓查詢的每個字都能在文件裡找到自己的最佳配對,不會像 dense 模型那樣把「冒冷汗」這種關鍵字跟整句話的其他字混在一起平均掉,這通常代表更強的檢索效果,只是要多付出一點索引空間。
這不是紙上談兵,是真實打贏的紀錄作者自己微調的 mLateOn-medical 模型,只用一張 RTX 3090、訓練 14.5 小時,就在醫療檢索測試上打贏了所有找得到的通用檢索模型——不分 dense、sparse、字面比對(lexical)還是其他多向量模型。這證明一個工程師靠消費級硬體就能做出比大廠現成模型更準的專屬檢索系統。

⚙️ 它是怎麼運作的

1
1. 選模型(Model)用 MultiVectorEncoder 把一個基礎 Transformer(例如 ModernBERT)包裝成多向量模型,也可以直接載入別人已經訓練好的多向量模型繼續微調。
2
2. 準備資料集(Dataset)用 Hugging Face 的 datasets 套件準備「查詢、正確文件」配對,最簡單的格式只要兩欄;要教模型細緻的分數差異,再加上分數或標籤欄位。
3
3. 選 loss 函數用 CachedMultiVectorMultipleNegativesRankingLoss 這種對比學習 loss,讓模型看到「這是正確答案,其他都是干擾項」,調整參數讓正確配對的 MaxSim 分數變高、干擾項變低;需要知識蒸餾則換成 MultiVectorDistillKLDivLoss。
4
4. 設定訓練參數MultiVectorEncoderTrainingArguments 裡定義訓練幾輪(epoch)、一次讀幾筆(batch size)、學習率(learning rate)多少、要不要用 fp16/bf16 混合精度加速、多久存一次檔、多久評估一次。
5
5. 掛上評估器(Evaluator)MultiVectorInformationRetrievalEvaluator 會在訓練途中,定期用一組「已知正確答案」的小測驗,量化模型是進步還是退步,避免練到後來反而變差都不知道。
6
6. 交給 Trainer 開跑MultiVectorEncoderTrainer 把模型、資料、loss、評估器全部兜起來,呼叫 trainer.train() 開始訓練,結束後用 model.save_pretrained() 存檔,之後就能直接載入這個資料夾使用。
通用 dense 嵌入模型 vs. 多向量(ColBERT 風格)模型
面向一般 dense 嵌入模型多向量(ColBERT 風格)模型
儲存方式整段文字 → 1 個向量每個字 → 1 個小向量
比對方式算 1 次內積MaxSim,逐字找最佳配對後加總
索引大小大(每個字都要存)
細節保留程度容易把細節平均掉保留逐字的細微差異
適合情境大範圍粗篩、資源有限要求高精準度的專業檢索(法律、醫療、程式碼)

🔍 程式碼漫遊(點有 ● 的行看白話講解)

這段是文章示範的完整微調流程精簡版,把模型、loss、訓練參數、訓練器四個角色兜在一起。

from sentence_transformers import MultiVectorEncoder
💬 先把 MultiVectorEncoder 這個新類別匯入進來,它是這篇文章的主角。
from sentence_transformers.multi_vector_encoder.losses import CachedMultiVectorMultipleNegativesRankingLoss
💬 匯入對比學習用的 loss 函數,名字很長但拆開看:Cached(省記憶體)+ MultiVector(多向量版)+ MultipleNegativesRankingLoss(用批次內其他答案當負例)。
from sentence_transformers import MultiVectorEncoderTrainingArguments, MultiVectorEncoderTrainer
💬 訓練參數類別跟訓練器類別,是負責「設定怎麼練」跟「實際去練」的兩個角色。
model = MultiVectorEncoder("answerdotai/ModernBERT-base", model_kwargs={"torch_dtype": "float32"})
💬 從一個現成的基礎模型(ModernBERT)包成多向量模型;也可以把模型名稱換成別人已經訓練好的多向量模型,直接在它基礎上繼續微調。
loss = CachedMultiVectorMultipleNegativesRankingLoss(model=model, mini_batch_size=16)
💬 對比學習的計分方式:告訴模型「這是對的答案,其他都是干擾」;mini_batch_size 是為了省顯示卡記憶體,把一大批資料切成小塊分開算。
args = MultiVectorEncoderTrainingArguments(output_dir="models/my-colbert", num_train_epochs=1, per_device_train_batch_size=128, learning_rate=1e-4, bf16=True, eval_strategy="steps", eval_steps=0.1)
💬 訓練的所有旋鈕都在這裡轉:看幾輪資料(num_train_epochs)、一次吃多少筆(batch_size)、腳步邁多大(learning_rate),還有多久做一次小考(eval_steps)。
trainer = MultiVectorEncoderTrainer(model=model, args=args, train_dataset=train_dataset, loss=loss, evaluator=dev_evaluator)
💬 把模型、資料、loss、評估器全部交給 trainer 統一調度,等於是把所有積木兜在一起。
trainer.train()
💬 真正開始訓練,期間會依 eval_steps 的設定,定期用評估器幫模型小考一次。
model.save_pretrained("models/my-colbert/final")
💬 訓練完存檔,之後只要指向這個資料夾路徑,就能直接載入這個微調好的模型。

🛠️ 動手做:動手微調一顆你自己的迷你 ColBERT 檢索模型

這是一個真實小專案:下載(或複製)檔案,照步驟在你電腦上跑起來。

📄 train_toy_colbert.py ⬇ 下載
"""迷你版 ColBERT 風格多向量模型微調示範
只用 8 筆玩具資料、CPU 可跑,目的是體驗六大訓練元件如何兜起來,
不是拿來做正式的醫療檢索系統。"""
from datasets import Dataset
from sentence_transformers import (
    MultiVectorEncoder,
    MultiVectorEncoderTrainingArguments,
    MultiVectorEncoderTrainer,
)
from sentence_transformers.multi_vector_encoder.losses import (
    CachedMultiVectorMultipleNegativesRankingLoss,
)

# 1. 玩具資料集:8 組「臨床問題 -> 正確處置說明」配對
train_rows = {
    "query": [
        "急性心肌梗塞合併冒冷汗要立刻做什麼處置",
        "某降血壓藥物與葡萄柚汁併用有什麼禁忌",
        "第一型糖尿病病人出現果香味呼吸要懷疑什麼",
        "打完蜂螫後全身起疹合併喘不過氣怎麼辦",
        "疑似中風時要用哪個口訣快速評估",
        "懷疑敗血症時有哪三項指標要優先確認",
        "氣喘急性發作合併說話只能講單字怎麼處理",
        "血鉀過高在心電圖上會看到什麼變化",
    ],
    "passage_text": [
        "病人主訴胸痛合併冒冷汗、噁心,應立即建立靜脈輸液通路、給予嚼服阿斯匹靈、12 導程心電圖確認是否為 ST 段上升,並啟動心導管室待命。",
        "鈣離子通道阻斷劑(如 amlodipine)與葡萄柚汁併服會抑制 CYP3A4 酵素代謝,導致藥物血中濃度異常升高,增加低血壓與心悸風險,應間隔至少兩小時或避免併用。",
        "呼吸帶有果香味、意識改變合併血糖過高,應懷疑糖尿病酮酸血症,須立即檢驗血酮與動脈血氣分析並給予胰島素與輸液治療。",
        "蜂螫後出現全身蕁麻疹合併呼吸喘鳴,屬於過敏性休克表現,應立即肌肉注射腎上腺素並準備氣道處置與輸液擴容。",
        "使用 FAST 口訣評估疑似中風:Face 臉歪、Arm 手無力、Speech 說話不清、Time 記錄發作時間並立即送醫。",
        "懷疑敗血症時優先確認呼吸速率、意識狀態與收縮壓,也就是 qSOFA 三項指標,任兩項異常即需提高警覺並啟動敗血症照護組合。",
        "氣喘急性發作只能講單字、端坐呼吸屬於重度發作,應立即給予高流量氧氣與吸入型支氣管擴張劑,並評估是否需要全身性類固醇。",
        "血鉀過高在心電圖上常見 T 波高聳窄尖,嚴重時會出現 P 波消失與 QRS 波變寬,需立即給予鈣劑穩定心肌並降鉀處置。",
    ],
}
train_dataset = Dataset.from_dict(train_rows)

# 2. 模型:用一顆極小的 BERT(bert-tiny)當骨架包成多向量模型,CPU 幾分鐘內可訓練完
model = MultiVectorEncoder(
    "prajjwal1/bert-tiny",
    model_kwargs={"torch_dtype": "float32"},
)

# 3. Loss:對比學習,讓「正確配對」的 MaxSim 分數比「批次內其他配對」高
loss = CachedMultiVectorMultipleNegativesRankingLoss(model=model, mini_batch_size=2)

# 4. 訓練參數:刻意調小、關閉混合精度,讓沒有獨立顯卡的電腦也能跑
args = MultiVectorEncoderTrainingArguments(
    output_dir="models/toy-colbert-medical",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    learning_rate=2e-5,
    fp16=False,
    bf16=False,
    eval_strategy="no",
    save_strategy="no",
    logging_steps=1,
    run_name="toy-colbert-medical",
)

# 5. 交給 Trainer 開跑(這個玩具規模在一般筆電 CPU 大約 1-3 分鐘內跑完;
#    為了讓示範簡單好跑,這裡先不掛評估器,正式專案請照 code_walk 掛上 evaluator)
trainer = MultiVectorEncoderTrainer(
    model=model,
    args=args,
    train_dataset=train_dataset,
    loss=loss,
)
trainer.train()

model.save_pretrained("models/toy-colbert-medical/final")
print("訓練完成,模型已存到 models/toy-colbert-medical/final")
📄 test_search.py ⬇ 下載
"""載入剛剛微調好的玩具多向量模型,
用一個新問題去跟四篇候選文件比對,
驗證「逐字比對(MaxSim)」機制真的會依語意排序,而不是亂猜。"""
from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("models/toy-colbert-medical/final")

query = ["病人胸口悶痛還一直冒汗,該先做什麼?"]
candidates = [
    "病人主訴胸痛合併冒冷汗、噁心,應立即建立靜脈輸液通路、給予嚼服阿斯匹靈、12 導程心電圖確認是否為 ST 段上升,並啟動心導管室待命。",
    "鈣離子通道阻斷劑與葡萄柚汁併服會抑制 CYP3A4 酵素代謝,導致藥物血中濃度異常升高。",
    "使用 FAST 口訣評估疑似中風:Face 臉歪、Arm 手無力、Speech 說話不清、Time 記錄發作時間。",
    "氣喘急性發作只能講單字、端坐呼吸屬於重度發作,應立即給予高流量氧氣與吸入型支氣管擴張劑。",
]

query_embeddings = model.encode_query(query)
document_embeddings = model.encode_document(candidates)

scores = model.similarity(query_embeddings, document_embeddings)
print("各候選文件的 MaxSim 分數:")
for text, score in sorted(zip(candidates, scores[0].tolist()), key=lambda x: -x[1]):
    print(f"{score:.4f}  {text[:24]}...")
  1. 在 D 槽建立一個新資料夾,例如 D:\lesson-colbert,把 train_toy_colbert.py 和 test_search.py 兩個檔案存進去
  2. 打開 PowerShell,切換到這個資料夾:cd D:\lesson-colbert
  3. 建立虛擬環境:python -m venv .venv
  4. 啟用虛擬環境:.\.venv\Scripts\Activate.ps1
  5. 安裝套件(第一次會下載 PyTorch,需要幾分鐘):pip install -U "sentence-transformers>=6.0" datasets torch
  6. 執行微調腳本:python train_toy_colbert.py(會自動下載 bert-tiny 模型,檔案很小,加上訓練約 1-3 分鐘完成)
  7. 執行推論測試:python test_search.py
  8. 觀察輸出:分數最高的那一筆應該是「心肌梗塞」那段說明,代表模型學會把語意相近的查詢跟文件配對在一起——這正是本課教的 MaxSim 逐字比對在起作用

🧠 工程思維透鏡(資深工程師看到的是什麼)

🔭 為什麼要犧牲「索引大小」去換「精準度」?這筆帳划算嗎?
多向量模型的索引比 dense 模型大上好幾倍(每個字都要存向量),對資源有限的團隊是實打實的成本壓力。但在醫療、法律這類「查錯一次代價很高」的領域,查得準的商業價值遠高於多花的儲存與運算成本。工程判斷不是「哪個技術比較潮」,而是「這個領域裡,查錯的代價 vs. 多花的資源,哪個划算」。
🔭 同樣是微調,為什麼用 CachedMultiVectorMultipleNegativesRankingLoss,而不是直接標「對/錯」的分類 loss?
對比學習利用「批次內其他樣本的正確答案」自動當作免費的負例,不需要人工額外標注「這是錯的」,大幅降低標註成本。Cached 版本則是先把大批次切成小塊分開算梯度再合併,用時間換取顯卡記憶體,這是資源受限時常見的工程取捨:用更長的訓練時間,換取一張消費級顯卡也能訓練。
🔭 文章特別展示 mLateOn-medical 打贏「所有」類型的現成模型(dense、sparse、lexical、多向量),這代表「微調」永遠比「用現成大模型」好嗎?
不是。這個結論成立的前提是「有夠好的領域資料、單一垂直領域、養得起訓練與維護成本」;通用模型的優勢在於零維護、跨領域都堪用。資深工程師看到這種評測,會先問「我的資料量夠嗎?我的領域夠窄、夠特殊嗎?後續資料更新我養得起這條訓練線嗎?」,而不是照單全收「微調一定比較強」。
🔭 同一張消費級顯卡(RTX 3090)訓練 14.5 小時,這個時間成本告訴我們什麼?
這說明作者刻意示範「不需要企業級算力,也能做出打敗現成模型的檢索系統」,是門檻的示範而非效能上限。但對工程決策而言,14.5 小時只是「一次訓練」的成本,之後資料更新、模型換版都要重跑這個時間,所以評估「該不該自建」時,要把這個週期成本乘上維運頻率,而不是只看一次性的訓練時間。

📝 隨堂考(點選答案,立即回饋)

Q1. 多向量模型(ColBERT 風格)跟一般 dense 嵌入模型最大的差異是什麼?
✅ 多向量(ColBERT 風格)模型的核心設計就是「不壓縮」,靠 MaxSim 逐字比對抓住細節;一般 dense 模型才是把整段話壓成一個向量。
Q2. 文章裡的 CachedMultiVectorMultipleNegativesRankingLoss,它的「負例」是從哪裡來的?
✅ 這種對比學習 loss 利用「批次內其他樣本的正解」當免費負例,不必額外人工標註「這是錯的」,大幅降低標註成本。
Q3. 如果你的檢索系統只是給一般大眾做粗略的網頁搜尋,資源(儲存、算力)又很吃緊,通常比較適合選哪一種?
✅ 多向量模型精準但索引大、運算成本高,適合對精準度要求極高的專業領域;資源有限、只要求粗篩的情境,dense 模型的 CP 值通常更高。
Q4. 文章中的 MultiVectorEncoderTrainingArguments 裡,learning_rate 這個參數大致在控制什麼?
✅ learning_rate(學習率)控制模型每次依照 loss 訊號調整參數時的步伐大小,跟控制「一次讀幾筆資料」的 per_device_train_batch_size 是不同的旋鈕。
Q5. 文章結論裡,作者的 mLateOn-medical 模型是拿什麼跟它比較,證明微調有效?
✅ 作者強調自己微調的模型打贏了每一種能找到的通用檢索模型,涵蓋 dense、sparse、lexical、多向量各類型,用完整的跨類型比較來證明微調的價值。

🃏 翻牌記憶卡(先想答案,再點開對答)

MaxSim點我翻面
查詢的每個字都去文件裡找最像的那個字,把所有「最佳配對分數」加總起來,當作總相似度。
多向量模型(ColBERT 風格)點我翻面
不把整段文字壓成一個向量,而是幫每個字都保留一個向量,查詢時逐字比對。
CachedMultiVectorMultipleNegativesRankingLoss點我翻面
對比學習用的 loss,拿同一批次裡其他樣本的正確答案當免費負例,並把大批次切小塊算梯度以省顯卡記憶體。
MultiVectorEncoderTrainingArguments點我翻面
設定訓練細節的地方:訓練幾輪、一次讀幾筆、學習率多少、要不要用混合精度等。
知識蒸餾(knowledge distillation)點我翻面
讓一個小模型模仿另一個更厲害的大模型的判斷結果來學習,而不是只看人工標註的正解。
encode_query / encode_document點我翻面
多向量模型推論時用的兩個方法,分別把查詢和文件編碼成「每個字一個向量」的張量。

✅ 離開前自測(全勾=這課真的學會了)

0%