上下文壓縮:讓 10 萬 Tokens 塞進 1 萬

Back
Category : News

Multigrid

壓縮是唯一需要花錢應用的上下文技術,因此也是最需要用算術來合理化的技術。通常有兩種更便宜的方法勝過它,而知道何時它們不適用才是真正的技巧。



先談免費的勝利

在任何基於模型的技術之前,先移除那些不帶資訊的 tokens。這看起來不華麗、是無損的,而且在真實提示詞上通常能省下 30–60%:

  • 去除標記。 提示詞中抓取的 HTML 大多是屬性、導航和腳本標籤。請提取純文字。
  • 壓縮結構化資料。 美化過的 JSON 會為每個空格付費。對於表格資料,CSV 或每欄一個緊湊鍵值的形式,遠比在每一列重複鍵值的物件陣列便宜得多。
  • 去除重複。 使用重疊區塊的檢索會傳回幾乎相同的段落。進行雜湊並丟棄。
  • 丟棄無法被讀取的內容。 Base64 資料塊、長 ID、雜湊、壓縮過的 bundle。它們無法被 tokenizer 進一步壓縮,對模型也沒有用處。
  • 修剪工具描述。 JSON Schema 描述中的每個字都會在迴圈的每次呼叫中被計費。



四種需要成本的類型

類型 描述
抽取式選取 為句子或段落根據與查詢的相關性進行評分,並保留最相關的。交叉編碼器 reranker 是標準工具。成本低,而且輸出是逐字的原始文字,這在答案必須可被引用時非常重要。
抽象式摘要 要求模型將上下文改寫得更短。壓縮率最高,風險也最高:摘要會遺漏具體數字、限定詞和否定詞,而這些常常是最終答案的關鍵。
token 剪枝 使用小型語言模型的 perplexity 作為訊號,刪除個別低資訊量的 token。這方面的已發表研究來自微軟研究院的 LLMLingua 和 LongLLMLingua(Jiang et al., EMNLP 2023 及其後續),在他們的評估任務上報告了高達約 20 倍的壓縮率,且性能損失有限。輸出不是人類可讀的,這對模型來說沒問題,但對記錄來說很麻煩。
改用檢索 根本不是壓縮 — 不要把它放進提示詞。幾乎總是最便宜的選項,也是考慮其他方法前要先排除的。



壓縮必須能回收成本

每種基於模型的技術都會花費 tokens 來節省 tokens。以下是算術,以您應該替換的示意費率為例:

original context T  = 20,000 tok      target model input   $3.00 / M
compressed T'       =  4,000 tok      compressor in/out    $0.15 / $0.60 per M

saving per use    (20,000 - 4,000) / 1e6 * $3.00       = $0.0480
compression cost  20,000/1e6*$0.15 + 4,000/1e6*$0.60   = $0.0054

net per use, if the compressed artefact is reused      = +$0.0426
net on a single use (compress then immediately send)   = +$0.0426 as well

BUT compare against caching the same 20,000 tokens:
  cache read at a 0.1x multiplier: 20,000/1e6 * $0.30  = $0.0060
  compressed prompt, uncompressed rate: 4,000/1e6*$3   = $0.0120

Enter fullscreen mode

Exit fullscreen mode

仔細閱讀最後兩行,因為它們才是真正重要的發現。當上下文是穩定的時,快取既比壓縮便宜,又是無損的 — 沒有理由去壓縮靜態的系統提示詞。壓縮只有在每次請求的上下文都不同、其大小大到無法經濟地快取,或是必須跨沒有共享快取的模型重複使用時,才有其價值。

因此完整的決策順序是:刪除無用的內容、改用檢索而非塞入、快取穩定的部分,然後壓縮剩下的。大多數團隊卻第一個就去抓第四個。

延遲值得在算術中單獨列一行,因為壓縮只是把成本移來移去,而非單純降低。一個壓縮呼叫是一次額外的網路來回和一次額外的生成,都在關鍵路徑上,而且壓縮器必須先讀完整個上下文才能開始輸出。對於互動功能,這很容易增加比縮短提示詞所節省的更多實際時間。因此壓縮最有道理的地方是它可以離線進行 — 在攝取時或按照排程進行 — 而在人類正在等待的請求中間進行是最沒有道理的。



每種方法會破壞什麼

壓縮本質上是有損的;問題在於你能容忍哪種損失。抽取式選取會失去段落之間的連接組織,因此會損害需要跨段落進行綜合的任務。抽象式摘要會失去精確性 — 精確數字、日期、名稱,以及眾所周知的否定詞,因為「該條款不適用於子公司」和「該條款適用於子公司」在嵌入空間中幾乎會被摘要成相鄰的向量。Token 剪枝會失去語法結構,這通常能被模型恢復,但偶爾不行,而且會讓你的提示詞對半夜兩點正在除錯的人類來說無法閱讀。

