Eric TechBlog
AIAI Assistant

Embedding Infrastructure

向量基礎設施設計,包含統一搜尋介面、快取與索引分工、批次處理與速率限制,以及 Query 的語境增強。

設計問題

上一篇我們設計了檢索 pipeline,其中語意搜尋需要 embedding。但 embedding 不是免費的,每次呼叫 API 都有延遲和費用,API 也有速率限制,而且同樣的文字重複生成 embedding 純粹是在浪費資源。

核心問題:如何設計一套 embedding 基礎設施,讓整個系統的多個模組都能高效地使用語意搜尋?

這篇在系列中的角色

Retrieval Pipeline 告訴你為什麼需要語意搜尋; 這篇補的是更底層的基礎設施:快取怎麼做、索引怎麼放、批次怎麼跑,以及為什麼不同模組可以共用同一套 embedding 搜尋能力。


需求盤點

在 AI 助理系統中,有四個地方需要 embedding:

模組搜尋什麼何時觸發資料量
Email 搜尋email chunks用戶問 email 相關問題數萬 chunks
記憶搜尋用戶記憶每次對話(找相關記憶)數十~數百則
關聯對話過去的對話每次對話(找相關經驗)數十~數百則
歷史訊息同一對話的舊訊息對話較長時數十則

這四個場景有不同的特性:

  • Email chunks 量大但靜態(新 email 進來才變)
  • 記憶量小但頻繁變動(每次對話後可能更新)
  • 對話歷史是 append-only

設計原則

1. 統一的搜尋介面

所有場景使用同一個搜尋函式:

searchWithEmbeddings<T>(
  query: string,         ← 搜尋什麼
  items: T[],            ← 在哪裡搜
  toText: (T) → string   ← 怎麼把物件轉成文字
) → { item: T, score: number }[]

透過泛型 + toText 函式,一套搜尋邏輯通用於所有資料類型:

searchWithEmbeddings(query, emailChunks, emailChunkToText)
searchWithEmbeddings(query, memories, memoryToText)
searchWithEmbeddings(query, chats, chatToText)
searchWithEmbeddings(query, messages, messageToText)

2. 去重與快取層

相同的文字不應該重複生成 embedding。

3. 批次處理

大量 embedding 應該批次生成,而非一個一個呼叫。


快取與索引設計

先分清楚兩件事

embedding 基礎設施其實同時在解兩個不同問題:

  1. 快取 / 去重:避免同一段文字反覆呼叫 embedding API
  2. 索引 / 儲存:讓搜尋路徑能快速找到相似向量

如果把這兩件事混在一起,文件很容易誤導成「把 embedding 存在本地檔案裡,就是完整的生產設計」。其實不是。

方案比較

方案主要用途優點限制
記憶體快取短生命週期快取最快重啟後消失
Redis多實例共享快取 / query cache快、支持 TTL需要額外基礎設施
本地檔案快取prototype 階段的 embedding 去重零依賴、好理解不適合當主要檢索層
向量資料庫production 的主索引與長期儲存儲存 + 搜尋一體化額外基礎設施、成本

Prototype:本地檔案快取只負責去重

在 prototype 階段,本地檔案快取是合理的,因為它能用最少依賴避免重複打 embedding API。這時它的角色是去重層,不是主要的搜尋索引。

每個 embedding 可以存為一個 JSON 檔案:

data/embeddings/
├── google-text-embedding-004-a1b2c3d4e5f6a7b8.json
├── google-text-embedding-004-0f1e2d3c4b5a6978.json
└── ...

這個設計適合:

  • 單機開發
  • 離線預建 embedding
  • 小規模資料集的原型驗證

這個設計不適合:

  • 把本地檔案當成 production 的主要檢索來源
  • 在每次搜尋時掃描大量檔案並全量做 cosine similarity
  • 多實例或多用戶環境

快取鍵的設計

用文字內容的 SHA-256 雜湊作為檔名:

content → SHA-256 → 取前 16 個 hex 字元 → 檔名

為什麼取 16 個 hex 字元?

16 個 hex 字元等於 64 bits,對 prototype 和中小規模資料都更保險,也不會讓檔名變得太長。

