iT邦幫忙

2026 iThome 鐵人賽

0
AI Engineering

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

Day 33:找不到、找錯、答太滿、引用錯——決定處置落點的不是嚴重度,是可判定性

  • 分享至 

  • xImage
  •  

RAG 上線之後最難處理的失敗都回 HTTP 200:找錯、答太滿、引用錯,三種都帶著一段看起來很像樣的答案。第四種「找不到」只有一半長這樣——零檢索命中會結構性地短路成 no_answer,連答案文字都沒有。這四個名字你大概都叫得出來,但「各自該怎麼處置」多半停在兩個籠統答案:加 evaluation,或者改 prompt。這篇給一個能真的分派責任的判準:先問這一半 server 端判定得了嗎,判定得了就一路往下壓成結構,判定不了就別讓它假裝有保證。

這是續發文。主線第三十天那篇公開分級過:RAG 這題「不是整片空白」,空白的是最後要下的那個判斷——四種失敗各自的處置。這篇補上那個判斷,順手把 Day 14 留下的一個誠實缺口封掉可判定的那一半。那個變更不是本篇的附錄,是判準的示範:判定得了就壓成結構,判定不了就不要假裝。

一、四種失敗,先各自切開

四分法談的是現象——使用者看到的樣子。可判定性談的是責任歸屬——這件事誰有資格說它為真。兩者不是同一張表,所以照這條軸切下去,形狀會是不規則的。

判定得了的,一路往下壓成結構:短路、filter、後處理。它們在 request 生命週期內就結案,不需要人、不需要模型、不需要事後跑批次。

判定不了的不會全部往同一個地方去。有些有明確的 evaluation 接手:檢索側需要預先登錄的相關性標註加上 live Search,生成側是 Day 28 那套評分。有些根本沒有人接——逐條引用支援度在本 repo 完全沒有實作,那一格是空的。「需要語料、需要標註」是前者的性質,不是「判定不了」自動帶來的性質。

夾在中間的 prompt 規則只是宣告——它降低機率,不產生保證。

所以處置落點總共三種,不是兩種:壓成結構交給一個叫得出名字的 evaluation明確沒有歸屬。第三種最需要講出來,因為把它說成「交給 evaluation」不用花任何力氣,聽起來也很像有在處理。

失敗 可判定的部分 處置落點 判定不了的部分 處置落點
找不到 零檢索命中 結構性短路:LLM 之前直接 no_answer 正確證據根本不在 hits 裡 檢索側 evaluation:需相關性標註 + live Search
找不到 同上 同上 證據在 hits 裡,但答案沒寫出來 生成側 judgedmissing_fact_ids
找錯 跨租戶/越權的來源 查詢形狀:ACL filter 排序不夠好 檢索側 evaluation:同上
答太滿 (無) 斷言了來源沒有的東西 prompt 宣告 + 生成側 judgedforbidden_facts
引用錯 引用號碼超出 1..N 結構性後處理:剝除標記、回傳計數 號碼合法但不支持該句 未實作

RAG 四種失敗的可判定性分層圖。左側一欄淺黃色底標「四種失敗(現象)」,由上而下四格:找不到、找錯、答太滿、引用錯。每一格向右分出箭頭,指向中欄的「可判定」與「判定不了」節點;可判定的節點畫成深藍實底,判定不了的畫成淺紫。找不到分出三條線:一條到深藍的「可判定:零檢索命中」,兩條到淺紫的「判定不了:正確證據不在 hits 裡」與「判定不了:證據在 hits 裡,答案沒寫出來」——這一格是全圖唯一分出兩個判定不了節點的失敗。找錯分出兩條:深藍的「可判定:跨租戶/越權的來源」與淺紫的「判定不了:排序不夠好」。答太滿只分出一條,指向淺紫的「判定不了:斷言了來源沒有的東西」,它沒有任何深藍節點,是全圖唯一可判定側為空的一格。引用錯分出兩條:深藍的「可判定:引用號碼超出 1..N」與淺紫的「判定不了:號碼合法但不支持該句」。中欄的節點再往右連到最右欄的處置落點。三個深藍節點各自連到一個深藍落點:零檢索命中連到「結構:LLM 之前直接 no_answer」,跨租戶來源連到「查詢形狀:ACL filter」,引用號碼超範圍連到「結構性後處理:剝除標記、回傳兩個計數」。判定不了的節點連向淺色落點:「正確證據不在 hits 裡」與「排序不夠好」兩條線匯到同一個淺藍落點「檢索側 evaluation:需相關性標註 + live Search,有載具、無現值」;「證據在 hits 裡,答案沒寫出來」連到淺藍的「生成側 judged:missing_fact_ids」;「斷言了來源沒有的東西」分出兩條,一條到米色的「prompt 宣告(只降低機率)」,一條到淺藍的「生成側 judged:forbidden_facts」。最後「號碼合法但不支持該句」連到一個粉底虛線框的落點,框裡寫「(空的)本 repo 未實作逐條引用支援度驗證」——全圖唯一畫成虛線的落點,表示那裡沒有東西。