有一種損失是所有方法都共有的,值得明白指出:壓縮之後你就無法再引用原始來源。如果你的產品會顯示引用,壓縮後的文字必須保留指向原始段落的識別符,否則引用功能會悄悄開始捏造內容。

在長期執行的對話中還有一種複合效應需要注意。壓縮一個已經包含先前壓縮摘要的上下文,就會變成摘要的摘要,而損失不是加成而是相乘 — 具體細節最先消失,然後是限定詞,最後是已確認事實與假設之間的區別。在每個層級都保留指向未壓縮原始內容的指標,並盡可能從原始來源重新壓縮,而不是從先前的壓縮結果壓縮。



防止最壞結果的規則

  • 絕對不要壓縮指令。 系統提示詞和使用者的問題要原封不動。它們很小,而且是規格說明。
  • 絕對不要壓縮結構化數值。 識別符、金額、日期、數量。將它們提取成一個小的逐字區塊,只壓縮它們周圍的散文。
  • 壓縮一次,快取結果。 如果同一份原始文件被重複使用,壓縮後的形式就是一個穩定的成品 — 應該儲存它,而不是每次請求都重新產生。
  • 衡量端到端的結果,而非壓縮率。 以三個答案準確度點為代價換來的 10 倍壓縮率,在幾乎任何價格下都是糟糕的交易,而除非評估是在最終答案上執行,否則你不會看到這一點。
  • 保留未壓縮的路徑。 當答案錯誤時,第一個診斷方法是用完整上下文重新執行。如果這樣就修好了,壓縮就是你的 bug;如果沒有,那它從來就不是問題。

上述損益平衡取決於壓縮器與目標模型之間的價格差距,這是兩個模型的比較而非單一查詢;把小型和大型模型的費率並排比較就足以判斷一個壓縮步驟在你開始建置前是否能回收成本。



相關文章

https://dev.to/multigrid/context-compression-making-100k-tokens-fit-in-10k-4aam

https://www.worldprogramming.org/posts/context-compression-making-100k-tokens-fit-in-10k-flby9g

Multigrid

在原本運作良好的向量查詢中加入 WHERE tenant_id = 42,會發生兩種情況之一:查詢變慢,或是返回的資料列比你要求的還少。這兩種現象背後是相同的事實——近似索引與過濾器無法同時套用——而導致致命問題的選擇性是可以事先計算出來的。



為什麼兩者無法組合

B-tree 索引和 GIN 索引可以結合:Postgres 會從每個索引建立位元圖並進行 AND 運算。HNSW 索引無法參與其中,因為它不會產生一組符合的資料列——它產生的是有序串流,最接近的排在最前面,而這個排序正是它的全部價值。你無法將一個排序與位元圖進行交集運算後還保有排序。

因此規劃器必須做出選擇。它要嘛走訪圖形,之後再捨棄不符合過濾器的資料列(後置過濾),要嘛先找出通過過濾器的資料列,再為它們全部計算距離(前置過濾)。這兩種方式有不同的成本,更重要的是,會產生不同的結果。

Postgres 使用其成本模型在兩者之間選擇,而它對近似最近鄰居掃描的成本模型接近猜測。它沒有描述圖形在找到十個通過你過濾器的資料列之前會檢查多少資料列的統計資訊,因為這個數量取決於你的查詢落在向量空間的哪個位置。因此規劃器使用結構上缺乏資訊的估計值來選擇,有時會選到無法回答你查詢的執行計畫。這是在 Postgres 中唯一一種從應用程式覆寫規劃器是正常行為而非壞味道的情況。



後置過濾器及其運算

這是你對明顯查詢預設會得到的結果:

SELECT id, content
FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 10;

Enter fullscreen mode

Exit fullscreen mode

HNSW 掃描會讓 hnsw.ef_search 個候選保持存活,並依距離順序返回它們;過濾器會套用在這個串流上。令 s 為過濾器的選擇性——它通過資料表的分數。如果過濾器與向量空間中的位置無關,預期的倖存者數量為:

survivors ≈ ef_search × s

To get k results:   ef_search ≥ k / s

Assumptions: the filter is uncorrelated with the embedding
distribution; ef_search candidates are the only rows examined;
hnsw.ef_search has a hard maximum of 1000.

Enter fullscreen mode

Exit fullscreen mode

在預設 ef_search = 40 且過濾器通過資料表一半的情況下,你預期有二十個倖存者,而你要求十個。沒問題。在過濾器每千分之一列通過的情況下,你預期有 40 × 0.001 = 0.04 個倖存者,這在實務上代表查詢完全沒有返回任何結果,而索引運作完全符合設計。

在 pgvector 0.8.0 之前,在掃描內沒有補救方法:你只會得到倖存的資料列數量,無聲無息,且不會提示答案不足。0.8.0 加入了疊代掃描,它會使用較大的候選集合重新掃描,直到達到限制或耗盡預算為止:

SET LOCAL hnsw.iterative_scan = strict_order;   -- pgvector 0.8.0+
SET LOCAL hnsw.max_scan_tuples = 20000;        -- the budget it stops at

