![]()
在原本運作良好的向量查詢中加入 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