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 基礎設施其實同時在解兩個不同問題:
- 快取 / 去重:避免同一段文字反覆呼叫 embedding API
- 索引 / 儲存:讓搜尋路徑能快速找到相似向量
如果把這兩件事混在一起,文件很容易誤導成「把 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 size | 99 | 低於 100/min 限制(留 1 個 buffer) |
| 間隔 | 70 秒 | 確保不超過每分鐘上限(60 秒 + 10 秒 buffer) |
Prototype:冷啟動 vs 暖啟動
本地去重快取為空,如首次部署:
- 10,000 個 chunks → 102 個批次 → 102 × 70 秒 ≈ 2 小時
- 這是一次性成本,之後大多是暖快取
本地去重快取已建立:
- 搜尋時只需要生成 query 的 embedding(1 次 API 呼叫)
- 所有文件的 embedding 從本地快取讀取
- 近乎即時
Production:向量索引才是主設計
如果把本地檔案快取當成主要搜尋路徑,系統會在資料量變大時很快碰到瓶頸。production 比較合理的做法是:
- 寫入路徑生成 embedding:新資料進來就算好,不放在讀取路徑上現算
- 向量資料庫當主索引:搜尋直接查 pgvector / Pinecone / Weaviate
- 本地快取退居輔助角色:最多只用於離線預建或本機開發
- 視需求加 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