Enter fullscreen mode

Exit fullscreen mode

strict_order 保證結果會以真正的距離順序返回;relaxed_order 較快,可能會讓結果稍微偏離順序,如果之後無論如何都會執行重新排序器,這通常是可以接受的。兩者都無法消除底層成本——在高度選擇性過濾器上的疊代掃描需要做大量工作才能找到少數幾列。



以數字呈現的懸崖

將公式放入表格中,問題的形狀立刻顯現。對於 k = 10

Filter passes Description
50% of rows ef_search of 20 is enough. The default of 40 has margin. Nothing to do.
10% of rows ef_search ≥ 100. Query cost roughly 2.5× the unfiltered one. Still comfortable.
2% of rows ef_search ≥ 500. Roughly 12× the work of the default, and latency you will notice.
1% of rows ef_search ≥ 1000 — exactly the maximum pgvector allows. You are at the edge with no margin for an unlucky query.
0.1% of rows ef_search would need to be 10,000. Not reachable. The post-filter plan cannot answer this query correctly, at any setting, ever.

這就是懸崖:它不是逐漸退化,而是在頂-10 查詢百分之一選擇性處的硬牆,一般而言位於 s = k / 1000。在它之下,索引不是變慢——而是無能為力,而疊代掃描會把這種無能從錯誤答案轉變成緩慢答案。

該推導中的一個假設值得一提,因為它經常是錯誤的。它假設過濾器與向量位置無關。租戶過濾器通常大致無關。語言、文件類型或日期的過濾器則有強烈相關性——所有德文區塊在嵌入空間中都彼此靠近——這時倖存者要嘛聚集在候選集合中(優於公式),要嘛完全不在其中(糟得多)。公式是正確的規劃工具;你自己的測量才是正確的決策工具。



前置過濾器,以及它何時更快

另一個執行計畫完全忽略向量索引:使用 B-tree 找出符合的資料列,為每個計算距離,排序,取十個。召回率精確為 100%,因為沒有任何近似。

-- Force it, to see what it costs:
SET LOCAL enable_indexscan = off;   -- disables the HNSW ordered scan
SET LOCAL enable_bitmapscan = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM chunks WHERE tenant_id = 42
ORDER BY embedding <=> $1 LIMIT 10;

 Limit
   ->  Sort
         Sort Key: ((embedding <=> '[...]'::vector))
         Sort Method: top-N heapsort  Memory: 27kB
         ->  Bitmap Heap Scan on chunks
               Recheck Cond: (tenant_id = 42)
               ->  Bitmap Index Scan on chunks_tenant_idx
                     Index Cond: (tenant_id = 42)

Enter fullscreen mode

Exit fullscreen mode

這個執行計畫的成本是 s × N 次各有 d 個維度的距離計算,加上堆積提取。在 N = 10,000,000s = 0.001d = 1536 時:一萬列、一千五百萬次乘加,個位數毫秒的運算。一萬個分散資料列的堆積提取才是真正的成本,而且在暖機狀態下仍可能低於一百毫秒。

因此後置過濾器完全無法回答的執行計畫,是前置過濾器能精確且快速回答的計畫。懸崖和交叉點是同一個地方,這是個方便的巧合,也是本頁最有用的資訊。



交叉點位於何處

將兩種成本設為相等並求解。精確計畫的成本約為 sNd。後置過濾計畫需要 ef_search ≈ k/s,且每個候選會展開約 m 個鄰居,因此成本約為 (k/s)md

s N d  =  (k / s) m d

s²     =  k m / N

s*     =  sqrt(k m / N)

k = 10, m = 16, N = 10,000,000:
  s* = sqrt(160 / 10,000,000) = sqrt(1.6e-5) = 0.0040  →  0.40%

k = 10, m = 16, N = 1,000,000:
  s* = sqrt(160 / 1,000,000)  = 0.0126        →  1.26%

Assumptions: distance computation dominates both plans; heap access
costs are ignored, which flatters the exact plan; graph traversal
cost is linear in ef_search and m.

Enter fullscreen mode

Exit fullscreen mode

s* 之下,不要使用向量索引。在它之上,才使用。這個數字會隨著你的資料表大小移動,而平方根意味著它移動緩慢——從一百萬列成長到一千萬列只會讓它從約 1.3% 移動到約 0.4%。

這代表實務規則是:選擇超過資料表百分之幾的過濾器應該透過提高 ef_search 的向量索引;選擇低於百分之一的過濾器應該跳過它。規劃器不會可靠地為你做出這個選擇,因為它對 ANN 掃描的成本模型只是個佔位符,所以你應該明確地做出選擇。



