iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI Engineering

從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作系列 第 26 篇

Day 26:【語料庫與檢索品質】不是給她更多知識,是教她「什麼時候不要開口」

  • 分享至 

  • xImage
  •  

前言

前三天我們都在處理「使用者的記憶」,提取、側寫、矛盾偵測、過期淘汰,整套記憶治理(Memory Governance)機制總算建立起來。今天回過頭來看角色自己的腦袋,卻發現一個更根本的問題。

目前的 Lore(角色設定庫)只有 7 筆,而 query_character_lore() 長這樣:

results = lore_collection.query(..., n_results=n_results)
return results['documents'][0]

它永遠會回傳東西。

不管你問什麼,只要庫裡有資料,「最接近的那一筆」就會被塞進 Prompt。所以當你跟莉莉說「我今天好累」,RAG 照樣把「莉莉非常擅長製作各種甜點與手工烘焙」注入 context,於是她會沒頭沒腦地開始跟你推薦烤薄餅。

這不是模型的錯,是檢索層根本沒有「查無資料」這個概念。向量檢索天生回傳的是排序,不是判斷;除非你自己劃一條線,否則它永遠會給你「最像的那個」,哪怕一點都不像。

Day 24 我們其實已經量出了這條線該畫在哪,但一直沒套用:

該命中:0.584 ~ 0.679
不該命中:0.779 起跳
建議門檻:0.72

今天的目標:

  • 擴充世界觀語料:把兩位角色的 lore 從 3–4 筆擴到 9 筆。
  • 跨角色共用語料(Shared Lore):加入 acg_meme 類常識,並解掉它與「角色隔離」機制的正面衝突。
  • 套用檢索門檻:讓 query_character_lore() 學會回傳空清單。
  • 重新量測驗證:語料從 7 筆變 24 筆,舊數字還準嗎?
  • 撰寫 test_day26.py 驗證隔離、共用與門檻。

順序有講究:先擴充語料,再套門檻,最後量測。語料多了距離分佈會變,先套門檻等於用舊數據做決定。

第一關:先擴充語料,門檻等資料齊了再量

現在每個角色 3–4 筆,量測基數太小,任何門檻都是過擬合。先把 seed_lore.py 補到有意義的規模。

莉莉(原創設定):原有 3 筆偏「個人特質」,缺的是世界觀,和姊姊共同經營的關係、老店背景、店裡夥計的由來、帳務設定。

時刻(原創設定):原有 4 筆已涵蓋能力與背景,補的是關係網,守夜者是誰、時裂、時序管理局。

每筆維持既有的寫法原則:一句話一件事、主詞明確、單獨拿出來看也讀得懂。這跟 Day 23 對「事實」的要求完全一致,理由也一樣,RAG 檢索出來的內容是單獨注入的,沒有上下文可以依靠。

擴充後:莉莉 9 筆、時刻 9 筆。不用更多,這是 demo 不是百科。

第二關:共用語料跟角色隔離的正面衝突

這是今天真正的設計題。

現在的隔離是硬的,query_character_lore() 寫死了精準比對:

where={"char_id": char_id}   # 關鍵:只在這個角色的設定裡搜尋

這是 Day 22 刻意做的,不是疏漏,沒有它,時刻會撈到「莉莉非常擅長製作甜點」然後當成自己的設定講出來。

但「傲嬌是什麼意思」「本命是什麼」這類 ACG 共通常識,兩個角色都該知道。而精準比對的 char_id 天生塞不進「不屬於任何人」的資料。三種解法:

https://ithelp.ithome.com.tw/upload/images/20261010/20183877BeXezk5B2B.png

選第三個。Day 25 才花了一整篇的篇幅學到「加欄位要付回填的代價」,能用既有欄位表達的事,就不要開新欄位。

SHARED_CHAR_ID = "_shared"

results = lore_collection.query(
    query_embeddings=_query_ef([query_text]),
    n_results=n_results,
    # 只搜尋這個角色的設定 + 共用語料。
    # 用 $in 而不是精準比對,但仍然撈不到「其他角色」的設定
    where={"char_id": {"$in": [char_id, SHARED_CHAR_ID]}},
    include=["documents", "metadatas", "distances"],
)

add_character_lore() 原本就有的 id 前綴機制(f"{char_id}:{lore_id}")剛好免費支援,共用語料的 id 會是 _shared:meme_1,不會跟任何角色的撞到。

但這帶來一個新風險:共用語料被檢索到之後,角色可能把它當成「自己的設定」講出來。所以 query_character_lore() 的回傳值也得跟著改,從 list[str] 變成 list[dict]:

