自訂 RAG 解決方案實務範例

實務案例展示量身打造的檢索增強生成系統,如何解決複雜的企業知識問題。

近來 RAG 有時名聲不佳:有人如今認為它極其簡單(入門確實如此,但要擴展就沒那麼容易),也有人認為它已被「代理式系統」超越(但許多代理式系統稍加探究,很快就會發現其實與 RAG 非常相似……)。

本文透過一兩個實作範例,說明我們如何處理以下常見挑戰:

  • 處理文字與數值混合的資料,以及這類資料為何會讓簡易 RAG 失效:關鍵字彼此重疊,數字本身則沒有語意。

  • 為何採用摘要優先的嵌入設計會有幫助:先為每個區塊產生簡短的描述性摘要,再以該摘要進行嵌入與查詢。

  • 如何產生具上下文的摘要:納入上層文件的上下文,避免形式相似的統計資料混淆。

  • 何時應依賴程式碼與 Pydantic 模型:若逐字保留內容至關重要,可將自訂程式碼及/或 Pydantic 模型與 LLM 呼叫搭配使用,以確保可靠性。

建構自訂 RAG 解決方案

基本原理

從客服機器人到內部知識助理,各種應用背後都有 RAG 系統提供支援。

在底層,通常會執行以下步驟:

  1. 將來源文件切分成區塊

  2. 將每個區塊嵌入向量空間

  3. 查詢時檢索排名前 K 的區塊

  4. 以這些區塊為依據生成答案

LangChain、LlamaIndex、OpenAI Filestore 等熱門工具組,讓這些步驟幾乎變得輕而易舉。但在實際的資料管線中,遇到的資料不會只有密集文字,而基礎 RAG 往往難以應付。接下來,我們會以具體範例說明資料方面的挑戰,並隨著複雜度增加,逐步建構解決方案。

情況變得更棘手時

  1. 當資料不只是文字時(其實很常見)

請看看以下遊戲情境中的資料區塊:

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

嵌入之所以有效,是因為模型透過語意和文法,學會了詞語之間的關係。上述資料混合了文字與數字;一旦脫離這個特定情境,數字與文字之間便毫無關聯。因此,可以說這個資料區塊基本上只是一些略具描述性的詞語,後面接著若干隨機數字。

如果我們只有這類資料,其實不成問題,因為仍可利用少數描述性詞語的嵌入進行檢索(或者直接使用 text-to-SQL)。但如果這個區塊埋在大量文字密集的區塊中,而且那些區塊也出現相同詞語呢?例如:

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

現在,假設我們想檢索「啟用 Draconic Ascension 時的攻擊範圍是多少?」我們很可能無法找出所需的相關區塊,因為它淹沒在其他含有相同關鍵字的區塊雜訊中。

根本問題在於:即使這些資料區塊針對同一主題提供不同類型的資訊,我們也無法有效區分它們。我們能否以某種方式豐富或強化這些資料?當然可以:smile:

  1. 用摘要來豐富資料,沒錯,你沒看錯

我們不直接嵌入區塊本身,而是先產生一段摘要來描述資料內容,再根據摘要進行嵌入與檢索。到了生成步驟,我們仍會使用與摘要連結的原始資料。

因此,針對上面的兩個區塊範例,我們可以產生如下摘要:

  1. 攻擊範圍、速度與傷害的統計資料(預設狀態及啟用 Draconic Ascension 時)。

  2. Draconic Ascension 的說明與詳細資料,包括啟用條件、視覺效果及背景故事。

接著,我們也會擴充查詢,使其與摘要「對齊」。例如,我們會把「啟用 Draconic Ascension 時的攻擊範圍是多少?」改成「啟用 Draconic Ascension 時,攻擊範圍的統計資料是什麼?」當檢索查詢來自非技術領域的使用者,且他們以~~「自由發揮」的~~一般自然語言提問時,這點尤其重要。畢竟,他們不會知道或在意 RAG 如何運作,才能將精確率/召回率提升至最高。

說明情況何時會變得更棘手的圖表。

  1. 別斷章取義(人生通常也適用)

接下來的情況,是要處理大量外觀相同的資料區塊,如下所示:

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

