Title
KB-scoped vector search (CLIP/DINO) can silently return incomplete results due to post-filter HNSW behavior on multi-tenant embeddings table
Summary
get_image_embedding_clip / get_image_embedding_dino in generate_query.py filter by knowledge_base_id via a join to knowledge_base_documents, then order by the HNSW-indexed <=> distance on knowledge_base_embeddings. Because pgvector applies WHERE-clause filtering after the HNSW index scan, and the filter column lives on a joined table rather than the embeddings table itself, the planner cannot push the KB filter into the ANN walk. This can silently return fewer than top_k results (or zero) for a knowledge base whose vectors aren't among the globally-nearest candidates in the shared, multi-tenant knowledge_base_embeddings table.
Root cause
knowledge_base_embeddings is shared across all knowledge bases; the only KB scoping is via a JOIN to knowledge_base_documents.knowledge_base_id.
- pgvector's HNSW index scans in distance order and returns up to
hnsw.ef_search candidates before any WHERE/JOIN filter is applied (confirmed against pgvector docs: "filtering is applied after the index is scanned").
ef_search is set via compute_ef_search() (generate_query.py:27), floored at 200 — but those 200 candidates are the globally-nearest vectors across all KBs, not the nearest vectors within the target KB.
- If a KB is a small slice of a large shared table, its true nearest neighbors may not appear in that global top-200, so the post-join filter can drop below
top_k results (or return none) even though matching rows exist.
hnsw.iterative_scan (pgvector ≥0.8, would auto-expand the scan until enough post-filter matches are found) is not configured anywhere in the codebase.
- pgvector's own guidance for this shape of problem is to index/filter on a column in the same table as the vector column — not achievable here since
knowledge_base_id lives on the joined knowledge_base_documents table, not on knowledge_base_embeddings.
Impact
- Correctness/recall bug, not a performance bug: queries can return incomplete or empty result sets without any error, for knowledge bases that are small relative to the total shared embeddings table.
- Severity scales with total row count in
knowledge_base_embeddings and the number/size distribution of tenants (KBs) sharing it — worse as the platform grows.
- Affects both
image_retrieve_clip and image_retrieve_dino in image_rag_retrieve.py.
Proposed fix (pick one, not mutually exclusive)
- Denormalize
knowledge_base_id onto knowledge_base_embeddings directly (avoid the join for filtering). This puts the filter column on the same table as the vector column, matching pgvector's documented recommendation, and enables:
- A plain b-tree index on
knowledge_base_embeddings.knowledge_base_id, and/or
- Partial HNSW indexes per hot/large KB (
... WHERE (knowledge_base_id = X)), if a small number of KBs dominate row count.
- Enable iterative index scans (
SET LOCAL hnsw.iterative_scan = relaxed_order) alongside the existing SET LOCAL hnsw.ef_search call in sql_alchemy_repository.py, so Postgres auto-expands the scan until enough post-filter matches are found. Requires confirming the deployed pgvector extension version is ≥0.8 (currently unpinned via ankane/pgvector:latest).
- Table partitioning of
knowledge_base_embeddings by knowledge_base_id, if the number of distinct KBs is manageable — likely overkill unless (1) proves insufficient.
Recommend starting with (1) + (2) together: denormalize for correctness and index efficiency, enable iterative scan as a safety net for recall.
References
- pgvector filtering docs: https://github.com/pgvector/pgvector#filtering
- Related: separate missing-index issue for
knowledge_base_documents.knowledge_base_id and knowledge_base_embeddings.document_id (join scan cost) — tracked separately.
Title
KB-scoped vector search (CLIP/DINO) can silently return incomplete results due to post-filter HNSW behavior on multi-tenant embeddings table
Summary
get_image_embedding_clip/get_image_embedding_dinoingenerate_query.pyfilter byknowledge_base_idvia a join toknowledge_base_documents, then order by the HNSW-indexed<=>distance onknowledge_base_embeddings. Because pgvector applies WHERE-clause filtering after the HNSW index scan, and the filter column lives on a joined table rather than the embeddings table itself, the planner cannot push the KB filter into the ANN walk. This can silently return fewer thantop_kresults (or zero) for a knowledge base whose vectors aren't among the globally-nearest candidates in the shared, multi-tenantknowledge_base_embeddingstable.Root cause
knowledge_base_embeddingsis shared across all knowledge bases; the only KB scoping is via a JOIN toknowledge_base_documents.knowledge_base_id.hnsw.ef_searchcandidates before any WHERE/JOIN filter is applied (confirmed against pgvector docs: "filtering is applied after the index is scanned").ef_searchis set viacompute_ef_search()(generate_query.py:27), floored at 200 — but those 200 candidates are the globally-nearest vectors across all KBs, not the nearest vectors within the target KB.top_kresults (or return none) even though matching rows exist.hnsw.iterative_scan(pgvector ≥0.8, would auto-expand the scan until enough post-filter matches are found) is not configured anywhere in the codebase.knowledge_base_idlives on the joinedknowledge_base_documentstable, not onknowledge_base_embeddings.Impact
knowledge_base_embeddingsand the number/size distribution of tenants (KBs) sharing it — worse as the platform grows.image_retrieve_clipandimage_retrieve_dinoinimage_rag_retrieve.py.Proposed fix (pick one, not mutually exclusive)
knowledge_base_idontoknowledge_base_embeddingsdirectly (avoid the join for filtering). This puts the filter column on the same table as the vector column, matching pgvector's documented recommendation, and enables:knowledge_base_embeddings.knowledge_base_id, and/or... WHERE (knowledge_base_id = X)), if a small number of KBs dominate row count.SET LOCAL hnsw.iterative_scan = relaxed_order) alongside the existingSET LOCAL hnsw.ef_searchcall insql_alchemy_repository.py, so Postgres auto-expands the scan until enough post-filter matches are found. Requires confirming the deployed pgvector extension version is ≥0.8 (currently unpinned viaankane/pgvector:latest).knowledge_base_embeddingsbyknowledge_base_id, if the number of distinct KBs is manageable — likely overkill unless (1) proves insufficient.Recommend starting with (1) + (2) together: denormalize for correctness and index efficiency, enable iterative scan as a safety net for recall.
References
knowledge_base_documents.knowledge_base_idandknowledge_base_embeddings.document_id(join scan cost) — tracked separately.