return [
    {
        "text": doc,
        "char_id": (meta or {}).get("char_id", ""),   # ← 關鍵
        "category": (meta or {}).get("category", "general"),
        "distance": dist,
    }
    ...
]

帶 char_id 回去,呼叫端才分得出「這是你的設定」還是「這是共通常識」。 然後在 rag_builder 分區標示:

own = [l["text"] for l in lores if l["char_id"] != rag_memory.SHARED_CHAR_ID]
shared = [l["text"] for l in lores if l["char_id"] == rag_memory.SHARED_CHAR_ID]

if own:
    context_parts.append(f"【檢索到的角色設定 (Lore)】:\n{...}")

if shared:
    context_parts.append(f"【ACG 共通常識(背景知識,不是你的個人設定)】:\n{...}")

混在同一個標題底下的話,「傲嬌指的是嘴上強硬其實關心對方」會被莉莉當成自己的人設唸出來,那是常識,不是她的設定。

第三關:套上門檻,讓她學會閉嘴

LORE_DISTANCE_MAX = 0.72   # Day 24 量測值,稍後會被推翻

def query_character_lore(query_text, char_id, n_results=2,
                         max_distance=LORE_DISTANCE_MAX) -> list[dict]:
    ...
    return [
        {...}
        for doc, meta, dist in zip(...)
        # 超過門檻的一律丟掉。寧可這輪沒有 lore,
        # 也不要塞一筆不相干的設定讓角色硬扯
        if dist <= max_distance
    ]

這裡有個值得停下來想的地方:這個門檻的取捨方向,跟 Day 25 的去重門檻剛好相反。

https://ithelp.ithome.com.tw/upload/images/20261010/20183877G0Afs1q19H.png

同一個系統裡兩個門檻的哲學不同,因為錯誤的代價不對稱。這不是不一致,這才是正確的設計。

第四關:重新量測,然後舊數字就被推翻了

寫 measure_lore_threshold.py,跑 16 個案例、刻意不套門檻(max_distance=99),看原始距離。案例分四類:

https://ithelp.ithome.com.tw/upload/images/20261010/20183877nyu4XBrYnL.png

第三類最容易被忽略。時刻有時淵之盤所以該命中,但莉莉沒有超能力設定,如果她也回傳了東西,代表門檻太鬆,她會拿「重視家人的羈絆」去硬答一個關於能力的問題。

第一次量測的結果,直接打臉:

HIT    shike  0.748  你的能力是什麼? → 守夜者是唯一能與時之異客締約的人……
MISS   lily    0.720  你的能力是什麼? → 莉莉非常擅長製作各種甜點與手工烘焙……
------------------------------------------------------------------
該命中     0.516 ~ 0.748
不該命中   0.720 ~ 0.899
→ 兩區間重疊,沒有完美門檻

兩個問題同時爆出來:

  1. 該命中的最遠(0.748)比不該命中的最近(0.720)還遠,區間重疊了,沒有任何門檻能同時滿足。
  2. 時刻被問「你的能力是什麼?」,撈到的竟然是守夜者的設定,不是時淵之盤。

第二點才是真正的病灶。把那題的前四名印出來看:

>>> 你的能力是什麼?  [shike]
   0.748  守夜者是唯一能與時之異客締約的人,時刻對他抱持著特殊的興趣。
   0.771  時刻說話時自稱「我」,語氣優雅從容……
   0.771  時刻的時之器物是「時淵之盤」,盤上有延、溯、存三種權能……   ← 正解在第三名
   0.774  聲優是為動畫角色配音的演員……

正解排第三,比不相干的守夜者設定還遠 0.023。

這題調門檻永遠救不了,不管你把線畫在哪,守夜者那筆都排在時淵之盤前面。問題出在語料的措辭:「器物」「權能」「借與還」裡面,沒有任何一個詞跟使用者說的「能力」對得上;而守夜者那筆寫了「與時之異客締約的力量」,反而更近。

今天既然是「語料庫加強」,那就改語料:

# 措辭刻意帶上「能力」二字:量測時發現使用者問「你的能力是什麼?」,
# 這筆的距離是 0.771,反而比不相干的守夜者設定 (0.748) 還遠。
# 門檻調再細也救不了,語料裡沒出現使用者會用的詞,向量就是靠不過去
("lore_1", "時刻的能力來自時之器物「時淵之盤」,共有延、溯、存三種權能,"
           "能向未來預借時間、觀看過去的殘影、把某個瞬間封存起來。", "power"),

改一句話,重灌,再量一次:

該命中     0.516 ~ 0.697
共用該命中 0.362 ~ 0.412
不該命中   0.720 ~ 0.899

