Skip to main content

RAG 專案:Ask about the site 功能與部署

·3 mins

專案背景 #

Retrieval Augmented GenerationRAG)是 LLM 常見的應用之一,其基本概念很簡單:在 LLM 回答問題之前,先從文件庫(資料來源)中搜尋、取得和問題相關的文件,再將文件內容作為脈絡的一部分交給 LLM 用於產生回答。

在完成 Learn Retrieval Augmented Generation 的實作後不久,我決定要將 RAG 方法應用於個人網頁的開放式問答。

個人過往的工作經驗是以資料分析、處理以及機器學習模型的開發為主,但也一直想要對機器學習應用的端到端流程能有更完整的理解。在這個專案裡我從頭到尾完成了後端功能的開發及部署上線,達成了一部分目標。

專案成果 #

  • ✅️ 開發 RAG Server API:RAG Server
  • ✅️ 包裝成 Docker 並部署於 Google Cloud Run
  • ✅️ 建立開放式問答頁面:Ask about the site 截圖如下

Ask about the site page screenshot
問答頁面截圖

討論 #

需求與功能 #

這次的開發工作大概分為幾個面向:

  1. 重新架構:定義應用程式架構並重構 RAG 功能
  2. 功能優化:改寫 keyword search 支援中文斷詞
  3. 後端功能:以 FastAPI 建立 API 服務
  4. 部署上線:打包為 Docker 並部署於 Google Cloud Run
  5. 前端呈現:建立問答頁面的 Hugo 樣板
  6. 安全考量:使用 API Gateway 作為公開端點並限制 API 的呼叫頻率及使用者輸入字數上限

目前的版本已達成了專案設定的「個人網頁問答」目標,主要的遺珠大概是 Chunking 和 Conversation History 核心功能尚未實作進去。由於總共文章數量只有數十篇,就算一次全部餵給 LLM 大概也行,但採用 RAG 的好處是不用擔心 context 過大帶來的負擔。

至於採用 Cloud Run 部署則是考量其經濟性,沒有使用時可以自動降到零,省錢的代價則是犧牲了使用者體驗,由於冷啟動會需要一點時間,因此使用者一開始會遭遇到請求逾時的失敗,這對一般應用程式來說是不可接受的,因此真正的應用程式後端應該至少會維持一個啟動好的服務。

進階考量 #

在專案一開始我曾構想了比較完整的功能,例如説讓使用者登入以管理 token 使用量;使用關聯式資料庫來儲存對話歷史;使用向量資料庫來取代目前全部讀進記憶體的做法等等,不過這些對我目前的用例來說大概都是殺雞用牛刀,因此選擇保持簡單。

心得 #

RAG #

  • RAG 為 LLM 提供了「文件庫」及「搜尋」的外掛,讓我們得以將最新、最相關的資料提供給 LLM 作為脈絡,彌補了其知識凍結和脈絡窗口大小的限制,也提高回答的可靠度和正確性。
  • 搜尋功能是整體成效表現的關鍵,畢竟如果前頭無法正確的取得相關的文件,那接手的 LLM 也是無能為力。
  • 「關鍵字」和「語意」搜尋各有其擅長和適合的情境,效果到底好還是仍得透過設計實際的測試案例集來評估。

軟體設計 #

  • 軟體開發讓人開心的一點是知識與實踐的串連,以及理解的修正與深化。在這方面上 AI 真的是學習良伴,任何想釐清的觀念都可以盡情地發問。
  • 這次專案的程式架構我是從和 AI 的討論開始,發現他建議了 domain 和 infrastructure 等資料夾,喚醒了我之前閱讀「領域驅動開發」的記憶。專案完成之後再請 AI 分析一次架構,回饋認為分層架構的設計有符合 Clean Architecture / Hexagonal Architecture(Ports & Adapters)的原則,於是很快查了一下六角形架構,看起來雖然是不同的框架但有著相似的精神。第二次看到肯定比第一次接觸更親切一些,期望從不同的角度多看幾次後能更掌握精神。
  • 另外一個新經驗則在於在安全性的考量,由於 static site 的內容基本上是全公開的,第一次思考公開面向的應用程式如何避免被濫用。查到的安全措施大概是「限制流量」和「限制訪問來源」兩個方向,也是這次採用的做法。雖然感覺無法解決所有的情況,但似乎也沒有簡單又絕對安全的做法,資安果然是個專業領域。
  • 當然,AI 也指出許許多多的缺失和改善的方向,雖然想把事情做到最好,但也要提醒自己適時地 Move on!

問題回顧 #

以下紀錄一些開發過程中印象比較深刻的問題以及 Lesson Learned。

Sync vs Async #

雖然原本就知道 Concurrency 在應用程式裡的重要性,但直到自己開發 API 時才會思考哪些函數操作應該要設計成非同步而哪些不用。我目前的理解是:

  1. 作為後端的 FastAPI Server 應讓所有被呼叫的 API 方法都是 Async
    • Sync 的方法會佔住 Server 執行,導致無法對任何其他需求做出即時反應
    • 當遇到需要執行的方法是 Sync 函數時,用 anyio.to_thread.run_syncasyncio.to_thread 包起來執行,避免阻塞
  2. 網路 IO、呼叫外部服務的函數方法(例如呼叫 Gemini API)應該要有 Async 的版本
    • 外包給原生 Async 的方法就保持 Async
  3. 高度使用 CPU 的工作和本地端的工作可以保持 Sync 版本
    • 要由 Server 自己完成執行的工作就如前述以另 thread 執行避免阻塞

