Add semantic package search via pgvector embeddings

Package text search now combines several strategies: exact name match,
name prefix promotion, compound word splitting ("sqlite browser" finds
"sqlitebrowser") and semantic nearest neighbor search over description
embeddings computed by an external embedding service (all-MiniLM-L6-v2,
384 dims) stored with the pgvector extension. Classic PostgreSQL
full-text search remains as fallback when the vector service is
unreachable. Gibberish queries without lexical overlap with the package
data return empty results instead of random matches.

Embeddings can be backfilled with bin/rails debshots:compute_vectors.

Also drops the unused lograge gem from the Gemfile.
This commit is contained in:
Christoph Haas 2026-08-22 22:07:56 +02:00
parent c7e8109bbd
commit 4948fe50e3
15 changed files with 662 additions and 125 deletions

View file

@ -1,7 +1,107 @@
# frozen_string_literal: true
require 'test_helper'
class PackageTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test 'embedding_text combines description and long description' do
package = packages(:firefox)
assert_equal "A web browser\nA program to browse web sites.", package.embedding_text
end
test 'embedding_text works with missing long description' do
package = packages(:firefox)
package.long_description = nil
assert_equal 'A web browser', package.embedding_text
end
test 'update_embedding! stores the vector returned by the vector service' do
vector = Array.new(384) { |i| i / 384.0 }
with_stubbed_method(Vectorizer, :embed, ->(_text) { vector }) do
package = packages(:vim)
assert package.update_embedding!
# The vector type stores float32 so allow small rounding differences
package.reload.embedding.to_a.each_with_index do |actual, i|
assert_in_delta vector[i], actual, 1e-6
end
end
end
test 'update_embedding! does nothing without description text' do
package = packages(:firefox)
package.description = nil
package.long_description = nil
with_stubbed_method(Vectorizer, :embed,
->(_text) { flunk 'should not call the vector service' }) do
assert_not package.update_embedding!
end
end
test 'nearest_to_text returns none for blank text' do
assert_empty Package.nearest_to_text('')
assert_empty Package.nearest_to_text(nil)
end
test 'name_starts_with matches name prefixes case-insensitively' do
assert_includes Package.name_starts_with('fir').map(&:name), packages(:firefox).name
assert_not_includes Package.name_starts_with('vim').map(&:name), packages(:firefox).name
assert_empty Package.name_starts_with('')
assert_empty Package.name_starts_with(nil)
end
test 'name_contains_all finds compound words split by spaces' do
assert_includes Package.name_contains_all(%w[fire fox]).map(&:name),
packages(:firefox).name
# all tokens must be present in the name
assert_empty Package.name_contains_all(%w[firefox zzzz])
assert_empty Package.name_contains_all([])
# single character tokens would flood results - ignore them
assert_empty Package.name_contains_all(['f'])
end
test 'lexical_overlap? pins the english dictionary regardless of server config' do
# Regression: with default_text_search_config = german (as on some
# servers) 'browser' stems to 'brows' which never matches the
# english-stemmed index.
connection = ActiveRecord::Base.connection
original = connection.select_value('SHOW default_text_search_config')
connection.execute("SET default_text_search_config = 'german'")
assert Package.lexical_overlap?('browser')
ensure
connection&.execute("SET default_text_search_config = '#{original}'")
end
test 'lexical_overlap? detects real words vs gibberish' do
# words present in fixture package descriptions
assert Package.lexical_overlap?('browser')
assert Package.lexical_overlap?('email client for vim syntax')
assert Package.lexical_overlap?('the browser') # stopwords are ignored
# gibberish, numbers and empty strings share no vocabulary
assert_not Package.lexical_overlap?('asdfgh zzzz')
assert_not Package.lexical_overlap?('123 !!!')
assert_not Package.lexical_overlap?('the') # stopword-only query
assert_not Package.lexical_overlap?('')
assert_not Package.lexical_overlap?(nil)
end
test 'nearest_to_text embeds the query and searches' do
vector = Array.new(384, 0.25)
embed_called_with = nil
with_stubbed_method(Vectorizer, :embed,
lambda { |text|
embed_called_with = text
vector
}) do
received = []
results = with_stubbed_method(Package, :nearest_neighbors,
lambda { |_field, received_vector, **_opts|
received << received_vector
[packages(:vim)]
}) do
Package.nearest_to_text('some search text')
end
assert_equal [packages(:vim)], results
assert_equal 'some search text', embed_called_with
assert_equal [vector], received
end
end
end