四種解決方法

  1. 提高 ef_search 並測量。 對於高於百分之幾的選擇性,這就是完整解答。根據預期的選擇性為每個查詢設定它——你通常大致知道一個租戶有多少列——而不是全域設定。
  2. 依過濾欄位進行分割。 如果你的過濾器幾乎總是同一個欄位,將它設為分割鍵並為每個分割區建立一個 HNSW 索引。每個分割區的索引只包含符合的資料列,因此對它的掃描是未過濾的,上面的運算完全不適用。這是最乾淨的修正,也是具有真正運作成本的修正,因為它會讓你的索引數量倍增。

    CREATE TABLE chunks (
      id bigint GENERATED ALWAYS AS IDENTITY,
      tenant_id int NOT NULL,
      embedding vector(1536) NOT NULL,
      content text NOT NULL
    ) PARTITION BY LIST (tenant_id);
    
    CREATE TABLE chunks_t42 PARTITION OF chunks FOR VALUES IN (42);
    CREATE INDEX ON chunks_t42 USING hnsw (embedding vector_cosine_ops);
    
  3. 部分索引,用於少數熱門值。 索引本身的 WHERE 子句。適合兩三個大型租戶或單一 status = 'active' 述詞;超過幾十個就無法實用,因為每個都需要完整的 HNSW 建置。

    CREATE INDEX chunks_active_hnsw ON chunks
      USING hnsw (embedding vector_cosine_ops)
      WHERE deleted_at IS NULL;
    
  4. 在交叉點之下強制使用精確計畫。(tenant_id) 上建立複合 B-tree,並為該查詢停用索引掃描,或是撰寫查詢讓 ANN 索引無法套用——例如對計算出的運算式排序。在小的已過濾集合上進行精確搜尋不是後備方案;在 s* 之下,它才是正確的計畫。

不在清單中的一件事:將過濾欄位加入 HNSW 索引。pgvector 不支援多欄位 HNSW 索引,也沒有任何運算子類別能讓它有意義。如果某個教學建議這麼做,那個教學是在描述不同的引擎。

無論你採取哪種路線,你首先需要的數字是實際的選擇性,而它是一個查詢而非假設。過濾器很少是均勻的:少數租戶持有大部分語料,而長尾幾乎沒有,因此平均選擇性為百分之五可能隱藏著一千個坐在 0.01% 的客戶,他們看到空的結果而其他人都沒問題。

-- The distribution of selectivity across the filter values you
-- actually use. Look at the bottom decile, not the mean.
WITH n AS (SELECT count(*)::numeric AS total FROM chunks)
SELECT tenant_id,
       count(*) AS rows,
       round(100.0 * count(*) / (SELECT total FROM n), 4) AS pct_of_table,
       -- ef_search needed for a top-10 query, from k/s:
       ceil(10.0 * (SELECT total FROM n) / count(*)) AS ef_search_needed
FROM chunks
GROUP BY tenant_id
ORDER BY ef_search_needed DESC
LIMIT 20;

Enter fullscreen mode

Exit fullscreen mode

該輸出中任何 ef_search_needed 高於 1000 的項目,都是後置過濾計畫無法服務的過濾值,而此類資料列的計數會告訴你答案是每個查詢的 ef_search、分割策略,還是將小型租戶送到精確路徑、大型租戶透過索引的路由規則。最後這個選項值得考慮:兩個計畫都是正確的,因此依每個請求在它們之間選擇是一種合理的優化,而不是駭客行為。

最後,讓團隊驚訝的互動是:Postgres 列層級安全性原則會變成 WHERE 子句,這意味著啟用 RLS 會在一夜之間把應用程式中每個向量查詢都變成後置過濾的查詢,具有單一租戶的選擇性。這在多租戶檢索的列層級安全性中有涵蓋,並包含洩漏測試。



相關文章

https://dev.to/multigrid/filtering-and-vector-search-in-one-query-5085

https://www.worldprogramming.org/posts/filtering-and-vector-search-in-one-query-wki9iv

在 localhost,Rami Banna,Stripe 負責 Ecosystems、Apps 和 Stripe Projects 的產品負責人,介紹了 Stripe Projects,這是一款針對建置初期階段的工具:在產品還沒有使用者、還沒向任何人收費、開發者仍在為新技術堆疊連接帳號與服務的時候。

它背後的痛點與撰寫程式碼無關。設定一個新專案通常意味著在各個儀表板之間跳來跳去:建立帳號、架設服務、複製環境變數、輸入付款資訊、將所有內容貼到本機檔案中,並為堆疊所需的每個供應商重複這個過程。Stripe 將這個痛點追溯到付款:人們在第一天使用的多數 SaaS 和開發者工具,已經透過 Stripe 進行計費。這個重疊正是 Stripe Projects 所建立的基礎,讓開發者能從單一 CLI 建立帳號、佈建服務、管理環境變數,並支付升級費用。

其背後的網路目前約有 50 個供應商並持續增加,旨在作為一個開放生態系,任何人都能加入。

Stripe Projects 背後的協定運行在三個物件上:一個帳號(開發者在特定供應商的租戶、組織或團隊)、一個服務(該供應商提供的項目,其方案與價格目錄),以及一個資源(被建立並回傳憑證的具體實例)。

