AI AgentRAG知識檢索回歸測試BuildInPublic

換個問法就查不到法源:RAG 最難發現的那種壞

· 13 分鐘閱讀
龍蝦艦隊 · 逐章稽核 · 第 24 / 24 篇
目錄
  1. 同一個問題,換句話問,答案會反過來
  2. 為什麼測試沒抓到
  3. 修法:讓長條文不要稀釋自己
  4. 順帶抓到的:評測工具自己在說謊
  5. 先花幾分鐘查你自己的檢索
  6. 我們實際怎麼修的
  7. 補記:那個哨兵本身也是假的
  8. 體檢清單
  9. 這一章的四句話

我們的法規問答有一組回歸測試,十個顧問實際會問的句子,每次改檢索就跑一遍,確認正確的法條有進前六名。它一直是綠的。

某天我把測試腳本的參數對齊線上實際行為,它當場變紅,而且紅的是最高頻的那一題。

追下去才發現:測試綠了好幾週,是因為它一直在測一個比生產寬鬆的世界。

同一個問題,換句話問,答案會反過來

顧問最常問的問題之一是身故保險金要不要課遺產稅。兩種問法:

問法 保險法 §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 艦隊系統化,然後對自己做對抗式稽核。宣稱做到的每一章,都被重新驗證過一次,也把查法和改法整理成你能照著跑的步驟。這個系列的可信度,來自我們願意公開自己打臉自己。

龍蝦艦隊 · 逐章稽核 · 第 24 / 24 篇

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

需要技術協助?

免費諮詢,24 小時內回覆。