上一篇把語料切好、算完向量、schema 也定了,但一次都還沒真的查過。這篇要處理「怎麼找回來」。第一件要拆掉的直覺是:檢索看起來像一個動作,實際上是三段各自獨立的機制。任何一段壞掉,你收到的都是 HTTP 200 加一份看起來很像樣的名單。讀完你會知道該去修哪一段,以及為什麼「換一個更強的 ranker」在其中兩種情況下完全沒有用。
| 階段 | 它決定什麼 | 誰控制它 | 它修不了什麼 |
|---|---|---|---|
| 1. 候選產生(recall) | 答案有沒有進到候選集合 | vector_k、BM25 的文字召回 |
進不來的東西,後面兩段都救不回來 |
| 2. 融合(fusion) | 兩路候選怎麼合成一份名單 | RRF(只有 hybrid 有這段) | 只有一路的模式根本沒有這段 |
| 3. 重排(ranking) | 名單內部的順序 | semantic ranker(L2) | 召回失敗——它只看得到已經在名單裡的東西 |
「semantic ranker 什麼時候需要」的答案直接從這張表掉出來:先確認答案在不在候選集合裡。在,但排太後面 → 這是重排的活。不在 → 換 ranker 沒有用,要調的是階段 1。
這個分法值得記,因為三段的失敗在 response 上長得一模一樣。沒有一個欄位會告訴你「答案根本沒被撈進來」。
這裡沒有四支 API,只有一個 POST /indexes/{index}/docs/search,差別在於你放了什麼欄位:
| 模式 | search |
vectorQueries |
queryType |
|---|---|---|---|
| Keyword | 有 | 無 | 預設 |
| Vector | 無 | 有 | 預設 |
| Hybrid | 有 | 有 | 預設 |
| Hybrid + semantic | 有 | 有 | semantic + semanticConfiguration |
寫錯形狀不會報錯。少放 vectorQueries,你以為在跑 hybrid,實際上跑的是純 keyword,回來一樣是 200、一樣有分數、一樣排得好好的。這是這天把模式收斂成一個 enum、由一個函式產生請求體的原因——因為這種錯誤沒有任何執行期訊號,不是美觀問題。
把三個階段和四種模式畫在同一張圖上,就看得出每種模式停在哪一段:

初學這塊最容易糊在一起的是幾個數字,它們剛好都可以是 50:
vectorQueries[].k:向量那一路要撈幾個最近鄰。這是階段 1 的旋鈕。top:整個 response 最後回幾筆。這是輸出裁切,它不影響前面撈了多少。把 top 當成 k 用,你調的是「看得到幾筆」。撈進來幾筆早就定了,你只是把窗戶關小。
四種模式回傳的 @search.score 是四種不同的東西:
| 模式 | 分數來自 | 範圍 |
|---|---|---|
| Keyword | BM25 | 無上限 |
| Vector | cosine(HNSW) | 0.333–1.00 |
| Hybrid | RRF | 由融合的查詢數決定,每路最多貢獻約 1/k |
| Hybrid + semantic | @search.score 仍是 RRF,另外多一個 @search.rerankerScore |
reranker 0.00–4.00 |
官方自己的範例把落差寫得很清楚:同一筆旅館文件,純向量查詢是 0.8399121,hybrid 走 RRF 之後是 0.032786883413791656。差了大約二十六倍(精確是 25.617)。
低不代表比較差,是換了一把尺。實務後果是沒有任何一個門檻撐得過模式切換。你拿 vector 分數調出來的 0.5 門檻,套到 hybrid 上會濾掉全部——cosine 的地板 0.333,比本次實測那個形狀(兩路等權重、observed 的 0 起算形式)的 RRF 天花板還高。融合更多路、或給不同權重,天花板就會移動。而且它安靜地失敗:查詢照樣回,結果照樣排,只是線以上一個都不剩。
四個分數裡只有 @search.rerankerScore 有官方公布的判讀表(4.0 完整回答、3.0 相關但不完整、2.0 部分、1.0 沾到邊、0.0 不相關)。要設門檻,只有這一個有依據。
寫文件的時候我想順便驗一下那個 0.0328 是怎麼來的,結果對不起來。
官方的計分頁把 RRF 寫成 1/(rank + k),並且說明 rank 是「文件在清單中的位置」,同一頁的加權範例用的是 position 1、5、10——1 起算。但實測第一題的第一名,在 keyword 和 vector 兩份清單都排第一,分數是 0.033333;1-based 公式算出來應該是 2/61 ≈ 0.032787。
於是把兩路輸入的名次抓出來,重算全部融合分數。25 chunks、6 題、api-version=2026-04-01:
| 公式 | 吻合(小數第六位) |
|---|---|
1/(60 + rank),rank 從 1 起算(文件寫的) |
0 / 150 |
1/(60 + rank),rank 從 0 起算 |
150 / 150 |
150 筆全中。這個量測釘住的是組合:常數 60 配 0 起算,跟常數 59 配 1 起算,在算術上分不出來。而且它釘住的是「這次回傳的分數」,不是我看不到的服務內部實作。
這個結果要連著它的範圍一起讀。吻合是吻合到 evidence 記錄的小數第六位,不到完整浮點精度。兩路的名次則是另外跑出來的:同一段查詢文字、同一條查詢向量,各跑一次 keyword-only 與 vector-only,屬於「推測 hybrid 融合了什麼」而非從服務讀到的 subscore 實錄。整件事還是一個區域、一個日期、一個 API 版本、一份靜態語料、一種兩路預設權重的查詢形狀下的觀察,不是 Azure 公布的合約。
官方那個範例則是兩邊都證明不了。0.032786883413791656 是 2/61 的 float32 值——在文件公式下這是「兩份清單都排第一」,在本次觀察到的那個形式下這是「兩份都排第二」。那一頁說它是 top result,但那個 top 指的是融合之後的第一名;而兩份都第二(2/61 ≈ 0.0328)本來就贏得過只在一份清單排第一的文件(1/60 ≈ 0.0167)。那一頁從來沒公布這筆文件在兩路各自的名次,所以兩種讀法都還開著。那個範例能說明的是尺度差異,不是名次慣例。
要注意這個重算之所以可行,是因為這份語料不大於 top(25 對 25),兩路各自跑一次就拿得到完整清單。語料一旦大過 top,融合的輸入從外面就看不全了。
忍喵:這種差一的坑,只有真的去對數字才會掉出來。照著文件推導、推出一個很合理的解釋,然後整篇建立在上面——這是最貴的那種錯,因為它讀起來完全正確。
環境與日期都寫清楚:japaneast、Free tier、api-version=2026-04-01、2026-07-29。語料 25 chunks,top 固定 25(等於語料大小,所以「缺席」真的是「沒被撈出來」,不是「被裁掉」)。查詢集在跑之前就凍結並單獨 commit——看到排名之後才回頭挑預期答案,證明不了任何事。
先講兩個跟預期不一樣的:
第一,keyword 最有用的性質是候選集合會自己收斂,而不只是排序。第一題問 99.9% monthly uptime,BM25 只回了 4 筆(25 筆裡),第一名分數 10.2,第二名 5.5,梯度很陡。向量那一路回滿 25 筆,分數從 0.696 到 0.517,幾乎是平的。兩者都把正確答案排第一,差別在候選集合的大小。
4 筆不是 Azure 判斷「只有四筆語意相關」,更不是某種「其他都無關」的裁決。它就只是這組 analyzer(en.microsoft)與這句查詢在這份語料上,字面命中了四筆。換一個 analyzer 或換一種問法,這個數字就不一樣。向量那一路則是不管相不相關都回滿 k 筆。
第二,向量對數字沒有預期中那麼弱。設計時假設精確字面查詢會讓向量吃癟,實際上它照樣排第一。這份語料太小、主題區隔太清楚,不足以逼出那個差異——照實記著,不硬拗成預期的樣子。
真正看得到差別的是這兩題:
語彙誘餌(第四題):問「客戶自己設定錯了會怎樣」。BM25 的第一名是 runbook 裡一段不相干的「相關文件」,正確的 SLA Exclusions 排第二。向量、hybrid、hybrid+semantic 三者都把正確答案放回第一。這是 hybrid 最典型的用途:一路被表面詞彙帶偏,另一路把它拉回來。
重排會動,而且不是只往好的方向動(第三題):問「客戶什麼時候拿得到 credit」,語料裡 SLA 的 service credit 和 Returns 的 store credit 都相關。加上 semantic ranker 之後,SLA 那筆從第 5 名升到第 2 名——但 Returns 那筆從第 3 名掉到第 6 名。重排是用另一套判準重新排一次,不等於把對的往前推,而換判準必然有人往下掉。
最後一題最值得記:問語料裡根本沒有的「育嬰假政策」。四種模式全都回了一份自信的名單。hybrid 的最高分是 0.032796;第五題(語料裡確實有答案)的最高分是 0.032787。兩者實質上分不出來,而且無答案的那題還略高一點。
被否證的只有 hybrid 的 RRF @search.score,不是所有分數。同兩題的 @search.rerankerScore 最高分分別是 2.113(有答案)與 1.104(無答案),差了將近一倍,而且分別落在官方判讀表的「部分回答」與「只沾到一點邊」兩格。所以 reranker 分數在這兩題上確實分得出來——它是四個分數裡唯一有公布判讀表的,也是唯一在這裡帶了訊號的。
這不表示「設個 reranker 門檻就解決了」。官方自己提醒 reranker 分數的分佈會隨基礎設施與模型更新浮動,門檻不該切得太細;兩題也不足以支撐任何門檻值。Day 14 仍然要自己做 no-answer 判斷,只是理由要說對:是 RRF 分數沒用,而唯一有訊號的那個分數不夠穩、樣本也不夠,不是「分數都沒用」。
官方文件對這件事講了兩種話。以下四段原文皆為 2026-07 查核:
| 來源 | 原文 | 立場 |
|---|---|---|
| tier 頁 | 「Runs on the Free tier」 | 可以 |
| semantic 總覽 | 可以免費使用,但受 free tier 的服務限制 | 可以 |
| 計費頁 | Free 方案「Available on all pricing tiers」 | 可以 |
| pricing 頁 | 「It is not available in the Dedicated Free tier」 | 不行 |
三比一。但票數不是證據,下面會看到這個衝突到現在還沒收掉。
實測結論:在這台服務上可以。Free tier 的服務開出來就是 semanticSearch: free,queryType=semantic 的查詢回 HTTP 200,五筆結果每一筆都帶 @search.rerankerScore,而且確實重排了——一筆 BM25 排第五(分數最低)的文件,重排後回到第三。
判定標準不是 HTTP 200。官方明訂 semantic ranker 只在 queryType=semantic 且 search 字串非空時才計費,所以拿 search=* 去測,很可能拿到一個證明不了任何事的 200。要看的是回應裡有沒有 @search.rerankerScore。
有人會想這樣調和:pricing 頁講的是 standard 計費方案,而計費頁確實也說 standard「Requires the Basic tier or higher」。但這個讀法撐不住——pricing 頁那句話講的是功能可不可用,不是在描述 standard 方案的 tier 需求。所以文件衝突到現在仍未解決,我不宣稱哪一頁是對的。實測能確定的只有一件很窄的事:這台服務、這個區域、這個日期、這個 API 版本上,semantic 重排跑起來了。
另外兩個限制,讓「Free tier 可以免費用 semantic ranker」這句話不能就這樣收尾:
這兩點都來自官方文件——這次 session 沒有把額度用完,所以兩者都不是實測到的。要用在正式環境,自己再驗一次。
還有第三個限制,不在文件裡,是後來自己撞到的。
2026-08-02,也就是四天後,同一台 Free tier 服務、同一個區域,控制面的 metadata 秒回,但每一個資料查詢都失敗——多數在 client timeout 內等不到回應,把 timeout 拉長的三次探測分別在 60.3、85.0、60.2 秒收到 503 Operation was canceled. Please wait and retry your request later.。
三次收到的取消都發生在服務端,時間點會浮動;這一輪把 client timeout 調大並沒有換來成功,只換來較晚的 503——但這不證明每個沒等到回應的請求都是被服務端取消的。
當天的語料還是那 25 chunks、查詢也只有幾次,量級構不成壓力;換成 Basic tier 之後同樣的查詢 0.16 到 0.31 秒回應。我沒有分辨這是共享基礎設施在丟負載,還是這台 instance 卡住了——當下直接換了 tier,沒有回頭重建 Free 服務再測一次。所以這只是一次事件,撐不起關於 Free tier 的任何通則。
對照著做的人只要認得症狀就好:查詢全部失敗(等到 client timeout,或等更久收到 503)而 metadata 正常,這跟你的 filter 或向量欄位寫法無關,不必回頭 debug 請求體。
要繼續就只能 delete 之後用 AZ_SEARCH_SKU=basic 重建(Free 沒有原地升級的路,就是上一條),而 Basic 按服務存在的時間計費、不是按查詢量(查核 2026-07,pricing tiers),所以跑完立刻 infra/scripts/delete-search.sh。
debug 參數可以拿 subscore,這次沒用。en.microsoft。結論不可以外推到臺灣正體中文、多語系、更大或主題更接近的語料——中文分詞與這個 analyzer 的行為完全是另一回事。embed-small 是 deployment 別名,不等於釘住模型版本。同一個別名底下的模型換版,向量就會變,這次的分數也就不可重現。k,我沒有窮舉整份請求 schema。查核當下的請求體裡沒有這個參數(hybridSearch 只有 maxTextRecallSize 和 countAndFacetMode,vectorQueries[].k 是鄰居數),所以說它「固定在服務內部」是有依據的推論,不是逐欄位驗證過的斷言。檢索的三段拆開了,四種模式的請求形狀和分數性質也清楚了。Day 14 要把這條查詢管線接進 /chat:怎麼把檢索結果變成 prompt 的一部分、引用怎麼帶、以及這天留下的那個問題——語料裡沒有答案的時候,要怎麼讓系統說**「我不知道」**,因為分數已經證明它不會自己說。
完整程式碼在 day-13 tag,CI 綠。
用到的 Azure 服務:Azure AI Search(Free tier、japaneast,ephemeral session 建完即拆)、Azure OpenAI embeddings(embed-small deployment,算查詢向量)。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。