Three actors, one protocol
Three actors, one protocol

這三個物件支援完整的生命週期:建立或連接供應商帳號,即使原本不存在,也能使用 Stripe 自己的驗證機制來建立新帳號;拉取即時的服務目錄;佈建資源;回傳其憑證;之後輪替這些憑證;並在不再需要時移除資源。

Provisioning lifecycle steps
Provisioning lifecycle steps

每個供應商透過圍繞相同三個物件建置一小組端點來實作此協定:一個用於初始帳號請求,無需任何瀏覽器互動即可處理;一個用於服務請求,每十分鐘輪詢一次以保持價格與方案最新;以及一個用於資源本身,涵蓋其餘的生命週期。底層的服務 schema 足夠彈性,能夠建模用量計費、固定方案、混合計費或預付額度,因為供應商的收費方式差異很大。

Three core provider endpoints
Three core provider endpoints

在演講當時,該協定本身尚未公開。計畫是在今年夏天稍晚開放,並期望它能成為共享的標準。

佈建資源後仍會留下誰來支付的問題,而答案是一個共享的付款 token。開發者透過 Stripe Checkout 一次性輸入付款方式,Stripe 會為每個供應商分別將該憑證 token 化。每個供應商以其正常方式對自己的 token 進行計費,而開發者可以針對該共享方式為每個供應商個別設定消費上限。

其結果是在所有已連接的供應商之間,只需在單一地方輸入和檢視付款資訊,而非每個供應商各有一個,另外還有一個自然的控制點,可以限制代理程式正在消費的任何地方的支出。該層位於底層付款方式之上;目前支援銀行轉帳和先買後付。另一個推送 API 讓供應商能直接向 Stripe 回報變更,因此在供應商自家儀表板上所做的更新,仍會出現在 Stripe 這一側。

這與其說是一個平台,不如說是一個協調層。它是一個基礎設施層,讓你能夠擁有自己的供應商帳號。

透過 Stripe Projects 建立的帳號屬於開發者,而非 Stripe。當透過 Stripe 使用 Render 基礎設施時,Render 仍是實際的服務供應商和計費關係;憑證與環境變數會留在開發者自己的專案內。Stripe Projects 只負責協調佈建本身,之後不會介入開發者與供應商之間。

從命令列佈建

從 CLI 拉出 Stripe Projects 目錄,會顯示 Render 在網路中提供的項目:Postgres、Web 服務或靜態網站。輸入 stripe projects add render 會直接顯示該目錄,接著相同的指令可以交給像 Claude 這樣的程式碼代理,它會連接公開的 GitHub 儲存庫,並直接從命令列透過 Render 進行部署。同樣的模式也能擴展到加入其他供應商,包括 OpenRouter、Exa、Clerk 和 PostHog 等,只要用自然語言詢問代理它需要什麼;它會使用相同的底層指令,將所有東西整合到一個專案中,並共用一組環境變數。

Stripe Projects CLI setup
Stripe Projects CLI setup

https://render.com/blog/stripe-an-orchestration-layer-not-a-platform

https://www.worldprogramming.org/posts/stripe-an-orchestration-layer-not-a-platform-av4nrm

Multigrid

在原本運作良好的向量查詢中加入 WHERE tenant_id = 42,會發生兩種情況之一:查詢變慢,或是返回的資料列比你要求的還少。這兩種現象背後是相同的事實——近似索引與過濾器無法同時套用——而導致致命問題的選擇性是可以事先計算出來的。



為什麼兩者無法組合

B-tree 索引和 GIN 索引可以結合:Postgres 會從每個索引建立位元圖並進行 AND 運算。HNSW 索引無法參與其中,因為它不會產生一組符合的資料列——它產生的是有序串流,最接近的排在最前面,而這個排序正是它的全部價值。你無法將一個排序與位元圖進行交集運算後還保有排序。

因此規劃器必須做出選擇。它要嘛走訪圖形,之後再捨棄不符合過濾器的資料列(後置過濾),要嘛先找出通過過濾器的資料列,再為它們全部計算距離(前置過濾)。這兩種方式有不同的成本,更重要的是,會產生不同的結果。

Postgres 使用其成本模型在兩者之間選擇,而它對近似最近鄰居掃描的成本模型接近猜測。它沒有描述圖形在找到十個通過你過濾器的資料列之前會檢查多少資料列的統計資訊,因為這個數量取決於你的查詢落在向量空間的哪個位置。因此規劃器使用結構上缺乏資訊的估計值來選擇,有時會選到無法回答你查詢的執行計畫。這是在 Postgres 中唯一一種從應用程式覆寫規劃器是正常行為而非壞味道的情況。



後置過濾器及其運算

這是你對明顯查詢預設會得到的結果:

SELECT id, content
FROM chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 10;

Enter fullscreen mode

Exit fullscreen mode