中文/英文 #

在 Learn RAG 課程中所實作的的「關鍵字」搜尋部分是以英文文章為範例,而在我的應用裡文件是中英都有的。原本程式的預處理邏輯執行在中文文章上並不會產生錯誤,然而產出的結果卻是完全沒有意義(因為中文並不是靠空白來斷詞的)。幸好在重構的過程中發現這個狀況並修正,否則恐怕要留下個隱藏的大 bug。

在這個專案中我沒有撰寫單元測試,不過除非一開始就有想到多國語言這個因子,否則就算有撰寫測試,test case 恐怕也不會涵蓋到。

不同的資料可能需要客製化的處理

Google API Gateway #

對於 Google Cloud Console 的 API Gateway 產品 我目前的理解是這樣:

  1. Google API Gateway 產品裡可以建立多個 API Gateway 資源。API Gateway 資源底下是 Managed Service
  2. 個別 API Gateway 資源底下可以建立多個 API Config YAML 檔案。API Config 的功能是作為實際部署之 Gateway 背後的藍圖
  3. 個別 API Gateway 資源底下可以建立/部署多個實際 Gateway,其內容由你指定的 API Config 定義

可能是因為命名的雷同,我第一次使用的經驗其實是很混淆的。當我完成第一版 API Config YAML 檔並在 Cloud Console 進行 Create Gateway 的操作後,看見有一個 API Gateway 資源被建立起來,但一直找不到我預期的端點網址等等。直到花了一些時間研究了之後才了解前述三者的關係,並理解到我看到的是 API Gateway 資源而非實際 Gateway。而之所以是這樣是由於我第一版的 API Config 檔內容有錯誤,導致 Gateway 建立的程序的最後一步「部署實際 Gateway」是失敗的,因此留下了一個殘局。假設一切順利的情況下上述三件事情應該會一併建立完成。

使用服務或產品,還是要了解其設計概念

CORS & OPTIONS Preflight #

為了安全性,我建立一個 API Gateway 作為對外的公開入口,讓 RAG Server 作為背後的服務僅允許 API Gateway 透過 service account 賦予的權限呼叫,兩者的互動過程由 Google 替我們處理,因此省去了自行管理 JWT 的麻煩。使用時向 API Gateway 的 query/rag 發出 POST 請求。不過這樣的架構在瀏覽器的 CORS(Cross Origins Resource Sharing)機制下卻讓我卡關了一段時間。

首先,網頁瀏覽器預設會遵守「同源政策」,其中同源指的是相同 Protocol+Domain+Port,對於任何一個網頁 A 來說,瀏覽器預設僅允許其使用來自同源伺服器的請求結果,用意是避免網站 A 惡意的利用你在其他頁面 B 的登入資訊來向 B 發起請求取得機密資訊並洩漏出去。因此假若 A 網站真的需要跨來源向 B 發出請求,則 B 需要在其回應的表頭中明確的允許,這是也是我 CORS 機制的理解。

問題在於即使我在 Rag Server 程式中做了相關的設定(FastAPI 文件),請求卻還是遭遇到 CORS 的錯誤。仔細研究之後發現問題如下。

OPTIONS 與 allowCors #

我參考Google API Gateway 文件的例子在 API Config 裡做了 allowCors 的設定,但是遭遇到 405 Method Not Allowed 錯誤。

x-google-endpoints:
  - name: "test-gateway-a331xntq.an.gateway.dev"
    allowCors: True

原因是因為 allowCors=True 做的事情大概是在回應中加入 Access-Control-Allow-Origin 相關表頭資訊以讓瀏覽器看到正確的 CORS 設定。但在請求較複雜的情況下(例如 POST),瀏覽器其實會先發出一個 OPTIONS 方法的 Preflight Request 來向伺服器確認 CORS 的權限資訊,如果 Preflight 通過才會接著送出原本的請求。但不幸的是我的 API Config 中並沒有定義 OPTIONS query/rag 方法,也就是 Method Not Allowed 錯誤的原因。因此 allowCors=True 應該只能幫忙自動處理掉瀏覽器沒有使用 Preflight 的情況(例如 GET,我沒有再深入驗證)。

解決方法其實就是替每個 API 明確定義 OPTIONS 方法。雖然感覺很冗餘,但站在 Gateway 設計的角度來說,只允許明確定義的路徑和方法通過的確是比較謹慎、避免產生漏洞的做法。

被遮蔽的 Cold Start 逾時錯誤 #

在除錯的過程中有個蠻大的坑是沒注意到 CORS 錯誤可能不是真正失敗的原因。由於我在 Cloud Run 的自動調節設定中允許 RAG Server 容器數目降到零以節省花費,因此新的請求會在服務冷啟動時有「意料中」的逾時失敗,但這種初步的失敗在瀏覽器 Console 的訊息卻會顯示和 CORS 有關:

Access to fetch at 'https://gateway.../query/rag' from origin 
'https://...' has been blocked by CORS policy: Response to preflight 
request doesn't pass access control check: No 
'Access-Control-Allow-Origin' header is present on the 
requested resource.

後來我才注意到實際上發生的是 504 Gateway Timeout,只是瀏覽器每次只要跨來源請求沒有拿到預期中結果,都會不管三七二十一回報 CORS 錯誤。

alt text

實驗過程中令人混淆的結果一度還讓我懷疑是 Gateway 和 RAG Server 之間的身份驗證或是 Gateway Security 設定有問題,頭痛了一段時間。

仔細確認錯誤回饋的訊息/掌握領域知識