檢索模式選型的討論通常在比「哪個模式排名最準」,而你在 demo 規模的語料上得到的答案永遠是「都差不多」。本次量測的最小語料裡,四個模式幾乎每題都排第 1。把同一份語料沿一條事先凍結的路徑放大到 155、再到 773 個 chunk 之後,同一題從三個模式排第 1,變成四個模式全部找不到。這篇把過程量給你看:選型要先問「答案被哪條腿弄丟」,才輪得到「哪個模式排得準」——讀完你能用兩個判準去預測自己的系統哪條腿先失效。
上一篇(Day 33)把排序品質的判定推給「需要相關性標註」,欠了一個問題沒答:檢索模式本身怎麼選。這篇還這筆債,用的是 2026-08-30 一場即用即刪的量測:Basic tier 的 Azure AI Search 服務、三個 index、四個模式、17 題凍結查詢,跑完就刪。
四個模式的機制 Day 13 講過,這裡只擺定義:keyword 是 BM25 全文檢索,vector 是查詢向量的最近鄰,hybrid 用 RRF 把前兩者的結果融合,hybrid_semantic 再加一層 semantic ranker 重排。
候選產生只有一個階段、兩條腿,而兩條腿丟東西的理由不同。
keyword 腿用詞彙重疊排除——查詢的詞和文件的詞對不上就出局。它沒有本 repo 設定的固定名額,語料變大時新文件隨時可能擠進候選集。vector 腿用名次排除——它只交出 vector_k 個最近鄰,第 k+1 名連出場的機會都沒有,跟詞彙無關。
semantic ranker 則根本不在候選產生這一層。官方文件明說它不能「rerun the query over the entire corpus」,它重排的是既有結果集的前 50 名(semantic ranking overview,ms.date 2026-08-05,查核 2026-08)。候選集裡沒有的東西,它看都看不到。
所以「哪個模式好」在答案不在候選集裡的時候是個假問題。真正可回答的問題是:你的負載會讓哪條腿先把答案丟掉。