先看這張表的形狀,不要看內容。「找不到」的判定不了那側自己還會再裂成兩件事;「答太滿」的可判定側是空的;「引用錯」的兩半,一半在後處理、一半根本沒有落點。所以不要記「每一種各切成兩半」這種整齊的口訣——它對「答太滿」是假的,對「找不到」是不足的。責任歸屬本來就不會剛好對稱。

「evaluation」這個詞在下面會出現兩種,不能混為一談,這是把軸講一致的關鍵。檢索側判斷「這份文件本來該不該進 top-K」,判準只能來自預先登錄的相關性標註。生成側判斷「答案有沒有斷言來源沒有的東西」,判準是評分用的事實清單。兩者要的東西不同,缺的東西也不同。

二、找不到:量測否決了直覺解法

可判定的那一半很乾脆:retrieval 回來零筆命中,就直接回 no_answer,一次 LLM 都不呼叫。判準是「hits 是不是空的」,一個 server 自己數得出來的整數(services/rag.py)。

if not retrieved.hits:
    _log_rag_stage(question, retrieved.hits, context="", dropped_source_count=0)
    # No provider call happens on this path (structural
    # short-circuit), so there is no generation duration to
    # report -- omitted rather than logged as a fake 0ms.

為什麼不是分數閾值?直覺解法是「分數低於某個線就當作找不到」,這條路在 Day 13 被實測擋掉了:25 chunks 的語料,語料裡根本沒有答案的那題,hybrid RRF 最高分是 0.032796;有正確答案的那題是 0.032787,還更低。

但這句話要講細,講粗就會變成假的。Day 13 那次否證掉的只有 hybrid 的 RRF @search.score,不是所有分數——同兩題的 reranker 分數分別是 2.113 與 1.104,差了將近一倍,它確實分得出來。那為什麼還是沒有閾值可用?

因為 lab 的 /rag 走死 hybrid(services/retrieval.py 的檢索呼叫把 mode 寫成 SearchMode.HYBRID,不是設定值),而 hybrid 模式不會帶回 reranker 分數。所以分得出來的那個分數在這條路徑上根本不存在,存在的那個分不出來。兩件事合起來,才是「沒有閾值可以 gate」的完整理由。

判定不了的那一側,必須拆成兩件事,混講就會把責任推錯層:

  • 正確證據根本不在 hits 裡:這是檢索側。要判定它,你得先有一份「這題的正解是哪幾個 chunk」的標註,然後跑凍結查詢集去看那些 chunk 的 rank 或缺席。lab 有這個載具(tools/compare_retrieval.py),但它需要 live Search 服務,而那個服務目前是拆掉的——有載具、無現值
  • 證據在 hits 裡,但答案沒把它寫出來:這是生成側。Day 28 的 judged 層把每題的 expected_facts 丟給評分模型,沒被涵蓋的落在 missing_fact_ids

這兩件事的判準不同,去處也不同。而且 missing_fact_ids 有一條界線要一起講:它只證明「答案沒涵蓋這個 expected fact」,證明不了「這個 fact 不在 retrieved sources 裡」。評分模型拿到的是答案加整組 sources,它判的是覆蓋,不是檢索。所以它接得住後者,接不住前者。

三、找錯:授權是查詢的形狀,排序不是

「找錯」也切成兩半,而且兩半落在相隔很遠的層。

可判定的那一半是授權。跨租戶或越權的來源,server 完全判定得了,而且它壓得比「判定」還要下面一層——壓進查詢的形狀。lab 的檢索器只能送出帶 ACL filter 的查詢,送不出裸查詢;不可見的文件在 index 那一層就被濾掉了。

副作用是刻意的:跨租戶的提問回來的樣子,跟語料裡真的沒有答案一模一樣——status: "no_answer",沒有任何「你問了但被拒絕」的訊號。index 本來就分不出「沒有東西命中」與「有東西命中但你不能看」,這條 pipeline 也不打算事後幫它編一個出來。

判定不了的那一半是排序:該進 top-K 的沒進。這一半 server 判定不了,理由和上一節同一條——判準必須來自預先登錄的相關性標註。