HNSW 掃描會讓 hnsw.ef_search 個候選保持存活,並依距離順序返回它們;過濾器會套用在這個串流上。令 s 為過濾器的選擇性——它通過資料表的分數。如果過濾器與向量空間中的位置無關,預期的倖存者數量為:

survivors ≈ ef_search × s

To get k results:   ef_search ≥ k / s

Assumptions: the filter is uncorrelated with the embedding
distribution; ef_search candidates are the only rows examined;
hnsw.ef_search has a hard maximum of 1000.

Enter fullscreen mode

Exit fullscreen mode

在預設 ef_search = 40 且過濾器通過資料表一半的情況下,你預期有二十個倖存者,而你要求十個。沒問題。在過濾器每千分之一列通過的情況下,你預期有 40 × 0.001 = 0.04 個倖存者,這在實務上代表查詢完全沒有返回任何結果,而索引運作完全符合設計。

在 pgvector 0.8.0 之前,在掃描內沒有補救方法:你只會得到倖存的資料列數量,無聲無息,且不會提示答案不足。0.8.0 加入了疊代掃描,它會使用較大的候選集合重新掃描,直到達到限制或耗盡預算為止:

SET LOCAL hnsw.iterative_scan = strict_order;   -- pgvector 0.8.0+
SET LOCAL hnsw.max_scan_tuples = 20000;        -- the budget it stops at

Enter fullscreen mode

Exit fullscreen mode

strict_order 保證結果會以真正的距離順序返回;relaxed_order 較快,可能會讓結果稍微偏離順序,如果之後無論如何都會執行重新排序器,這通常是可以接受的。兩者都無法消除底層成本——在高度選擇性過濾器上的疊代掃描需要做大量工作才能找到少數幾列。



以數字呈現的懸崖

將公式放入表格中,問題的形狀立刻顯現。對於 k = 10

Filter passes Description
50% of rows ef_search of 20 is enough. The default of 40 has margin. Nothing to do.
10% of rows ef_search ≥ 100. Query cost roughly 2.5× the unfiltered one. Still comfortable.
2% of rows ef_search ≥ 500. Roughly 12× the work of the default, and latency you will notice.
1% of rows ef_search ≥ 1000 — exactly the maximum pgvector allows. You are at the edge with no margin for an unlucky query.
0.1% of rows ef_search would need to be 10,000. Not reachable. The post-filter plan cannot answer this query correctly, at any setting, ever.

這就是懸崖:它不是逐漸退化,而是在頂-10 查詢百分之一選擇性處的硬牆,一般而言位於 s = k / 1000。在它之下,索引不是變慢——而是無能為力,而疊代掃描會把這種無能從錯誤答案轉變成緩慢答案。

該推導中的一個假設值得一提,因為它經常是錯誤的。它假設過濾器與向量位置無關。租戶過濾器通常大致無關。語言、文件類型或日期的過濾器則有強烈相關性——所有德文區塊在嵌入空間中都彼此靠近——這時倖存者要嘛聚集在候選集合中(優於公式),要嘛完全不在其中(糟得多)。公式是正確的規劃工具;你自己的測量才是正確的決策工具。



前置過濾器,以及它何時更快

另一個執行計畫完全忽略向量索引:使用 B-tree 找出符合的資料列,為每個計算距離,排序,取十個。召回率精確為 100%,因為沒有任何近似。

-- Force it, to see what it costs:
SET LOCAL enable_indexscan = off;   -- disables the HNSW ordered scan
SET LOCAL enable_bitmapscan = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM chunks WHERE tenant_id = 42
ORDER BY embedding <=> $1 LIMIT 10;

 Limit
   ->  Sort
         Sort Key: ((embedding <=> '[...]'::vector))
         Sort Method: top-N heapsort  Memory: 27kB
         ->  Bitmap Heap Scan on chunks
               Recheck Cond: (tenant_id = 42)
               ->  Bitmap Index Scan on chunks_tenant_idx
                     Index Cond: (tenant_id = 42)

Enter fullscreen mode

Exit fullscreen mode

這個執行計畫的成本是 s × N 次各有 d 個維度的距離計算,加上堆積提取。在 N = 10,000,000s = 0.001d = 1536 時:一萬列、一千五百萬次乘加,個位數毫秒的運算。一萬個分散資料列的堆積提取才是真正的成本,而且在暖機狀態下仍可能低於一百毫秒。

因此後置過濾器完全無法回答的執行計畫,是前置過濾器能精確且快速回答的計畫。懸崖和交叉點是同一個地方,這是個方便的巧合,也是本頁最有用的資訊。



交叉點位於何處

將兩種成本設為相等並求解。精確計畫的成本約為 sNd。後置過濾計畫需要 ef_search ≈ k/s,且每個候選會展開約 m 個鄰居,因此成本約為 (k/s)md

s N d  =  (k / s) m d

s²     =  k m / N

s*     =  sqrt(k m / N)

k = 10, m = 16, N = 10,000,000:
  s* = sqrt(160 / 10,000,000) = sqrt(1.6e-5) = 0.0040  →  0.40%

