iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 13

Day 13:Vector、Keyword、Hybrid 與 Semantic Ranker——檢索不是一個動作,是三個會分別失效的階段

  • 分享至 

  • xImage
  •  

上一篇把語料切好、算完向量、schema 也定了,但一次都還沒真的查過。這篇要處理「怎麼找回來」。第一件要拆掉的直覺是:檢索看起來像一個動作,實際上是三段各自獨立的機制。任何一段壞掉,你收到的都是 HTTP 200 加一份看起來很像樣的名單。讀完你會知道該去修哪一段,以及為什麼「換一個更強的 ranker」在其中兩種情況下完全沒有用。

三個會分別失效的階段

階段 它決定什麼 誰控制它 它修不了什麼
1. 候選產生(recall) 答案有沒有進到候選集合 vector_k、BM25 的文字召回 進不來的東西,後面兩段都救不回來
2. 融合(fusion) 兩路候選怎麼合成一份名單 RRF(只有 hybrid 有這段) 只有一路的模式根本沒有這段
3. 重排(ranking) 名單內部的順序 semantic ranker(L2) 召回失敗——它只看得到已經在名單裡的東西

「semantic ranker 什麼時候需要」的答案直接從這張表掉出來:先確認答案在不在候選集合裡。在,但排太後面 → 這是重排的活。不在 → 換 ranker 沒有用,要調的是階段 1。

這個分法值得記,因為三段的失敗在 response 上長得一模一樣。沒有一個欄位會告訴你「答案根本沒被撈進來」。

四種模式,是同一個 POST body 的四種形狀

這裡沒有四支 API,只有一個 POST /indexes/{index}/docs/search,差別在於你放了什麼欄位:

模式 search vectorQueries queryType
Keyword 預設
Vector 預設
Hybrid 預設
Hybrid + semantic semanticsemanticConfiguration

寫錯形狀不會報錯。少放 vectorQueries,你以為在跑 hybrid,實際上跑的是純 keyword,回來一樣是 200、一樣有分數、一樣排得好好的。這是這天把模式收斂成一個 enum、由一個函式產生請求體的原因——因為這種錯誤沒有任何執行期訊號,不是美觀問題。

把三個階段和四種模式畫在同一張圖上,就看得出每種模式停在哪一段:

檢索三階段流程圖:BM25 與向量各自從語料產生候選,RRF 融合兩份名單,semantic 重排合併後的前 50 筆;四種模式分別停在不同階段

三個 50 是三件不同的事

初學這塊最容易糊在一起的是幾個數字,它們剛好都可以是 50:

  • vectorQueries[].k:向量那一路要撈幾個最近鄰。這是階段 1 的旋鈕。
  • top:整個 response 最後回幾筆。這是輸出裁切,它不影響前面撈了多少。
  • semantic ranker 的 50:重排最多吃 50 筆。這是階段 3 的輸入上限,官方明訂超過 50 的部分不會進重排。

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 不相關)。要設門檻,只有這一個有依據。

順手量到的事:RRF 的公式跟文件差一

寫文件的時候我想順便驗一下那個 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.0327868834137916562/61 的 float32 值——在文件公式下這是「兩份清單都排第一」,在本次觀察到的那個形式下這是「兩份都排第二」。那一頁說它是 top result,但那個 top 指的是融合之後的第一名;而兩份都第二(2/61 ≈ 0.0328)本來就贏得過只在一份清單排第一的文件(1/60 ≈ 0.0167)。那一頁從來沒公布這筆文件在兩路各自的名次,所以兩種讀法都還開著。那個範例能說明的是尺度差異,不是名次慣例。

要注意這個重算之所以可行,是因為這份語料不大於 top(25 對 25),兩路各自跑一次就拿得到完整清單。語料一旦大過 top,融合的輸入從外面就看不全了。

https://ithelp.ithome.com.tw/upload/images/20260813/20168288wJbrMuJMMR.png
忍喵:這種差一的坑,只有真的去對數字才會掉出來。照著文件推導、推出一個很合理的解釋,然後整篇建立在上面——這是最貴的那種錯,因為它讀起來完全正確。

實測:四種模式在同一份語料上的差別

環境與日期都寫清楚: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 分數沒用,而唯一有訊號的那個分數不夠穩、樣本也不夠,不是「分數都沒用」。

那個掛了兩天的懸案:Free tier 到底能不能用 semantic ranker

官方文件對這件事講了兩種話。以下四段原文皆為 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: freequeryType=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」這句話不能就這樣收尾:

  • Free 方案涵蓋的是每月前 1,000 次 semantic ranker 請求,超過之後請求會回計費錯誤。
  • 想繼續用就得換 standard 方案,而 standard 要 Basic 以上,所以一個 Free tier 的服務沒有原地升級的路

這兩點都來自官方文件——這次 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

這天的誠實邊界

  • 這是機制展示,不是品質量測。25 chunks 的語料上,semantic 的 50 筆候選窗會把整份語料吃進去,所以不可能在這裡自然展示「向量召回失敗」。沒有 nDCG,不宣稱「hybrid 好 X%」。
  • RRF 那 150 筆吻合,是吻合到小數第六位——evidence 記錄的精度就到那裡,不是完整浮點相等。
  • 兩路名次是推論出來的,不是讀出來的。它們來自另外各跑一次的 keyword-only 與 vector-only 查詢,不是 hybrid 的內部 subscore。服務有 debug 參數可以拿 subscore,這次沒用。
  • 一台服務、一個區域、一個日期、一個 API 版本、一種查詢形狀(兩路、預設權重、靜態語料)。這是觀察到的行為,不是 Azure 公布的合約。
  • 這份語料是合成的英文,analyzer 用 en.microsoft。結論不可以外推到臺灣正體中文、多語系、更大或主題更接近的語料——中文分詞與這個 analyzer 的行為完全是另一回事。
  • embed-small 是 deployment 別名,不等於釘住模型版本。同一個別名底下的模型換版,向量就會變,這次的分數也就不可重現。
  • reranker 分數不要切太細。它的分佈會隨基礎設施狀況與模型更新浮動,官方因此建議門檻不要訂得太細。這篇拿它比較兩題,不是在推薦門檻值。
  • latency 數字不能拿來比 tier。semantic 那一路大約多 50–70 ms,這個方向是可信的(多一段模型推論);但單次量測、Free tier、共用基礎設施,不足以推導任何效能結論。第一題的 keyword 是 214 ms,那是冷啟動造成的。
  • Free tier 的文件衝突未解決,本篇不宣稱哪一頁正確;每月 1,000 次額度與超額計費錯誤是文件說法,不是這次實測到的。
  • RRF 的 k我沒有窮舉整份請求 schema。查核當下的請求體裡沒有這個參數(hybridSearch 只有 maxTextRecallSizecountAndFacetModevectorQueries[].k 是鄰居數),所以說它「固定在服務內部」是有依據的推論,不是逐欄位驗證過的斷言。
  • 第三題的預期答案只登記了 SLA 的 Standard tier 那一筆,但 Premium tier 那一筆其實也提到 service credit。凍結之後就沒再動——看到結果才回頭改登記,這張表就沒有意義了。

下一篇

檢索的三段拆開了,四種模式的請求形狀和分數性質也清楚了。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)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 12:切分、Embedding 與 Index Schema——四個改不動的決定,四張不一樣大的帳單
下一篇
Day 14:實作 RAG API——Retrieve → Augment → Generate,以及「我不知道」為什麼不能交給分數或 prompt
系列文
Backend 工程師的 Azure GenAI 實戰14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言