FIX: Add workaround to pgvector HNSW search limitations (#1133)

From [pgvector/pgvector](https://github.com/pgvector/pgvector) README

> With approximate indexes, filtering is applied after the index is scanned. If a condition matches 10% of rows, with HNSW and the default hnsw.ef_search of 40, only 4 rows will match on average. For more rows, increase hnsw.ef_search.
> 
> Starting with 0.8.0, you can enable [iterative index scans](https://github.com/pgvector/pgvector#iterative-index-scans), which will automatically scan more of the index when needed.


Since we are stuck on 0.7.0 we are going the first option for now.
This commit is contained in:
Rafael dos Santos Silva 2025-02-19 16:30:01 -03:00 committed by GitHub
parent 3a755ca883
commit 37bf160d26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 60 additions and 21 deletions

View File

@ -5,13 +5,15 @@ module DiscourseAi
def initialize(input, user) def initialize(input, user)
@user = user @user = user
@text = input[:text] @text = input[:text]
@vector = DiscourseAi::Embeddings::Vector.instance
@schema = DiscourseAi::Embeddings::Schema.for(Topic)
end end
def categories def categories
return [] if @text.blank? return [] if @text.blank?
return [] if !DiscourseAi::Embeddings.enabled? return [] if !DiscourseAi::Embeddings.enabled?
candidates = nearest_neighbors(limit: 100) candidates = nearest_neighbors
return [] if candidates.empty? return [] if candidates.empty?
candidate_ids = candidates.map(&:first) candidate_ids = candidates.map(&:first)
@ -40,6 +42,9 @@ module DiscourseAi
} }
end end
.map do |c| .map do |c|
# Note: <#> returns the negative inner product since Postgres only supports ASC order index scans on operators
c[:score] = (c[:score] + 1).abs if @vector.vdef.pg_function = "<#>"
c[:score] = 1 / (c[:score] + 1) # inverse of the distance c[:score] = 1 / (c[:score] + 1) # inverse of the distance
c c
end end
@ -72,6 +77,9 @@ module DiscourseAi
.with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } } .with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } }
.flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } } .flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } }
.map do |c| .map do |c|
# Note: <#> returns the negative inner product since Postgres only supports ASC order index scans on operators
c[:score] = (c[:score] + 1).abs if @vector.vdef.pg_function = "<#>"
c[:score] = 1 / (c[:score] + 1) # inverse of the distance c[:score] = 1 / (c[:score] + 1) # inverse of the distance
c c
end end
@ -91,11 +99,8 @@ module DiscourseAi
private private
def nearest_neighbors(limit: 100) def nearest_neighbors(limit: 50)
vector = DiscourseAi::Embeddings::Vector.instance raw_vector = @vector.vector_from(@text)
schema = DiscourseAi::Embeddings::Schema.for(Topic)
raw_vector = vector.vector_from(@text)
muted_category_ids = nil muted_category_ids = nil
if @user.present? if @user.present?
@ -106,7 +111,7 @@ module DiscourseAi
).pluck(:category_id) ).pluck(:category_id)
end end
schema @schema
.asymmetric_similarity_search(raw_vector, limit: limit, offset: 0) do |builder| .asymmetric_similarity_search(raw_vector, limit: limit, offset: 0) do |builder|
builder.join("topics t on t.id = topic_id") builder.join("topics t on t.id = topic_id")
unless muted_category_ids.empty? unless muted_category_ids.empty?

View File

