mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-06 17:30:20 +00:00
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:
parent
3a755ca883
commit
37bf160d26
@ -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?
|
||||||
|
@ -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
|
||||||
|
@ -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 =
|
||||||
|
@ -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 =
|
||||||
|
Loading…
x
Reference in New Issue
Block a user