k = 10, m = 16, N = 1,000,000:
  s* = sqrt(160 / 1,000,000)  = 0.0126        →  1.26%

Assumptions: distance computation dominates both plans; heap access
costs are ignored, which flatters the exact plan; graph traversal
cost is linear in ef_search and m.

Enter fullscreen mode

Exit fullscreen mode

s* 之下,不要使用向量索引。在它之上,才使用。這個數字會隨著你的資料表大小移動,而平方根意味著它移動緩慢——從一百萬列成長到一千萬列只會讓它從約 1.3% 移動到約 0.4%。

這代表實務規則是:選擇超過資料表百分之幾的過濾器應該透過提高 ef_search 的向量索引;選擇低於百分之一的過濾器應該跳過它。規劃器不會可靠地為你做出這個選擇,因為它對 ANN 掃描的成本模型只是個佔位符,所以你應該明確地做出選擇。



四種解決方法

  1. 提高 ef_search 並測量。 對於高於百分之幾的選擇性,這就是完整解答。根據預期的選擇性為每個查詢設定它——你通常大致知道一個租戶有多少列——而不是全域設定。
  2. 依過濾欄位進行分割。 如果你的過濾器幾乎總是同一個欄位,將它設為分割鍵並為每個分割區建立一個 HNSW 索引。每個分割區的索引只包含符合的資料列,因此對它的掃描是未過濾的,上面的運算完全不適用。這是最乾淨的修正,也是具有真正運作成本的修正,因為它會讓你的索引數量倍增。

    CREATE TABLE chunks (
      id bigint GENERATED ALWAYS AS IDENTITY,
      tenant_id int NOT NULL,
      embedding vector(1536) NOT NULL,
      content text NOT NULL
    ) PARTITION BY LIST (tenant_id);
    
    CREATE TABLE chunks_t42 PARTITION OF chunks FOR VALUES IN (42);
    CREATE INDEX ON chunks_t42 USING hnsw (embedding vector_cosine_ops);
    
  3. 部分索引,用於少數熱門值。 索引本身的 WHERE 子句。適合兩三個大型租戶或單一 status = 'active' 述詞;超過幾十個就無法實用,因為每個都需要完整的 HNSW 建置。

    CREATE INDEX chunks_active_hnsw ON chunks
      USING hnsw (embedding vector_cosine_ops)
      WHERE deleted_at IS NULL;
    
  4. 在交叉點之下強制使用精確計畫。(tenant_id) 上建立複合 B-tree,並為該查詢停用索引掃描,或是撰寫查詢讓 ANN 索引無法套用——例如對計算出的運算式排序。在小的已過濾集合上進行精確搜尋不是後備方案;在 s* 之下,它才是正確的計畫。

不在清單中的一件事:將過濾欄位加入 HNSW 索引。pgvector 不支援多欄位 HNSW 索引,也沒有任何運算子類別能讓它有意義。如果某個教學建議這麼做,那個教學是在描述不同的引擎。

無論你採取哪種路線,你首先需要的數字是實際的選擇性,而它是一個查詢而非假設。過濾器很少是均勻的:少數租戶持有大部分語料,而長尾幾乎沒有,因此平均選擇性為百分之五可能隱藏著一千個坐在 0.01% 的客戶,他們看到空的結果而其他人都沒問題。

-- The distribution of selectivity across the filter values you
-- actually use. Look at the bottom decile, not the mean.
WITH n AS (SELECT count(*)::numeric AS total FROM chunks)
SELECT tenant_id,
       count(*) AS rows,
       round(100.0 * count(*) / (SELECT total FROM n), 4) AS pct_of_table,
       -- ef_search needed for a top-10 query, from k/s:
       ceil(10.0 * (SELECT total FROM n) / count(*)) AS ef_search_needed
FROM chunks
GROUP BY tenant_id
ORDER BY ef_search_needed DESC
LIMIT 20;

Enter fullscreen mode

Exit fullscreen mode

該輸出中任何 ef_search_needed 高於 1000 的項目,都是後置過濾計畫無法服務的過濾值,而此類資料列的計數會告訴你答案是每個查詢的 ef_search、分割策略,還是將小型租戶送到精確路徑、大型租戶透過索引的路由規則。最後這個選項值得考慮:兩個計畫都是正確的,因此依每個請求在它們之間選擇是一種合理的優化,而不是駭客行為。

最後,讓團隊驚訝的互動是:Postgres 列層級安全性原則會變成 WHERE 子句,這意味著啟用 RLS 會在一夜之間把應用程式中每個向量查詢都變成後置過濾的查詢,具有單一租戶的選擇性。這在多租戶檢索的列層級安全性中有涵蓋,並包含洩漏測試。



相關文章

https://dev.to/multigrid/filtering-and-vector-search-in-one-query-5085

https://www.worldprogramming.org/posts/filtering-and-vector-search-in-one-query-wki9iv

Multigrid