如果沿用相同方法,想像一下我們提出:「角色 X 的攻擊範圍是多少?」由於剛才產生的摘要也非常相似,我們只能靠運氣猜測。那麼,要如何區分它們?

答案很簡單:提供上下文。我們可以直接在資料區塊中加入其上層文件的參照;以本例而言,就是 {”角色”: “X”}。如此一來,即使也有角色 Y 和 Z 的相同資料,我們仍能準確檢索角色 X 的正確資料。

不過,更好且更具通用性的方法,是為區塊產生具上下文的摘要。也就是說,我們不只為資料區塊本身產生摘要,而是同時傳入其上層文件和區塊,以產生整體上下文摘要;摘要會說明這個區塊在上層文件中的作用。例如:

  1. 此區塊提供角色 X 的……詳細統計資料。此區塊藉由呈現 X 在攻擊速度方面的優勢……融入完整文件。

  2. 此區塊提供角色 Y 的……詳細統計資料。此區塊藉由呈現 Y 使用特殊能力後提升的屬性……融入完整文件。

  3. 此區塊提供角色 Z 的……詳細統計資料。此區塊藉由呈現 Z 十分適合在團隊對戰中擔任坦克的屬性……融入完整文件。

這種方法(部分靈感來自 Anthropic)用於上述範例或許顯得小題大作,但對於可能因「脫離上下文」而被誤解的區塊非常有效。此外,它還提供一套適用於所有區塊的統一方法,讓工程管線保持簡潔。

說明情況何時會變得更棘手的圖表。

  1. 需要~~控制一切~~嚴謹處理時

通常,我們取得的是完整資料,再將其切分為區塊供 RAG 系統使用。這個範例略有不同:資料雖然已切分成區塊,但切得很差。這些區塊只是從邏輯區塊中隨機截取的片段,實際上必須重新合併。「邏輯區塊」是指本來就應放在一起的一段內容,例如文件的小節或語意連貫的段落。

說明情況何時會變得更棘手的圖表。

第一次嘗試處理這些資料時,我們將所有內容放進一次 LLM 呼叫,要求它自行判斷如何分組,再傳回分組後的內容。LLM 應該很擅長這件事,對吧?嗯,對,也不對。

我們在其他幾次經驗中也發現,當你要求完整且精確的內容時,LLM 往往會偷懶,而且不太可靠,尤其是在上下文很長的情況下。這完全可以理解。但對這個特定使用情境來說,這是致命缺陷,因為我們確實需要逐字保留精確內容:不能摘要,也不能略過原始內容的任何部分。任何細節都不能遺漏。

至於「對」的部分,則是它確實非常擅長理解零碎區塊的語意與結構。前提是它不會拒絕逐字引述原文。真是的:/

那麼,我們要如何善用 LLM 的長處,同時避開它不可靠的部分?我們轉而求助老朋友:程式碼(其實就是自訂 Python 函式)。 以及一個「簡單到不能再簡單」的 Pydantic 模型。解決方案如下:

  • 逐一處理各個段落,同時維護目前的邏輯區塊

  • 處理每個段落時,都詢問 LLM:這個段落是否屬於目前的邏輯區塊?依照 Pydantic 模型回答「是」或「否」。

  • 若是,就把該段落附加至區塊;若否,則輸出已完成的目前邏輯區塊,再以該段落建立新區塊。

說明情況何時會變得更棘手的圖表。

當然,這種方法使用的權杖比一次處理完整內容稍多,但在這個以精確保留內容為首要目標的特定使用情境中,這筆(小額)額外成本非常值得。

這個解決方案非常簡單,卻遵循了一項重要原則:需要嚴謹處理時,不應完全依賴 LLM,畢竟它們本質上是機率式系統。

透過自訂程式碼/函式與 Pydantic 模型,我們既能充分發揮 LLM 的能力,也能獲得可預測且可靠的結果。

總結

建構生成式 AI 解決方案,既是 AI 挑戰,也是工程挑戰。希望這些範例能啟發你解決自己獨特的挑戰。若想進一步瞭解工程優先的生成式 AI 解決方案,請參閱我們介紹路由器式代理系統設計的文章。

作者

Cynthia Yu