為什麼在檔名中包含模型名?

google-text-embedding-004-{hash}.json

不同模型生成的 embedding 不相容。如果以後換模型,舊快取不會被誤用。

什麼時候需要重建?

同樣的文字在相同模型與相同前處理條件下,通常會得到相同的 embedding。因此本地檔案快取很適合拿來做去重。

但真正需要重建的情況不只一種:

  • 換了 embedding 模型
  • 改了文字前處理方式
  • 改了 chunking 策略,導致輸入文字邊界改變

這些情況都應該視為新的索引版本,而不是只「清空快取」就算了。


批次處理與速率限制

問題

Embedding API 有速率限制。例如 Google 的免費層限制每分鐘 100 次請求。一次批次請求可以包含多個文字,但仍然算一次 API 呼叫。

設計

參數原因
Batch size99低於 100/min 限制(留 1 個 buffer)
間隔70 秒確保不超過每分鐘上限(60 秒 + 10 秒 buffer)

Prototype:冷啟動 vs 暖啟動

本地去重快取為空,如首次部署:

  • 10,000 個 chunks → 102 個批次 → 102 × 70 秒 ≈ 2 小時
  • 這是一次性成本,之後大多是暖快取

本地去重快取已建立:

  • 搜尋時只需要生成 query 的 embedding(1 次 API 呼叫)
  • 所有文件的 embedding 從本地快取讀取
  • 近乎即時

Production:向量索引才是主設計

如果把本地檔案快取當成主要搜尋路徑,系統會在資料量變大時很快碰到瓶頸。production 比較合理的做法是:

  1. 寫入路徑生成 embedding:新資料進來就算好,不放在讀取路徑上現算
  2. 向量資料庫當主索引:搜尋直接查 pgvector / Pinecone / Weaviate
  3. 本地快取退居輔助角色:最多只用於離線預建或本機開發
  4. 視需求加 query cache:熱門查詢可再疊記憶體或 Redis

搜尋的完整流程

Prototype 階段,一次語意搜尋通常是 1 次 embedding API 呼叫 + N 次本地讀取 + N 次餘弦運算。到了 production,主要成本應該轉成「1 次 query embedding + 1 次向量索引查詢」。


Query 的設計

問題

用戶的最新訊息可能很短("他怎麼說?"),單獨作為搜尋查詢效果很差。

解法:帶權重的上下文查詢

query = 所有訊息.map(messageToText).join("\n") + 最新訊息(重複一次)

把整個對話歷史串成一個長查詢,但最新訊息重複一次,提高其在 embedding 中的權重。

這樣「他怎麼說?」就變成了:

user: 我想了解 John 對房子的看法
assistant: 讓我搜尋一下...
user: 他怎麼說?
user: 他怎麼說?  ← 重複

Embedding 模型會生成一個偏向「John + 房子 + 意見」的向量,而非一個空泛的「他怎麼說」向量。


從原型到生產的遷移路徑

Stage 1: Prototype
  - 本地檔案快取(只做去重)
  - 記憶體 cosine similarity
  - 適合:< 10k 文件,單用戶
  - 限制:冷啟動慢,記憶體受限

Stage 2: MVP
  - 向量資料庫成為主索引
  - 本地檔案快取只保留給離線預建或本機開發
  - 搜尋不再依賴全量掃描本地檔案

Stage 3: Production
  - embedding 在寫入路徑生成(新資料進來就算)
  - 搜尋直接查向量索引
  - 視情況保留 query cache,不再依賴本地檔案快取
  - Pinecone / Weaviate / pgvector

重點總結

設計決策選擇原因
搜尋介面泛型 + toText一套邏輯通用於所有資料類型
Prototype 去重SHA-256 本地檔案快取零依賴、適合原型驗證
Production 主設計向量資料庫 / 向量索引儲存與搜尋一致,能擴展
快取鍵16 hex chars (64 bits)降低碰撞風險,檔名仍可讀
批次策略99/batch + 70s 間隔符合 API 速率限制
Query 設計對話歷史 + 最新訊息加權解決短查詢的語境不足問題

Last updated on

On this page