換個問法就查不到法源:RAG 最難發現的那種壞
目錄
我們的法規問答有一組回歸測試,十個顧問實際會問的句子,每次改檢索就跑一遍,確認正確的法條有進前六名。它一直是綠的。
某天我把測試腳本的參數對齊線上實際行為,它當場變紅,而且紅的是最高頻的那一題。
追下去才發現:測試綠了好幾週,是因為它一直在測一個比生產寬鬆的世界。
同一個問題,換句話問,答案會反過來
顧問最常問的問題之一是身故保險金要不要課遺產稅。兩種問法:
| 問法 | 保險法 §112 排名 | 前 12 名裡的法條數 |
|---|---|---|
| 身故保險金指定受益人,要不要計入遺產總額? | 第 5 名 | 3 條 |
| 客戶身故後,指定受益人領到的壽險保險金要不要課遺產稅? | 第 21 名 | 0 條 |
第二種問法下,遺產及贈與稅法 §16 更遠,排到第 35 名。
排名掉了不是重點,重點是掉出去之後會發生什麼。我們的檢索有一道保底機制:前六名裡至少要留兩席給法條,不足就從距離最近的前十二名回填。第二種問法下,前十二名裡一條法條都沒有,保底回填撈不到東西。
於是模型看到的六份參考資料,全部是財政部的實質課稅函釋,而那些函釋清一色是「法院認定應課稅」的案例。
在「只能引用工具回傳結果」的鐵律下,這會讓答案方向反過來:把例外講成原則。顧問問「要不要課稅」,拿到一份看起來很有依據、卻只呈現課稅那一面的回答。
為什麼測試沒抓到
我們的回歸腳本檔頭寫得很清楚:「參數若與線上不同步,測出來的就不是線上行為」。寫是寫了,實際上有兩處沒同步。
最要命的是保底回填的範圍。線上是「只從距離最近的前十二名回填,撈不到就寧可少一席」,這條限制是為了防止從很遠的地方硬撈一條不相干的法條進來充數。而測試腳本用的是整個候選池,四十八筆全撈。
所以測試裡,保險法 §112 排第二十一名照樣被撈進來,測試顯示通過。線上前十二名撈不到,顧問拿到反向答案。
同一套檢索邏輯,測試版比生產版寬鬆,於是測試永遠比較樂觀。
第二處是這支腳本的嵌入呼叫沒有網路層重試。本機打外部 API 會間歇失敗,一失敗整組測試在第一題就掛掉,看起來像檢索壞了。這個不會造成假綠燈,但會讓人不想跑測試,效果差不多。
修法:讓長條文不要稀釋自己
診斷出來之後,真正的病因是向量稀釋。
遺產及贈與稅法 §16 是一條有十三款的長條文,涵蓋捐贈、文物、著作權、日常器具、職業工具、森林、保險金、供公眾通行的道路土地等等完全不相干的東西。整條做成一個向量,等於把十三個主題平均掉。問保險金的時候,那個平均值不夠像。
我們把符合條件的長條文額外切出「款層子切片」:每片是序文加上單一款,各自嵌入。母切片保留全文不動,但把用來比對的文字改成「序文加各款標題」,讓它繼續能被「這條在講什麼」命中,卻不再跟子切片搶特定款的查詢。
切完之後,§16 第九款「約定於被繼承人死亡時給付其所指定受益人之人壽保險金額」從第三十五名升到第五名。而且我另外驗過命中的確實是第九款本身,不是碰巧撞到別款。
但有一批長條文我們刻意不切,光是其中一條規則就排除了十八條。
那條規則的判斷條件是「一、」這個款號在條文裡出現幾次。出現超過一次,代表這條有多個項、每項各有一串款,切了款號會編錯。而錯的款號比沒有款號更糟,顧問會照抄。
全庫最長的兩條正好落在這裡:所得稅法 §14 有三千一百多字,「一、」出現六次,它其實是「第一類、第二類」的類結構不是款結構;§17 兩千多字,「一、」出現兩次。最長的兩條反而不能切,這是對的。
不切的規則總共四條,另外三條是:款號編號不連續、序文本身超過三百字(切完每個子片仍然被序文主導,白做)、以及款數不足三款或全文不足五百字。四條規則加起來排除六十七條,真正切的是四十條。
順帶抓到的:評測工具自己在說謊
修完之後跑考題評測驗證沒有回歸,分數掉了 0.6 個百分點。追下去發現一題「切款後檢索變空」。
我以為是切款弄壞了檢索,結果一查,那一題該命中的法條當時排第一名、距離 0.2086,而且那條法規根本不在切款候選內。
真因是評測腳本的嵌入呼叫沒有重試,而呼叫端的 catch 是空的。本機網路一抖,那一題的檢索就靜默降級成「無語料作答」,然後被算進分數裡。
註解甚至寫著「檢索失敗就退化成 baseline,並在結果標記」。標記那半從來沒有實作。
所以那不是產品退步,是我的量測工具在讓產品看起來比較差,而且它每次都給我一個看起來很正常的分數。重跑之後分數回到原值,各科逐科相同。
先花幾分鐘查你自己的檢索
01 把同一個問題換三種問法各問一次
不要只用你寫測試時想到的那句。用顧問實際會打的字、用比較口語的、用比較正式的。看關鍵依據的排名差多少。
# 把同一意圖的多種問法各寫成一題,放進回歸組跑
# (我們的題目是硬寫在腳本的 CASES 陣列裡,不是用旗標傳進去)
node scripts/check-law-retrieval.cjs --verbose
紅旗:換個問法,同一條依據掉出前段,而你從來沒測過第二種問法。
02 把測試腳本的每個參數,逐一對回線上程式碼
不是看註解說有同步,是逐個常數比對:top-k、候選池大小、各種上限、相關性門檻、回填範圍。任何一個比線上寬鬆,你的測試就是在測一個不存在的系統。
# 兩邊的檢索參數擺一起看
grep -nE 'TOP_K|MULTIPLIER|MAX_PER|MIN_|GATE|slice\(0,' api/agent.ts
grep -nE 'TOP_K|MULTIPLIER|MAX_PER|MIN_|GATE|slice\(0,' scripts/check-law-retrieval.cjs
紅旗:測試腳本裡有任何一個數字跟線上不一樣,或者測試用全池、線上用截斷池。
03 檢查你的長條文有沒有在稀釋自己
撈出知識庫裡最長的那些切片,看它們是不是一條涵蓋很多不相干主題。那些就是「問其中一項時查不到」的候選。
# 最長的切片與它們的款數
node -e "…按 text.length 排序,印出前 20 筆與其中『一、』出現次數"
紅旗:最長的切片是多主題的法條或章節,而你的檢索是整條一個向量。
04 你的評測工具失敗時,會不會靜默降級
找出評測腳本裡所有的空 catch。特別是那種「失敗就退化成沒有檢索」的路徑,它會讓你的產品在自己的評測裡看起來比實際差。
# 空 catch,以及有沒有真的統計失敗次數
grep -nE 'catch\s*(\([^)]*\))?\s*\{\s*/\*|catch\s*\{\s*\}' scripts/*.cjs
grep -nE 'retrievalError|failedCalls|degraded' scripts/bench-*.cjs
紅旗:註解寫「會標記」但程式碼裡找不到任何標記;或者失敗數不會出現在報告裡。
我們實際怎麼修的
三件事分開講。
檢索的洞用款層切塊補上了,四十條長條文切出兩百九十三個子片,同時加了一道「同一法條最多佔兩席」的上限,避免一條切成十三片之後自己把前六名洗版。回歸組從十題加到十一題,多的那一題就是「換個問法」的哨兵,常駐盯著它。
至於這個哨兵後來出了什麼事,下一節講。
測試腳本的不同步修了兩處:回填範圍對齊線上、嵌入呼叫補上含網路層例外的重試。
評測工具的靜默降級改成會計數並印出來,而且刻意不從分母剔除。剔掉等於挑掉不利樣本,誠實的做法是留著並揭露「這次有幾題其實是無語料作答」。
補記:那個哨兵本身也是假的
這篇文章寫完、準備發佈前,我讓一組獨立的查核員去 repo 逐條核對文中的每個數字。它抓到一件事。
那道「換個問法」的哨兵題,q 字串跟第一題一模一樣。逐位元組相同,連期待命中的法條清單都相同,只有標籤寫著「換問法哨兵」。
也就是說:回歸組確實從十題變成十一題,但第十一題是第一題的複製貼上。十一題只覆蓋十種問法,那個意圖仍然只被測了一種說法。多花一次嵌入呼叫,零額外覆蓋。
而報表照樣印「11/11 個顧問問法能在前 6 名撈到正確依據」。
腳本自己第 80 行的註解寫著「保留兩種問法各一題:只留一種會再次漏掉『換句話說就壞掉』這類問題」。註解寫對了,程式沒做到。
一篇講「同一意圖只測一種問法等於只測一個角度」的文章,它的招牌修法犯的正是同一個錯。 而且是在我已經知道這個陷阱、已經把它寫成文章主題的情況下犯的。
修法有兩層。第一層是把第一題的 q 換成真正的另一種問法。第二層才是重點:光在註解裡要求「兩題必須不同」擋不住複製貼上,所以加了一道結構性檢查,跑之前先掃一遍題庫,有任何兩題的 q 相同就直接紅掉並指出是哪兩題。
[fatal] 題庫有重複問法,覆蓋率是假的:
- 第 1 題與第 9 題的 q 相同:「客戶身故後,指定受益人領到的壽險保險金要不要課遺產稅?」
CASES 11 題,實際只有 10 種問法。
修完重跑,[題庫] 11 題 / 11 種相異問法,然後 11/11 綠。而且兩種問法命中的法條不一樣:正式問法命中保險法 §112,口語問法命中遺產及贈與稅法 §16。同一個意圖,不同問法,撈回來的依據就是不同的東西,這正是整篇文章要講的事,只是這次的證據來自我們自己的測試出包。
體檢清單
對你自己的檢索跑一遍:
- 同一個意圖,你測過幾種問法
- 你的測試題庫裡,有沒有兩題其實是同一題。有沒有東西會在重複時讓你知道
- 測試腳本的檢索參數,上一次跟線上逐一比對是什麼時候
- 你的知識庫裡最長的切片,涵蓋幾個不相干的主題
- 檢索失敗時,你的評測報告會不會告訴你
- 有沒有一條規則是「撈不到就寧可少一席」,而測試版沒有那條限制
最難發現的壞不是壞掉,是只在你沒測到的那個角度壞掉。庫還在、檢索還跑、分數還在、測試還綠,只是換個問法就沒有法源。
這一章的四句話
- 檢索的壞不一定是查不到,可能是「換個問法就查不到」。同一個意圖只測一種說法,等於只測了一個角度。
- 測試腳本比生產寬鬆,就會永遠給你樂觀的結果。參數要逐個對,不是看註解說有同步。
- 一條涵蓋十三個不相干主題的長條文,整條做成一個向量等於把十三個主題平均掉。切到款層才找得到其中一款。
- 最長的條文反而可能不能切。判斷條件是款號有沒有重複出現,錯的款號比沒有款號更糟,因為顧問會照抄。
- 測試題庫也會壞。我們新增的「換個問法」哨兵是第一題的複製貼上,十一題只覆蓋十種問法,報表照樣印 11/11 綠。註解要求「兩題必須不同」擋不住,最後是加一道結構檢查才擋住。
原始碼位置:切款腳本 scripts/split-long-articles.cjs(四條排除規則:款號重複出現、編號不連續、序文超過 300 字、不足 3 款或不足 500 字;實跑排除 67 條、可切 40 條);回歸測試 scripts/check-law-retrieval.cjs(十一題/十一種相異問法,含「換個問法」哨兵與題庫重複檢查,檢索參數需與 api/agent.ts 的常數逐一對齊);考題評測 scripts/bench-moex.cjs(檢索失敗現在會計數並印出,且刻意不從分母剔除)。
這是《Agentic Design Patterns》× 龍蝦艦隊系列。我們照書把一人公司的 AI agent 艦隊系統化,然後對自己做對抗式稽核。宣稱做到的每一章,都被重新驗證過一次,也把查法和改法整理成你能照著跑的步驟。這個系列的可信度,來自我們願意公開自己打臉自己。