把資料轉成向量,存進資料庫,再拿使用者的問題搜尋最接近的幾段文字,看起來就能把 RAG 的檢索部分接起來。
但在整理「從心出發」的檢索設計時,我更在意另一個問題:如果排名第一的內容已經被撤回,系統還會把它交出去嗎?
這個問題讓我重新看待向量資料庫的責任。相似度可以協助排序,卻無法單獨回答一筆資料現在是否仍然有效、是否屬於這次查詢允許的範圍,以及它能不能進入後續流程。
第八天,我沿著原先的系列規劃,整理向量資料庫與檢索抽象層的設計。現有專案裡可以看到後端介面與記憶體參考實作;本文討論的是這些契約如何拆分責任,不把目前的程式回填成第八天當日完成的開發紀錄,也不宣稱正式向量服務已經接通。
最先需要拆開的,是「相似」與「可用」。
假設有兩份合成文件,內容非常接近,其中一份已經被新版取代。使用相同問題搜尋時,舊版仍可能取得很高的分數。如果系統只拿前幾名結果,版本治理就會在檢索這一步消失。
在這個例子裡,搜尋演算法未必算錯。它可能非常忠實地找到了相近文字。真正漏掉的是:排序之前,系統沒有先排除已失去使用資格的資料。
因此,我希望檢索流程先決定哪些資料可以參與,再從這個範圍裡挑選候選。來源版本、撤回狀態與資料所屬範圍,都應該參與這個判斷。即使某段內容最後被取回,後續仍需要檢查它是否符合目前的用途。
這也決定了相似度分數的位置。它只是排序訊號,不能被解讀為答案正確的機率,更不能成為心理支持內容適用或安全的證明。
接著才是另一個常見問題:為什麼要多寫一層 retrieval abstraction,也就是檢索抽象層?
最直覺的理由是方便更換資料庫。但對我而言,更重要的是讓上層流程知道,每個後端都必須遵守哪些行為。
如果對話流程直接依賴某個資料庫回傳的格式,來源資訊要從哪裡讀、版本怎麼判斷、服務出錯會發生什麼事,都容易散落在不同呼叫點。日後替換後端時,變動的就不只是連線方式,還可能連失敗時的行為一起改掉。
現有的 VectorBackend 把查詢、寫入、刪除、失效與撤回等操作放進共同契約。這個安排提醒我,檢索系統要處理資料的一生,不能只設計「存進去」和「找出來」。
不過,介面名稱相同,還不足以證明兩個後端可以互換。真正需要一致的是:資料撤回後的查詢是否會排除它?寫入失敗會不會留下半套內容?同一份資料重複送入時,是保持原狀,還是意外增加另一份?
這些行為才是抽象層需要保護的部分。
撤回尤其容易被低估。
我一開始很容易把它理解成刪除:把向量刪掉,事情就結束了。但現有參考設計把「禁止再被取回」與「移除資料內容」分開處理。
它會保留撤回標記,讓後續查詢先排除對應資料。即使向量位元仍然存在,也不能再參與正常結果輸出。刪除內容之後,拒絕標記仍然保留,避免同一筆資料被重新匯入時又恢復可用。
這種標記常被稱為 tombstone。對這裡的設計來說,它保存的是一個不能隨著內容刪除而遺失的決定:這筆資料已經不能再被使用。
代價是系統需要管理更多狀態,也需要面對標記的容量限制。但若為了省空間而悄悄丟掉撤回紀錄,先前的決定就可能失效。
時間邊界也必須說清楚。撤回完成後開始的讀取,應該遵守新的拒絕狀態;已經在撤回之前送出去的內容,無法假裝從未交付。現在的記憶體參考實作可以用來討論單一實例內的順序,卻不能據此宣稱跨程序或分散式服務也具有相同行為。
檢索失敗則讓我看見另一種容易混淆的空白。
「查詢成功,但是沒有符合條件的資料」與「後端無法完成查詢」,最後都可能沒有文字可以提供。但前者表示這次搜尋沒有候選,後者表示系統根本不知道結果。
如果上層把兩者都當成「沒有相關知識」,故障就會被包裝成知識上的結論。更糟的是,為了讓對話繼續,流程可能要求模型自行補出原本應該由來源支持的內容。
目前的契約文件對不可用或格式不合法的向量結果採取不釋出知識的處理,並把非向量核心流程與這個可選能力分開。這讓我可以更精確地描述降級:流程是否繼續,要由既有規則決定;缺少的檢索依據,仍然是缺少。
至於真正的資料庫連線、正式 embedding 與外部後端故障行為,本日尚未完成此部分,以下先整理目前設計與下一步。
接下來若要接上外部服務,需要補上的證據會很具體:逾時發生時,遠端是否可能已完成寫入?撤回狀態能否在重新啟動後保留?不同資料範圍是否確實隔離?服務回傳的欄位是否足以讓上層重新檢查來源與資格?
記憶體裡可以一次完成的操作,到了網路另一端,可能留下「不知道到底有沒有成功」的狀態。這時候直接重試,未必能恢復一致。
寫到第八天,我對檢索層的期待逐漸變得具體:它應該找出相近內容,也應該保留來源、版本、使用資格與失敗狀態,讓後面的流程有足夠資訊做判斷。
而下一個問題已經藏在資料切塊的那一刻:當一份文件被拆成許多片段,每個片段要帶著哪些資訊,才不會在搜尋時失去原文的條件與限制?