@ -15,6 +15,8 @@ module DiscourseAi
EMBEDDING_TARGETS = %w[topics posts document_fragments] EMBEDDING_TARGETS = %w[topics posts document_fragments]
EMBEDDING_TABLES = [TOPICS_TABLE, POSTS_TABLE, RAG_DOCS_TABLE] EMBEDDING_TABLES = [TOPICS_TABLE, POSTS_TABLE, RAG_DOCS_TABLE]
DEFAULT_HNSW_EF_SEARCH = 40
MissingEmbeddingError = Class.new(StandardError) MissingEmbeddingError = Class.new(StandardError)
class << self class << self
@ -132,6 +134,8 @@ module DiscourseAi
end end
def asymmetric_similarity_search(embedding, limit:, offset:) def asymmetric_similarity_search(embedding, limit:, offset:)
before_query = hnsw_search_workaround(limit)
builder = DB.build(<<~SQL) builder = DB.build(<<~SQL)
WITH candidates AS ( WITH candidates AS (
SELECT SELECT
@ -153,7 +157,7 @@ module DiscourseAi
ORDER BY ORDER BY
embeddings::halfvec(#{dimensions}) #{pg_function} '[:query_embedding]'::halfvec(#{dimensions}) embeddings::halfvec(#{dimensions}) #{pg_function} '[:query_embedding]'::halfvec(#{dimensions})
LIMIT :limit LIMIT :limit
OFFSET :offset OFFSET :offset;
SQL SQL
builder.where( builder.where(
@ -171,18 +175,24 @@ module DiscourseAi
candidates_limit = limit * 2 candidates_limit = limit * 2
end end
ActiveRecord::Base.transaction do
DB.exec(before_query) if before_query.present?
builder.query( builder.query(
query_embedding: embedding, query_embedding: embedding,
candidates_limit: candidates_limit, candidates_limit: candidates_limit,
limit: limit, limit: limit,
offset: offset, offset: offset,
) )
end
rescue PG::Error => e rescue PG::Error => e
Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}") Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}")
raise MissingEmbeddingError raise MissingEmbeddingError
end end
def symmetric_similarity_search(record) def symmetric_similarity_search(record)
limit = 200
before_query = hnsw_search_workaround(limit)
builder = DB.build(<<~SQL) builder = DB.build(<<~SQL)
WITH le_target AS ( WITH le_target AS (
SELECT SELECT
@ -210,7 +220,7 @@ module DiscourseAi
le_target le_target
LIMIT 1 LIMIT 1
) )
LIMIT 200 LIMIT #{limit}
) AS widenet ) AS widenet
ORDER BY ORDER BY
embeddings::halfvec(#{dimensions}) #{pg_function} ( embeddings::halfvec(#{dimensions}) #{pg_function} (
@ -220,14 +230,17 @@ module DiscourseAi
le_target le_target
LIMIT 1 LIMIT 1
) )
LIMIT 100; LIMIT #{limit / 2};
SQL SQL
builder.where("model_id = :vid AND strategy_id = :vsid") builder.where("model_id = :vid AND strategy_id = :vsid")
yield(builder) if block_given? yield(builder) if block_given?
ActiveRecord::Base.transaction do
DB.exec(before_query) if before_query.present?
builder.query(vid: vector_def.id, vsid: vector_def.strategy_id, target_id: record.id) builder.query(vid: vector_def.id, vsid: vector_def.strategy_id, target_id: record.id)
end
rescue PG::Error => e rescue PG::Error => e
Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}") Rails.logger.error("Error #{e} querying embeddings for model #{vector_def.display_name}")
raise MissingEmbeddingError raise MissingEmbeddingError
@ -259,6 +272,13 @@ module DiscourseAi
private private
def hnsw_search_workaround(limit)
threshold = limit * 2
return "" if threshold < DEFAULT_HNSW_EF_SEARCH
"SET LOCAL hnsw.ef_search = #{threshold};"
end
delegate :dimensions, :pg_function, to: :vector_def delegate :dimensions, :pg_function, to: :vector_def
end end
end end

View File

@ -3,6 +3,7 @@
RSpec.describe "AI Composer helper", type: :system, js: true do RSpec.describe "AI Composer helper", type: :system, js: true do
fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) } fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) }
fab!(:non_member_group) { Fabricate(:group) } fab!(:non_member_group) { Fabricate(:group) }
fab!(:embedding_definition)
before do before do
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
@ -243,7 +244,10 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
end end
context "when suggesting the category with AI category suggester" do context "when suggesting the category with AI category suggester" do
before { SiteSetting.ai_embeddings_enabled = true } before do
SiteSetting.ai_embeddings_selected_model = embedding_definition.id
SiteSetting.ai_embeddings_enabled = true
end
it "updates the category with the suggested category" do it "updates the category with the suggested category" do
response = response =
@ -274,7 +278,10 @@ RSpec.describe "AI Composer helper", type: :system, js: true do
end end
context "when suggesting the tags with AI tag suggester" do context "when suggesting the tags with AI tag suggester" do
before { SiteSetting.ai_embeddings_enabled = true } before do
SiteSetting.ai_embeddings_selected_model = embedding_definition.id
SiteSetting.ai_embeddings_enabled = true
end
it "updates the tag with the suggested tag" do it "updates the tag with the suggested tag" do
response = response =

View File

@ -35,6 +35,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do
fab!(:cloud) { Fabricate(:tag) } fab!(:cloud) { Fabricate(:tag) }
fab!(:feedback) { Fabricate(:tag) } fab!(:feedback) { Fabricate(:tag) }
fab!(:review) { Fabricate(:tag) } fab!(:review) { Fabricate(:tag) }
fab!(:embedding_definition)
before do before do
Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user)
@ -80,7 +81,10 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end end
context "when suggesting categories with AI category suggester" do context "when suggesting categories with AI category suggester" do
before { SiteSetting.ai_embeddings_enabled = true } before do
SiteSetting.ai_embeddings_selected_model = embedding_definition.id
SiteSetting.ai_embeddings_enabled = true
end
skip "TODO: Category suggester only loading one category in test" do skip "TODO: Category suggester only loading one category in test" do
it "updates the category with the suggested category" do it "updates the category with the suggested category" do
@ -108,7 +112,10 @@ RSpec.describe "AI Post helper", type: :system, js: true do
end end
context "when suggesting tags with AI tag suggester" do context "when suggesting tags with AI tag suggester" do
before { SiteSetting.ai_embeddings_enabled = true } before do
SiteSetting.ai_embeddings_selected_model = embedding_definition.id
SiteSetting.ai_embeddings_enabled = true
end
it "update the tag with the suggested tag" do it "update the tag with the suggested tag" do
response = response =