per-call 觀測不是判準,這是最容易犯的一種誤植。Day 13 之後每次檢索都會 log 一行,記下 mode、候選窗、回傳的 chunk ids 與分數。那行 log 很有用,但它是輸入證據,不是判定依據——production 的觀測沒有相關性標註,它看得到某份文件排第幾,判定不了它本來該排第幾。把觀測當成排序失敗的處置,就是本篇正在批評的那個動作換了個樣子。

至於混合檢索、向量檢索、semantic ranker 怎麼選,那是下一篇的事。本篇只主張一件事:排序這一半的判準必須來自標註,不能來自觀測。

四、答太滿:全表唯一沒有可判定側的一格

「答太滿」指的是模型斷言了來源裡沒有的東西。這一格特別,因為它的可判定側是空的——server 端沒有任何一個位置能獨立說「這句話越過來源了」。

所以這一格的處置就兩層。第一層是 prompt 的宣告,lab 的 rag_answer 模板第 1 條與第 3 條:

1. Base every claim on the sources. Do not use outside knowledge, even when you are confident.
3. If the sources do not contain enough information to answer, say so plainly and do not guess. Do not fabricate citations.

這兩條是有價值的,但要看清楚它們是什麼:宣告,不是強制。它降低機率,不產生保證,而且沒有任何機制在 request 內檢查模型有沒有照做。

第二層才是判準所在:Day 28 的 judged 層,用每題的 forbidden_facts 讓評分模型指認答案真的斷言了哪幾條。那一層的評分結構性地進不了 exit code——deterministic 與 judged 是兩個獨立的 JSON key,計算退出碼的函式只吃 deterministic 那一份,沒有任何參數能讓 judged 的結果走進去。

這是設計上的選擇。判定不了的事情本來就不該去 gate CI,硬掛上去只會得到一條會隨模型心情變紅的 pipeline。

五、引用錯:一半壓成結構,另一半沒有人接

「引用錯」也是兩半,落差比前面幾格都大。

號碼超出範圍那一半,server 判定得了,而且已經在跑:_validate_citations() 逐一檢查答案裡每個 [n] 是否落在 1..N(N 是實際送進 context 的來源數,budget 淘汰之後的那個數),越界的直接剝掉,記一行只有數字的 log,不改 status、不讓 request 失敗。函式自己的 docstring 把理由寫死了:模型編出來的引用號是生成品質缺口,不是 request 失敗

另一半——號碼合法,但那個來源根本不支持它所附的那句話——本 repo 沒有實作任何判準。這不是「沒找到所以推測沒有」,是有正面證據的:Day 28 的評分模型拿到的是答案加整組 sources,回傳的是 fact id 集合;四條解析 invariant 守的是 id 集合完整性;唯一貼近的 unsupported_claims 是自由文字,只被檢查是不是字串陣列。沒有任何欄位把某個 [n] 對上第 n 個來源。

所以這一格在圖上是空的,畫成虛線框。我特別不想把它寫成「交給 evaluation」——那會暗示有個現成的去處在等著,而事實是沒有。

六、補上那一格:判定的位置早就在那裡

Day 14 留下一個明寫在文件裡的誠實缺口:模型層級的婉拒(「來源沒有涵蓋這題」)仍然回 status: "answered",因為從 pipeline 的角度看,婉拒也是一次成功的生成。文件當時的結論是:呼叫方得自己讀 answersources,不能只看 status

先講一件更重要的事:這個缺口不會被別的東西接走。lab 的 roadmap 裡 refusalno_answerstatus 三個字一次都沒出現;riding debt 清單沒有這一項;主線三十天已經結束;續發清單剩下的題目都不動 /rag 的 status 契約。所以「先只改文件、之後再補」在這個專案裡沒有對應的機制——不補就是永久不補。這是本篇動程式碼而不是只動文件的理由。

缺口的形狀很值得看:判定的位置早就存在_validate_citations() 一直站在那裡,逐一檢查每個號碼的範圍。它算得出「剝了幾次」,但那個數字只進了 log;「答案裡還剩幾個相異的合法號碼」則根本沒有算過。回傳型別是 str,兩個數字呼叫方一個也拿不到。

這次補上兩個 response 欄位,status 不動(合約寫在 docs/api-conventions.md):

欄位 語意(純語法層)
cited_source_count 清理後的答案文字中,落在 1..N 的相異 [n] 號碼個數
stripped_citation_count 從答案中剝除掉的標記次數(出現次數,非相異)

兩個欄位的語意刻意不對稱:前者是集合基數,後者是編輯事件計數。它們是兩條獨立的軸,合起來解析出四種狀態:

cited stripped 這個答案發生了什麼
0 0 完全沒有引用標記
0 >0 有標記,但號碼全部非法,全被剝光
>0 0 標記全部合法
>0 >0 有合法的也有被剝掉的