該命中的最遠:0.697
不該命中的最近:0.720
→ 兩區間沒有重疊,門檻可取中間值:0.708

0.771 → 0.697。 區間分開了,正解也回到第一名。

那門檻就取中間值 0.708 嗎?不。取 0.70。

因為「不該命中的最近」那筆是 0.720,莉莉被問「你的能力」時撈到的甜點設定。Day 24 建議的舊門檻 0.72 會放行它(0.720 <= 0.72)。門檻要壓在假命中之下,不是壓在中點;中點只是好看,擋不擋得住才是重點。

LORE_DISTANCE_MAX = 0.70

Day 24 建議的 0.72,在 Day 26 的語料規模下正式作廢。量測結論是有保鮮期的,資料變了就得重量。

第五關:test_day26.py

這支測的是 lore,不動 user_memory,所以不需要像 Day 24/25 那樣建測試用 user_id 再清除。

https://ithelp.ithome.com.tw/upload/images/20261010/2018387748Y5TCMnwM.png

案例 2 是今天最重要的回歸測試。 為了支援共用語料,我們把 where 從精準比對改成了 $in,這正是最容易把角色隔離改壞的一刀。改完必須驗回來。

第六關:實測,她真的學會閉嘴了嗎?

【2】時刻問「甜點」→ 不該命中莉莉的設定
  [shike] (無,全部超過門檻 0.7)
  撈到莉莉設定的筆數:0 ← 必須是 0

【3】兩位角色問「傲嬌」→ 都命中同一筆共用語料
  [lily  ] (共用/0.412) 傲嬌指的是嘴上態度強硬冷淡、其實內心關心對方的性格……
  [shike] (共用/0.412) 傲嬌指的是嘴上態度強硬冷淡、其實內心關心對方的性格……
  兩邊撈到同一筆:True ← 語料只存一份,不是複製兩份

【4】「我今天好累」→ 兩位角色都不該有 lore
  [lily  ] (無,全部超過門檻 0.7)
  [shike] (無,全部超過門檻 0.7)

【5】莉莉問「你的能力」→ 空;時刻問同一句 → 命中時淵之盤
  [lily  ] (無,全部超過門檻 0.7)
  [shike] (自己/0.697) 時刻的能力來自時之器物「時淵之盤」,共有延、溯、存三種權能……

案例 5 這組並排看最有意思:同一句話「你的能力是什麼?」,時刻命中 0.697,莉莉回空。 這才是我們要的行為,不是「每個角色都有話說」,而是「有設定的人才開口」。

分區標示也如預期。問莉莉「你的本命偶像是誰?」:

【檢索到的角色設定 (Lore)】:
- 莉莉喜歡帥哥類型的偶像,對於少女漫畫風格的情節容易害羞。

【ACG 共通常識(背景知識,不是你的個人設定)】:
- 本命指的是一個人最喜歡、無可取代的那位角色。

一筆是她的人設,一筆是她該懂但不屬於她的常識,兩者在 Prompt 裡清清楚楚分開。

今日心得

Day 26 表面上是「加資料」,做起來卻是整季少數幾天讓我改變既有結論的一天。

最大的收穫是那個 0.771:我原本以為檢索品質是門檻的問題,量完才發現是語料措辭的問題。 使用者說「能力」,語料寫「器物、權能、借與還」,這中間的落差不是任何一個閾值能彌補的。門檻能做的只有「把明顯不相干的擋掉」,它救不了「相干的東西寫得讓人找不到」。

所以 RAG 的品質其實是兩層:

  • 語料層:你寫的內容,有沒有用上使用者會用的詞?
  • 檢索層:撈出來的東西,夠不夠近到值得注入?

大家調 RAG 時通常只動第二層,換 embedding 模型、調 top-k、加 rerank。但第一層才是上游,上游髒了,下游怎麼濾都是髒的。今天改一句話帶來的改善(0.771 → 0.697),比我調任何參數都有效。

第二個收穫是關於門檻本身:Day 24 量的 0.72,在 Day 26 就過期了。 不是當時量錯,是語料從 7 筆變 24 筆,分佈整個變了。量測結論有保鮮期,把它寫死在腦子裡當常識用,遲早會出事,所以我把量測腳本留在專案裡(measure_lore_threshold.py),下次語料再擴充時直接重跑就好。


上一篇
Day 25:【記憶過期與清理】「那些漸漸模糊的過往,是我為了更深刻記住現在的你。」
下一篇
Day 27:【例外處理與 API 限流】當免費額度撞牆時,該犧牲誰?
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言