在 Android 上實現裝置端 AI 的困難之處不在於撰寫推論呼叫,而是相同的呼叫必須在數千種系統單晶片與驅動程式組合上執行。其中有些能優雅地加速你的模型,有些會在不告知的情況下退回 CPU,還有少數會產生錯誤的數值。有效的策略是在執行階段探測並記住結果。



問題不在於 API,而在於差異性

在 iOS 上,你只需針對單一廠商的少數幾代晶片。而在 Android 上,你必須面對多家晶片廠商、各自多個世代,以及由裝置製造商按照自己的時程所出貨的驅動程式堆疊。兩支搭載相同旗艦晶片的手機,可能因為其中一支使用較舊的 GPU 驅動程式而表現不同。

由此導出的規則是:永遠不要依據裝置型號字串來決定是否加速。依裝置名稱建立的白名單會在一個版本週期內過期,無法涵蓋你出貨後才推出的裝置,而且它們是在有實際測量可用的情況下仍做出的猜測。應該在裝置上實際嘗試一次,並快取結果。



你所選擇的各層抽象

大致上有三種可用的抽象層級,選擇正確的層級主要取決於你需要多少控制權:

  • 由平台提供的受管理裝置端模型。 Google 提供系統層級的生成式 AI 功能,應用程式可以呼叫,模型由平台管理並在應用程式外部更新。這能讓你的 bundle 大小為零,但完全沒有控制權:可用性取決於裝置與系統元件版本,因此你的功能在缺少這些條件時必須降級。請以程式方式檢查目前可用性,而不是假設最低 API 等級。
  • 你自行打包的執行環境,搭配 delegate。 LiteRT(先前稱為 TensorFlow Lite)或 ONNX Runtime,在載入時選擇加速 delegate 或執行提供者。這是主流選擇。你可以控制模型、版本與後備機制。
  • 直接針對單一晶片家族的廠商 SDK。 晶片廠商會發布自己的神經網路 SDK,通常能從自家 NPU 榨取出比通用 delegate 更多的效能,但代價是需要額外的整合工作,並且每個廠商都要準備獨立的模型成品。當推論本身就是產品時值得這麼做;若推論只是其中一項功能則很少值得。

哪些加速路徑目前有效、哪些已被棄用而改用廠商 SDK,已改變過多次且仍持續變化。請將你選擇的 delegate 視為建置的參數,而非 Android 的既定事實,並在每次主要平台版本發布時重新檢查。以下的探測程式碼設計成只需修改一行就能更換 delegate。



能力探測器

無論你選擇哪種執行環境,模式都相同:嘗試建構加速的直譯器、執行固定的輸入、與 CPU 參考值比對,並計時兩者。如果建構拋出例外、數值差異超過容忍範圍,或加速路徑實際上並未更快,則使用 CPU。

enum Accel { NNAPI_OR_VENDOR, GPU, CPU }

data class ProbeResult(val accel: Accel, val medianMs: Double, val valid: Boolean)

fun probe(context: Context, modelBytes: ByteBuffer): Accel {
    val golden = loadGoldenInputOutput(context)   // fixed input + expected output
    val results = mutableListOf<ProbeResult>()

    // Always establish the CPU reference first: it is the tie-breaker
    // for both correctness and speed.
    val cpu = timeRun(modelBytes, Accel.CPU, golden)
    results += cpu

    for (accel in listOf(Accel.NNAPI_OR_VENDOR, Accel.GPU)) {
        val r = try {
            timeRun(modelBytes, accel, golden)
        } catch (t: Throwable) {
            // Delegate construction failing is normal, not exceptional.
            ProbeResult(accel, Double.MAX_VALUE, valid = false)
        }
        results += r
    }

    val best = results
        .filter { it.valid }
        .minByOrNull { it.medianMs } ?: cpu

    // Require a real margin. A 5% win is not worth a second code path.
    return if (best.medianMs < cpu.medianMs * 0.8) best.accel else Accel.CPU
}

Enter fullscreen mode

Exit fullscreen mode

在功能首次被使用時,於背景執行緒執行此探測一次——不要在應用程式啟動時執行,因為那時它會與其他所有工作競爭啟動預算。將判斷結果以你的應用程式版本、模型版本與 OS build number 為鍵值持久化儲存,並在這三者任一改變時重新探測。系統更新可能會新增或移除可用的驅動程式,而你的快取答案不能比它活得更久。



與黃金參考值進行驗證

探測中正確性這一半是最常被整合忽略的部分,卻也是能抓住最嚴重 bug 類型的那一部分:一個能執行、速度快、但結果錯誤的 delegate。

  1. 選擇一個能有效測試模型的固定輸入——不要用全零,因為許多有問題的 kernel 會意外地正確處理全零。
  2. 使用你信任的參考實作,在離線狀態下計算一次預期輸出,並以 asset 形式出貨。
  3. 在裝置上,以適合精確度的容忍值進行元素逐一比對。使用 fp16 進行計算 的 de