要兩個欄位,是因為任一個單獨都會把方陣壓成一維:只看 cited_source_count,「沒有標記」和「標記全非法」長得一樣;只看 stripped_citation_count,「沒有標記」和「標記全合法」長得一樣。

實作時踩到一個不能對調的順序約束,這個實跑的例子就是原因:

_validate_citations('[1[99]]', 1)
->  ValidatedCitations(answer='[1]', cited_source_count=1, stripped_citation_count=1)

正規式 \[(\d+)\] 匹配到的是內層的 [99];剝掉它之後,殘留的 [1] 拼成了一個 [1]——這個合法引用從未出現在剝除 callback 看得到的匹配裡。如果順手在 callback 裡收集合法號碼,這個例子會回報 0,跟最終回傳的文字互相矛盾。

所以計算順序不能對調:先剝除產生乾淨答案,再對乾淨答案重新掃描一次。這條規則在測試裡有一條專屬的迴歸案例守著。

https://ithelp.ithome.com.tw/upload/images/20260902/20168288OcOqdjiCv9.png
忍喵:一個「刪除」動作,把兩段殘骸拼成了一個合法引用。替換過的文字要重新掃一次才算數:新文字裡的匹配,不保證出現在舊文字那份匹配清單上——所以在 callback 裡數出來的,數的是舊世界。你手上有幾個 sub() 是一邊改字串、一邊順手統計的?

為什麼不是改 status

兩個被否決的做法,正好是本篇軸線的反面教材。

新增一個 status: "ungrounded":這是破壞性契約變更,所有 client 都要處理新值。但更嚴重的問題是它把「零有效引用」硬等同於「模型拒答」。零有效引用不是一種情況:號碼全非法被剝光那一種,stripped_citation_count > 0 分得出來;真正分不出來的是「模型婉拒」與「模型答了但沒引用」,兩者都落在 (0, 0)。把一個 server 分得出一部分、分不出另一部分的東西,包成一個看起來很確定的狀態值,正是這條軸在批評的動作。

https://ithelp.ithome.com.tw/upload/images/20260902/20168288ZPx2wPSA6N.png
忍喵:「分不出來就加一個新狀態」是最省事的下一步,代價是每個 client 從此都要處理它,而它背後只是一次猜測。要加狀態之前先問一句:這個值代表的事實,server 自己驗得出來嗎?驗不出來的,別給它一個看起來很確定的名字。

零引用降級成 no_answer:同上,再加一項會撞既有裁決。Day 22 的 audit log 把 rag.query 的「有沒有真的呼叫 provider」由 status 導出,no_answer 一律是 false;但這條路徑真的打過 LLM。改 status 會讓 audit 說謊。

所以做法是 additive:answered 的回應多兩個欄位,no_answer 那條分支兩個都是 null——不是 0。那條分支沒有答案文字讓計數去描述,填 0 會被讀成「有答案但沒引用」,等於自己製造本篇一路在拆的那種混淆。

這天的誠實邊界

  • cited_source_count > 0 不代表引用是對的。它是字串比對的結果,合法號碼仍然可能完全不支持它所附的那句話,而那一半在本 repo 未實作任何判準。
  • cited_source_count == 0 不代表模型拒答。要配 stripped_citation_count 一起讀:(0, >0) 是號碼全非法,分得出來;只有 (0, 0) 那一格分不出婉拒與「答了但沒引用」。分得出來的分出來,分不出來的誠實交給呼叫方,不替它猜。
  • 本 repo 沒有量過真實 recall。eval 跑在 seeded 的假檢索上,那個 fake 各模式都以詞彙計分,它的 docstring 自陳「Retrieval quality observed here means nothing」;要量真實 recall 需要現已拆除的 live Search 服務。
  • Day 13 那組分數觀測是 n=2 兩題的單邊觀測,不導出任何比率,也不能擴大成「檢索分數都沒有鑑別力」。
  • 可判定性不是普遍定律。它是這個 repo 這四種失敗的處置落點所呈現的一致模式,n=1。你的系統可能在別的地方切開。

下一篇

本篇兩次把排序那一半推給「需要相關性標註」,卻沒有談檢索模式本身怎麼選。下一篇回到那個問題:hybrid、vector、semantic ranker 的取捨在什麼負載下會翻轉,以及要量它需要先準備什麼。


本篇無新增雲端資源、零成本:契約變更、測試與所有 probe 都在本機跑完,沒有發出任何一次雲端呼叫。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 32:同一個 RAG 端點的兩個問題:哪些行為進 feature 檔,哪些進 eval dataset
下一篇
Day 34:hybrid、vector、semantic ranker 怎麼選——先問答案被哪條腿弄丟,再問哪個模式排得準
系列文
Backend 工程師的 Azure GenAI 實戰35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言