![]()
壓縮是唯一需要花錢應用的上下文技術,因此也是最需要用算術來合理化的技術。通常有兩種更便宜的方法勝過它,而知道何時它們不適用才是真正的技巧。
在任何基於模型的技術之前,先移除那些不帶資訊的 tokens。這看起來不華麗、是無損的,而且在真實提示詞上通常能省下 30–60%:
| 類型 | 描述 |
|---|---|
| 抽取式選取 | 為句子或段落根據與查詢的相關性進行評分,並保留最相關的。交叉編碼器 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 剪枝會失去語法結構,這通常能被模型恢復,但偶爾不行,而且會讓你的提示詞對半夜兩點正在除錯的人類來說無法閱讀。
有一種損失是所有方法都共有的,值得明白指出:壓縮之後你就無法再引用原始來源。如果你的產品會顯示引用,壓縮後的文字必須保留指向原始段落的識別符,否則引用功能會悄悄開始捏造內容。
在長期執行的對話中還有一種複合效應需要注意。壓縮一個已經包含先前壓縮摘要的上下文,就會變成摘要的摘要,而損失不是加成而是相乘 — 具體細節最先消失,然後是限定詞,最後是已確認事實與假設之間的區別。在每個層級都保留指向未壓縮原始內容的指標,並盡可能從原始來源重新壓縮,而不是從先前的壓縮結果壓縮。
上述損益平衡取決於壓縮器與目標模型之間的價格差距,這是兩個模型的比較而非單一查詢;把小型和大型模型的費率並排比較就足以判斷一個壓縮步驟在你開始建置前是否能回收成本。
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
![]()
在原本運作良好的向量查詢中加入 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,000、s = 0.001 且 d = 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 掃描的成本模型只是個佔位符,所以你應該明確地做出選擇。
依過濾欄位進行分割。 如果你的過濾器幾乎總是同一個欄位,將它設為分割鍵並為每個分割區建立一個 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);
部分索引,用於少數熱門值。 索引本身的 WHERE 子句。適合兩三個大型租戶或單一 status = 'active' 述詞;超過幾十個就無法實用,因為每個都需要完整的 HNSW 建置。
CREATE INDEX chunks_active_hnsw ON chunks
USING hnsw (embedding vector_cosine_ops)
WHERE deleted_at IS NULL;
在交叉點之下強制使用精確計畫。 在 (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 背後的協定運行在三個物件上:一個帳號(開發者在特定供應商的租戶、組織或團隊)、一個服務(該供應商提供的項目,其方案與價格目錄),以及一個資源(被建立並回傳憑證的具體實例)。

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

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

在演講當時,該協定本身尚未公開。計畫是在今年夏天稍晚開放,並期望它能成為共享的標準。
佈建資源後仍會留下誰來支付的問題,而答案是一個共享的付款 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 等,只要用自然語言詢問代理它需要什麼;它會使用相同的底層指令,將所有東西整合到一個專案中,並共用一組環境變數。

https://render.com/blog/stripe-an-orchestration-layer-not-a-platform
https://www.worldprogramming.org/posts/stripe-an-orchestration-layer-not-a-platform-av4nrm
![]()
在原本運作良好的向量查詢中加入 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,000、s = 0.001 且 d = 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 掃描的成本模型只是個佔位符,所以你應該明確地做出選擇。
依過濾欄位進行分割。 如果你的過濾器幾乎總是同一個欄位,將它設為分割鍵並為每個分割區建立一個 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);
部分索引,用於少數熱門值。 索引本身的 WHERE 子句。適合兩三個大型租戶或單一 status = 'active' 述詞;超過幾十個就無法實用,因為每個都需要完整的 HNSW 建置。
CREATE INDEX chunks_active_hnsw ON chunks
USING hnsw (embedding vector_cosine_ops)
WHERE deleted_at IS NULL;
在交叉點之下強制使用精確計畫。 在 (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
![]()
在 Android 上實現裝置端 AI 的困難之處不在於撰寫推論呼叫,而是相同的呼叫必須在數千種系統單晶片與驅動程式組合上執行。其中有些能優雅地加速你的模型,有些會在不告知的情況下退回 CPU,還有少數會產生錯誤的數值。有效的策略是在執行階段探測並記住結果。
在 iOS 上,你只需針對單一廠商的少數幾代晶片。而在 Android 上,你必須面對多家晶片廠商、各自多個世代,以及由裝置製造商按照自己的時程所出貨的驅動程式堆疊。兩支搭載相同旗艦晶片的手機,可能因為其中一支使用較舊的 GPU 驅動程式而表現不同。
由此導出的規則是:永遠不要依據裝置型號字串來決定是否加速。依裝置名稱建立的白名單會在一個版本週期內過期,無法涵蓋你出貨後才推出的裝置,而且它們是在有實際測量可用的情況下仍做出的猜測。應該在裝置上實際嘗試一次,並快取結果。
大致上有三種可用的抽象層級,選擇正確的層級主要取決於你需要多少控制權:
哪些加速路徑目前有效、哪些已被棄用而改用廠商 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。
使用 SoC 識別碼與 OS build 來記錄探測結果。幾週後,這些記錄會成為你自己安裝基礎唯一準確的地圖,其價值遠超過任何公開的相容性矩陣。
與其設計一個必須在所有地方都能運作的體驗,不如定義兩到三個等級,讓探測來為裝置指派等級:
| 等級 | 描述 |
|---|---|
| accelerated | 探測找到有效且明顯更快的路徑。完整功能:更大的模型、更長的上下文、即時處理。 |
| cpu-capable | 沒有加速,但 CPU 推論能在互動預算內完成。較小的模型、批次處理或依需求執行而非即時。 |
| unsupported | CPU 推論太慢,或記憶體不允許載入模型。功能被隱藏或改由遠端提供。隱藏它是誠實的做法;一個需要九秒的功能比沒有功能更糟糕。 |
透過實際測量來決定,意味著隨著你的安裝基礎變化,等級指派仍能保持正確,也意味著在你推出後才發布的新手機能自動獲得良好的體驗。這也為你提供了一個乾淨的地方來放置緊急停止開關:如果某個 delegate 在部分使用者群上被發現有問題,你可以透過遠端旗標將該群組降級到 CPU,而不是推出緊急版本。此模式與 模型與提示的機能旗標 中描述的模式相同。
onTrimMemory。在背景 session 中持續保留模型是導致應用程式被殺掉、然後被怪罪冷啟動變慢的常見原因。如果 unsupported 等級退回至託管模型,則這兩條路徑在呼叫端必須可以互換——Multigrid 在各提供者之間暴露單一 API,並提供每次請求的成本與延遲,這讓遠端分支保持為單一實作,而非針對每個可能路由到的廠商各有一個。
https://dev.to/multigrid/on-device-ai-on-android-delegates-npus-and-fragmentation-2o2p
![]()
生產日誌是你能擁有的最佳訓練資料,也是最危險的。它們是真實分布中的真實輸入——但充滿了你目前模型的輸出,而這正是你絕對不能用來訓練的內容。
日誌傾印不是資料集。百分之九十的生產流量是模型已經在正確執行的事,訓練這些內容不會教會你任何原本沒有的知識。值得納入的範例,是那些攜帶目前模型所缺乏資訊的案例。
選擇給了你輸入。目標則是另一個問題,而且只有三種來源,依品質與成本由高到低排序。
| 目標來源 | 描述 |
|---|---|
| 人類編輯的輸出 | 如果你的產品已經有審核步驟則免費,若沒有則極為昂貴。品質最高,也是唯一能反映使用者真正想要什麼、而非模型認為他們想要什麼的來源。 |
| 更強模型(已過濾) | 前沿模型的回答,僅在通過驗證器或評分標準時保留。便宜且可擴展。會繼承教師的盲點,並受限於教師的使用條款——見 /learn/fine-tuning-licenses。 |
| 你自己模型的輸出 | 免費,但未經過濾幾乎總是錯誤的。你無法透過模仿自己來學習原本沒有的行為。只有在有嚴格外部過濾器時才合理——例如通過的單元測試、已對帳的交易。 |
直覺是把一切都正規化:去除空白、統一大小寫、統一日期格式、將樣板內容模板化。大部分都要抵抗。推論時的輸入會以日誌中同樣混亂的方式呈現,而只在乾淨輸入上訓練的模型,處理混亂輸入時會表現更差。
真正需要做的事:
max_tokens 而非結束序列 token 停止的完成,都是在教導模型在中途句子停止。請根據結束原因而非長度來過濾。以隨機抽樣分割後,出現 400 次的模板化請求會同時出現在訓練集與測試集。你的評估會因此測量到記憶而誤報為泛化,數字會非常漂亮,直到生產環境與其不符為止。
請在分割之前先去重,並根據分組鍵而非單列來分割——依租戶、文件、工作階段、模板 ID 來分割。如果兩個範例有可能來自同一個底層事物,它們就應該屬於分割的同一側。
隨機分割也會讓模型在下個月資料上訓練,卻在上個月資料上測試。對於任何輸入分布會漂移的情況——而產品流量總是會漂移——請依時間分割:在截止時間之前的所有資料用於訓練,之後的所有資料用於測試。得到的數字會較低,但才是能預測生產環境的那一個。
這是專屬於日誌衍生資料的陷阱,而且它很安靜。你的日誌包含目前模型的輸出。如果你拿來訓練並部署,下個月的日誌就會包含新模型的輸出,然後你又拿來訓練。每一個世代都是在擬合前一個世代,而不是擬合使用者真正想要的內容。
可見的症狀是收窄:輸出變得更一致、避險消失、模型發展出語言上的抽搐,而稀有但有效的回應形式則完全消失。這與 Shumailov 等人在 2024 年《Nature》論文中描述的合成資料動態相同,只是透過日誌管線的後門進入。
防禦方式是硬性規則而非警覺:只有當目標來自模型外部——人類編輯、通過的測試、已對帳的交易、不同的模型——時,範例才能進入資料集。將來源記錄為一個欄位,並讓訓練作業拒絕沒有來源的列。
最後一步是機械性的,也是許多執行在無聲中失敗的地方。有兩個規則。
使用基礎模型自己的聊天模板。 不是看起來像的那個。特殊 token、角色標記與空白字元都很重要,如果訓練時使用的模板與服務堆疊套用的模板不同,適配器就會被要求在格式偏移中進行無謂的泛化。在啟動執行前,請渲染一個範例並印出可見的特殊 token。
在提示上遮罩損失。 許多訓練腳本預設會計算整個序列的損失,因此模型同時被訓練去預測你的指令以及它的答案。對於指令微調,你幾乎總是想要僅完成部分的損失:
tokens <|system|> You are ... <|user|> Summarise ... <|assistant|> The report ... <|eot|>
labels -100 -100 -100 ... -100 -100 -100 ... The report ... <|eot|>
________________ masked ________________/ ____ supervised ____/
Enter fullscreen mode
Exit fullscreen mode
結束回合的 token 必須位於受監督的區域內。如果遮罩它,模型就永遠不會被訓練如何停止,這會產生最常見的「微調搞壞了一切」報告:一個正確回答後卻不斷持續下去的模型。
https://dev.to/multigrid/building-a-fine-tuning-dataset-from-production-logs-e95
https://www.worldprogramming.org/posts/building-a-fine-tuning-dataset-from-production-logs-y3ppn0
![]()

發佈於 2026 年 8 月 5 日,東部時間晚上 8:00
住在明尼蘇達州鄉村的 Brian Burgess 在 IT 領域工作了 10 年,之後才找到他對消費者科技寫作的熱情。
Brian 在 2000 年取得電腦與網路科技學位。他擔任過各種 IT 職位,包括技術支援、為 IBM 安裝路由器、資料庫管理,以及其他網路管理相關角色。
他已經擔任個人科技記者 18 年,撰寫過數千篇關於 Microsoft 產品與服務、Android、iOS、串流媒體等主題的文章。他曾為知名科技網站撰稿,包括 XDA、InformationWeek、How-To Geek、Byte、groovyPost 等。
當他離開鍵盤時,你會聽到他在彈吉他、戶外工作,以及觀看《星艦迷航記》。
你是否曾好奇為什麼你的筆電運行起來比預期還熱?