實驗設計只講骨架,工具都在 day-34 tag 上:
build_distractor_corpus.py——組三個 generation 的干擾語料compare_retrieval.py——四模式逐題比較與截斷檢查三個 generation 共用同一份 base corpus(7 個文件、3 個租戶),只有 acme 租戶的干擾文件在長大——干擾語料是本系列已發布的文章與 lab 的公開文件:
| generation | acme 可見 chunk | 對 vector_k = 50 |
|---|---|---|
| G1 | 13 | 低於——vector 腿的截斷在結構上不可能 |
| G2 | 155 | 高於(3.1×)——截斷變得可能 |
| G3 | 773 | 高於(15.5×) |
G1 與 G2 之間跨過 50,是整個實驗唯一刻意製造的條件。「變得可能」不等於「發生了」——後者要逐題看排名。
查詢集是 12 題 acme(6 個分層 × 中英各一)加 5 題 globex 對照組,在任何一次查詢送出之前連同相關性標註一起凍結、先 commit 才開跑。看過排名再挑標註,這個比較就不可證偽——沒有任何東西能事後分辨「先凍結才量」與「先量才挑好看的」。
凍結還包括一條事先寫下的裁決。開跑前語料的形狀看起來對 null 有利——gold 集中在兩篇客服文件、干擾全是工程散文,向量距離本來就遠——所以我先押了字據:
如果 vector 腿的截斷根本沒有發生——三個 generation 的 gold chunk 都待在
vector_k= 50 以內,四個模式從頭到尾看起來都差不多——那就把這個 null result 當成結論寫出去。不加題目、不弱化語料、不重切 G2。
結果不是 null。但這段話得先存在,量出來的東西才可信——一個看過結果才做的決定,跟一個為了容納結果而做的決定,事後沒有人分得出來。
對照組 globex 與 opsdemo 的語料三個 generation 逐位元組相同(diff -r 全數為空),principal 也不變。它的用途本來只有一個:acme 語料長大時,替「什麼都沒變的租戶」留一組基準。後面會看到它多量到了一件事。
最後是兩句不假裝:干擾語料的排除規則(防止答案洩漏進干擾)系統性地把最接近 gold 的鄰居先剔掉了,所以本次所有數字都是干擾難度的下界;而且擴張路徑上語言比例也在動(英文 chunk 佔比從 G2 的 46.5% 走到 G3 的 64.6%),所以歸因一律寫「沿這條路徑擴張」,不寫「只有規模變了」。
先看最劇烈的一題。Q4 中文版問「如果是客戶自己把系統設定弄錯了會怎樣?」,gold 是 SLA 的排除條款,題面刻意不含 gold 的用詞(lexical decoy 層):
| Q4 zh 的名次 | G1(13 chunk) | G2(155 chunk) | G3(773 chunk) |
|---|---|---|---|
| keyword | absent | absent | absent |
| vector | 1 | 45 | absent |
| hybrid | 1 | 59 | absent |
| hybrid_semantic | 1 | 59 | absent |
G1 三個模式都排第 1。G3 四個模式全滅——keyword 腿從頭到尾詞彙對不上,vector 腿在 G3 被擠出前 50,兩條腿都空手,RRF 融合空集合還是空集合,semantic ranker 連重排的對象都沒有。可見語料沿這條路徑從 13 個 chunk 走到 773 個,這一題就從「隨便選都對」變成「怎麼選都錯」。
vector 腿的退場是階梯狀的,三題跨語言查詢排在一起看:
| 題目(vector 模式) | G1 | G2 | G3 |
|---|---|---|---|
| Q5 zh(Sev 1 升級) | 1 | absent | absent |
| Q4 zh(客戶設定錯誤) | 1 | 45 | absent |
| Q3 zh(何時拿到 credit) | 8, 2 | 40, 2 | absent, 2 |
而整批 acme 觀測值裡,vector 模式出現過的最深名次是 45,從未超過 50——DEFAULT_VECTOR_K 就是 50。同一批觀測裡 keyword 走到 111、hybrid 走到 138。這是可證偽的主張:vector 只要出現任何一個大於 50 的名次,「候選集被 k 截斷」的機制說法就死了。它沒有出現。
忍喵:圈「45」這個數字。keyword 走得到 111、hybrid 走得到 138,只有 vector 停在 45——上限是 50 的時候,最深觀測值卡在 50 之內不是巧合,是天花板的形狀。你 repo 裡那個k上次被當成「容量參數」而不是「效能參數」檢討,是什麼時候?
G1 的四個 absent 全部落在 keyword 模式的中文題——13 個 chunk 遠低於 50,vector 腿在結構上不可能截斷,所以 G1 的失敗全是詞彙的失敗,跟 k 無關。兩條腿的死法從第一個 generation 就分得開。
這裡對主線 Day 14 有一個措辭上的修正。lab 的 /rag 在零命中時結構性短路成 no_answer,那個判斷本身不動——零 hits 唯一誠實的讀法仍然是不呼叫 LLM。但 no_answer 的成因不得再被描述成「語料裡沒有答案」:Q4 zh 的答案三個 generation 都在語料裡,換個 generation、換條腿就找得到。講 no_answer 只能說「這次檢索沒有取回任何 hit」。
翻轉點講完召回,這節講排名——semantic ranker 到底買到什麼。量測給出一條乾淨的分界線:
| 題目 | hybrid 名次 | hybrid_semantic 名次 | ranker 有沒有動它 |
|---|---|---|---|
| Q2 zh @ G2 | 38 | 2 | 修回來了 |
| Q3 zh @ G2 | 49, 15 | 8, 2 | 修回來了 |
| Q2 zh @ G3 | 29 | 2 | 修回來了 |
| Q4 zh @ G2 | 59 | 59 | 一動不動 |
| Q5 zh @ G2 | 65 | 65 | 一動不動 |
| Q5 zh @ G3 | 138 | 138 | 一動不動 |
名次在 50 以內的(38、49、29),ranker 一把拉回前 10;名次在 50 以外的(59、65、138),一格都沒動。這與官方文件寫的機制一致:semantic ranker 重排的是既有結果集的前 50 名(來源同 §一)。它是修理工,但修理範圍有牆——牆外的候選,它視同不存在。
所以「要不要開 semantic ranker」這個選型問題,答案取決於你的失敗長什麼樣。答案常在候選集前 50 名內、只是排序不穩:這正是 ranker 的作用範圍,值得開起來量——本次量到的三個案例都被拉回前 10,38→2 這個量級。答案掉在 50 名外、或根本不在候選集:ranker 一點忙都幫不上,該調的是 vector_k 或查詢本身。
順帶一提 hybrid 的「容錯」在這批資料裡的真實長相:Q5 zh 在 G2,vector 腿 absent、keyword 名次 42,hybrid 還找得到——候選只可能來自 keyword 腿——但融合後的名次是 65。一條腿撐住了「找得到」,撐不住「排得前」。兩條腿機制不同是事實;「所以 hybrid 比較不會失敗」是本篇樣本支持不了的結論,這裡不下。
這一條不在設計裡,是量出來才看到的。
globex 的語料三個 generation 逐位元組相同,principal 相同,查詢相同。照直覺它的名次應該三代不動——但它動了,而且每一次變動都落在 keyword 腿(以及含 keyword 腿的 hybrid):Q1 2→2→1、Q2 2→1→1、Q3 3→2→2、Q5 3→3→2。vector 腿四題三代全部釘在 1,一次都沒動。
分數把成因指得更明白:同一題、同一個 chunk、globex 自己的語料一個位元組都沒變,BM25 分數卻從 G1 的 4.298867 走到 G3 的 12.173283——因為 acme 的干擾文件進了同一個 index。
機制上這不是 bug,是 BM25 的定義:ACL filter 決定哪些文件可以回傳,不決定分數怎麼算。BM25 的統計量取自被索引的語料,不是取自 filter 之後的結果集。
官方文件把範圍講得更細:分數預設在 shard 層級計算,加 scoringStatistics=global 才是跨 shard 全域(BM25 Relevance Scoring,ms.date 2025-08-27,查核 2026-08)。
同一頁的 Data volatility 一列明說:「Term frequencies will change as index updates are processed over time, affecting the search scores of matching documents.」本次量到的就是這句話的後果。
本次沒有分辨是 shard 級還是全域造成的,也不需要——兩者的範圍都不是「這個 principal 看得到的那一份」,結論只靠這一點。
對 Day 12 那個「單一 index + ACL filter」的多租戶模型,這是一條要老實寫上的限制:可見性隔離不等於排序隔離。租戶看不到彼此的文件,但本次量到:一個租戶灌語料,另一個租戶的 keyword 名次跟著動了。
這不是「每次寫入必動」的定律——預設統計量在 shard 範圍內算,分數變了名次也不一定跟著變。但 ACL 不在計分路徑上,「鄰居永遠不影響我的排序」同樣沒有任何東西保證。能寫的只有:排序獨立性無從承諾。本次量到的變動每次都只有 1 名、方向甚至是變好;幅度與方向是這份語料的性質,不是機制的保證。
忍喵:「名次只動了 1,還變好了」是這份語料的運氣,不是你的 production 會拿到的合約。隔壁租戶明天把一萬份「退款」相關文件灌進共用 index,你的退款 FAQ 的 BM25 統計量就可能跟著改——會不會、改多少,沒有合約替你保證,而你的監控大概只盯著自己租戶的寫入量。共用 index 的租戶隔離,你驗過的是哪一半?
它同時也限縮了本實驗自己:globex 能證明「租戶自己的語料沒變」,不能證明「租戶不受鄰居影響」。控制臂只控住了一半。
把量到的東西收成判準。第一判準:你的(單租戶可見)語料規模對 vector_k。語料在 k 以下,rank cutoff 這種失敗在結構上不可能發生——本次 G1 的 vector 腿也確實一題都沒丟;語料跨過 k,它從不可能變成可能,而且本次量到它真的發生。這把 vector_k 從「效能參數」變成「召回容量參數」——語料要長大,k 得跟著檢討,而不是抄一個預設值用到天荒地老。
第二判準:查詢與語料的詞彙重疊。英文題(詞彙貼近語料)keyword 腿在小語料夠用,但沿路徑擴張時名次一路滑:Q2 en 1→1→10、Q4 en 2→21→52;同題的 vector 與 hybrid_semantic 全程 1。
跨語言題更直接。中文題面在 en.microsoft 分析器之下,keyword 腿在 G1 就 absent 的是 Q2、Q3、Q4——三題題面沒有任何拉丁字母或數字;還排得進來的 Q1 與 Q5,題面各帶著 99.9% 與 Sev 1。這個對比與「中文詞彙匹配只剩英數 token 可用」一致——寫成一致,不寫成證明,本次沒有對分析器做隔離測試。
兩個判準疊起來,就是 Q4 zh 的死亡路線:詞彙判準說 keyword 腿從頭靠不住,規模判準說 vector 腿在 G3 到頂——兩條腿都失效,四個模式一起滅。選型矩陣長這樣:
| 你的負載 | 先看什麼 | 本次的依據 |
|---|---|---|
| 語料 < k、單語言、詞彙貼近 | 這個規模量不出模式差異——選型結論留到語料接近目標規模再下 | G1 幾乎每題全模式第 1(本語料) |
| 語料會長大 | vector_k 是召回天花板,隨語料檢討 |
最深 45/111/138;Q4 zh 的階梯 |
| 跨語言、paraphrase 重 | 先量 keyword 腿在你的分析器+查詢語言組合下還剩多少召回;它失守的部分全落在 vector 腿,k 的檢討跟著變急 | 本次中文題在 en.microsoft 下 keyword 近乎全滅 |
| 排名不穩、答案還在前 50 | 答案先進合併後前 50 才輪得到 ranker——確認這點,再開起來量修復幅度 | 本次 38→2、49→8 |
| 答案掉出前 50 或候選集 | ranker 無效,調 k 或查詢 | 59→59、65→65、138→138 |
| 多租戶共用 index | 可見性隔離 ≠ 排序隔離 | globex 的 keyword 名次三代在動 |
keyword 腿順帶一句:它在本 repo 的參數下沒有固定 k,但不是沒有上限。Azure 文件記載 hybrid 查詢的 BM25 腿有 maxTextRecallSize——預設 1,000、上限 10,000(create a hybrid query,頁面更新 2026-08-06,查核 2026-08)。
三個界線讓這句話精確:它是 preview 參數,本 repo 釘的 stable 2026-04-01 設不了它;stable 路徑是否套用那個預設視窗,官方沒有明講,記為未確立;無論套不套用,1,000 都遠高於本次最大的 773 chunk,在這裡不生效。
最後一條判準凌駕前面所有:這張表的數字綁這份語料與這組題目。它教你的是量法,不是答案——你的選型要用你的語料量。所以,最後一個問題:量一次多少錢。
整套量測的資源形狀是「即用即刪」:create-search.sh 建 Basic tier 服務 → 建三個 index → 六趟查詢 → 刪服務。兩個服務(其中一個是失誤多開的,等下講)合計存活 17 分 08 秒。
帳單對帳(2026-08-31,Azure Cost Management usageDetails,計價幣別 TWD):
| 項目 | cost(TWD) |
|---|---|
| Azure AI Search - Basic Unit × 2 個服務(各記 1 unit) | 4.276527 × 2 |
| embedding(text-embedding-3-small) | 0.527068386 |
| 合計 | 9.080122386 |
一杯手搖飲的零頭。系列的成本天花板是 US$20/月(Day 4 設的 budget alert),這次連邊都沒碰到——但這篇不換算百分比,因為帳單裡沒有匯率,硬換就是把估算混進量測。
這筆帳還順便回答了一個之前查不到的問題:不滿一小時的 Basic 服務怎麼計費。答案是每個服務各記一個 unit,quantity 各為 1——活 8 分 51 秒的和活 8 分 17 秒的,各拿整整一個 unit。所以「合計存活 17 分鐘」對帳單沒有意義:帳按你開了幾個服務算,不按分鐘數。ephemeral 策略省的是小時數,不是服務數。但只能推到這裡——兩次 run 跨過 UTC 15:00 整點,「只開一個服務會不會只記一個 unit」本次沒有觀測,不寫。
至於那個多開的服務:第一次 run 漏帶了 globex 對照組需要的 --group-id oncall,該臂的量測作廢,整場重跑。失誤的價目是 4.276527 TWD,佔本次總花費 47%。它的副產品剛好有用——acme 臂的 84 個預先登錄觀測值在兩個獨立建立的服務上逐格相同,是現成的可重現性證據——但它是失誤,不是事先規劃的驗證,這裡照實記。
忍喵:9 塊台幣量出一張選型矩陣——但先看清楚小字:這個價格是「跑完就刪」的價格。同一個 Basic 服務開著過夜,帳單就換一種算法陪你聊天了。
en.microsoft 分析器,不是中文檢索的一般結論。top 嚴格大於語料數、MAX_TOP = 1,000,所以這套方法量得到的單租戶語料上限是 999 chunk——production 規模的行為本篇量不到。回到題目:hybrid、vector、semantic ranker 怎麼選。這篇給的不是一個模式名,是一個順序——先用「語料對 k」和「詞彙重疊」判斷哪條腿會把答案弄丟,再決定 ranker 值不值得開,最後拿你自己的語料驗證——價目在上一節。
這是 Bonus 通道的第四篇。主線 30 天加上 Day 31–34,檢索這條線該交的卷都交了——剩下的問題在你的語料裡,量測工具在 day-34 tag 等你。
下一篇換一條完全不同的線。Day 4 建的那顆 Azure OpenAI resource(kind: OpenAI)已被官方標為新 Foundry portal 不支援(general availability overview,ms.date 2026-08-14,查核 2026-09),升級則是同一顆資源上一個屬性的 patch。
下一篇用一顆即建即刪的資源實測那個 patch 動到什麼——包括一條從頭到尾沒被改過的 role assignment,在那次單次觀測裡跟著變寬。
用到的 Azure 服務:
embed-small;本次零 LLM 呼叫,帳單印證)本篇的雲端花費已逐筆對帳(2026-08-31,Cost Management):9.08 TWD。Search 服務已全數刪除,subscription 內無殘留 Search 資源。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。