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

@ -41,8 +41,7 @@ gem 'mini_magick'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
# Structured logging for production
gem 'lograge'
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] # Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem 'kamal', require: false gem 'kamal', require: false
@ -124,6 +123,10 @@ gem 'will_paginate-foundation'
# Full-text search in PostgreSQL # Full-text search in PostgreSQL
gem 'pg_search' gem 'pg_search'
# Nearest neighbor search for PostgreSQL using the pgvector extension
# https://github.com/ankane/neighbor
gem 'neighbor'
# Use SLIM as our templating language # Use SLIM as our templating language
gem 'slim-rails' gem 'slim-rails'

View file

@ -224,11 +224,6 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0) logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.1) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
@ -255,6 +250,8 @@ GEM
minitest (>= 5.0, < 7) minitest (>= 5.0, < 7)
ruby-progressbar ruby-progressbar
msgpack (1.8.0) msgpack (1.8.0)
neighbor (1.2.0)
activerecord (>= 7.2)
nenv (0.3.0) nenv (0.3.0)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
@ -427,8 +424,6 @@ GEM
regexp_parser (2.12.0) regexp_parser (2.12.0)
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
responders (3.2.0) responders (3.2.0)
actionpack (>= 7.0) actionpack (>= 7.0)
railties (>= 7.0) railties (>= 7.0)
@ -585,10 +580,10 @@ DEPENDENCIES
jbuilder jbuilder
kamal kamal
listen (~> 3.5) listen (~> 3.5)
lograge
mini_magick mini_magick
minitest-rails minitest-rails
minitest-reporters minitest-reporters
neighbor
omniauth omniauth
omniauth-rails_csrf_protection omniauth-rails_csrf_protection
omniauth_openid_connect omniauth_openid_connect

View file

@ -15,6 +15,30 @@ https://salsa.debian.org/debian/debshots/-/issues
Check out the doc/README.Development.md Check out the doc/README.Development.md
## Package search
The package search combines several strategies, tried in this order:
1. **Exact package name**: Searching for `vim` shows exactly this
package without calling any external service.
2. **Name prefix matches**: Packages whose name starts with the query
rank first, e.g. searching for `sqlite` promotes `sqlite3`,
`sqlitebrowser` and friends above all other results.
3. **Compound word splitting**: Packages whose name contains *all*
query tokens match too, so `sqlite browser` finds `sqlitebrowser`.
4. **Semantic search**: The query is embedded via the vector service
(all-MiniLM-L6-v2, 384 dimensions) and the nearest neighbors among
the package description embeddings are returned, using the pgvector
extension with an HNSW cosine index. Embeddings are computed from
the package description and long description and can be (re-)built
with `bin/rails debshots:compute_vectors`.
5. **Full-text fallback**: If the vector service cannot be reached,
classic PostgreSQL full-text search takes over.
Queries that share no vocabulary with the package data (gibberish,
keyboard mash) return an honest empty result instead of random
semantic matches.
## Deployment ## Deployment
Read the doc/README.Installation.md Read the doc/README.Installation.md

View file

@ -362,7 +362,7 @@ class PackagesController < ApplicationController
# text search # text search
if params[:search].present? if params[:search].present?
logger.debug "Searching for #{params[:search]}" logger.debug "Searching for #{params[:search]}"
packages = packages.general_search(params[:search]) packages = search_packages(params[:search])
end end
case params[:show] case params[:show]
@ -378,6 +378,51 @@ class PackagesController < ApplicationController
packages packages
end end
# Text search for packages: an exact package name match wins without
# touching the vector service. Otherwise packages whose name starts
# with the query (or contains all query tokens, catching compound
# words like "sqlite browser" -> "sqlitebrowser") are promoted above
# the semantic (vector) nearest neighbor results. Classic PostgreSQL
# full-text search kicks in when the vector service cannot be reached.
MAX_PROMOTED_NAME_MATCHES = 8
MAX_SEMANTIC_RESULTS = 100
private_constant :MAX_PROMOTED_NAME_MATCHES, :MAX_SEMANTIC_RESULTS
def search_packages(query)
exact_match = Package.find_by(name: query)
return Package.where(id: exact_match.id) if exact_match
tokens = query.to_s.split(/\s+/)
name_matches = (
Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES) +
Package.name_contains_all(tokens).limit(MAX_PROMOTED_NAME_MATCHES)
).uniq(&:id)
results = (name_matches + semantic_results(query)).uniq(&:id)
# Queries that share no vocabulary with our package data can produce
# no meaningful results. Skip the vector service round-trip and show
# an honest empty result instead of random packages.
results.empty? ? Package.none : results
end
# Semantic (vector) nearest neighbor search with a full-text fallback
# for when the external embedding service is unreachable.
def semantic_results(query)
unless Package.lexical_overlap?(query)
logger.debug "Query '#{query}' has no lexical overlap with package data"
return []
end
begin
Package.nearest_to_text(query, limit: MAX_SEMANTIC_RESULTS).to_a
rescue Vectorizer::Error => e
logger.error "Semantic search failed: #{e.message}"
flash.now['error'] = 'Semantic search is unavailable right now. Showing full-text matches.'
Package.general_search(query).limit(100).to_a
end
end
# Store a random identifier and the client's IP address in the session # Store a random identifier and the client's IP address in the session
# for later identification. # for later identification.
# def create_user_token # def create_user_token

View file

@ -2,6 +2,9 @@ require 'open-uri' # allows to load URLs using open()
require 'json' require 'json'
require 'deb_importer' require 'deb_importer'
# Search and embedding logic keeps accumulating here; the class is
# cohesive enough that splitting it is not worth the indirection.
# rubocop:disable Metrics/ClassLength
class Package < ApplicationRecord class Package < ApplicationRecord
# PostgreSQL-based full-text search: # PostgreSQL-based full-text search:
# https://github.com/Casecommons/pg_search # https://github.com/Casecommons/pg_search
@ -26,6 +29,11 @@ class Package < ApplicationRecord
inverse_of: :package, inverse_of: :package,
dependent: :destroy dependent: :destroy
# Nearest neighbor search using pgvector. The "embedding" column stores
# a vector computed from description + long_description by an external
# web service (see Vectorizer and lib/tasks/vectorize_packages.rake).
has_neighbors :embedding
# default_scope { # default_scope {
# order('name ASC') # order('name ASC')
# } # }
@ -40,6 +48,94 @@ class Package < ApplicationRecord
long_description.split(/\n\.\n/).first if long_description long_description.split(/\n\.\n/).first if long_description
end end
# The text that the embedding vector is computed from: description
# and long description combined.
def embedding_text
[description, long_description].compact.join("\n")
end
# Compute and store the embedding vector for this package by asking
# the external vector service. Raises Vectorizer::Error on failure.
# Returns true if the vector was saved.
def update_embedding!
text = embedding_text
return false if text.blank?
self.embedding = Vectorizer.embed(text)
save!
end
# Return a relation of packages whose stored embeddings are closest to
# the given query vector (an array of floats). Records get a
# "neighbor_distance" attribute added (cosine distance, lower is better).
# The returned relation can still be chained (e.g. filtered or paginated).
#
# Only returns packages that already have an embedding computed.
def self.nearest_to_vector(vector, limit: nil)
neighbors = nearest_neighbors(:embedding, vector, distance: 'cosine')
limit ? neighbors.limit(limit) : neighbors
end
# Turn a free-text search string into a vector via the external web
# service and find the closest packages.
def self.nearest_to_text(text, limit: nil)
return none if text.blank?
nearest_to_vector(Vectorizer.embed(text), limit: limit)
end
# Packages whose name starts with the given string - used to promote
# obvious matches like "sqlite3" for a "sqlite" search.
def self.name_starts_with(text)
return none if text.blank?
where('name ILIKE ? ESCAPE ?', "#{escape_for_like(text)}%", '\\').order(:name)
end
# Packages whose name contains all given tokens as substrings. Catches
# compound words split by the user, e.g. "sqlite browser" finding the
# "sqlitebrowser" package.
def self.name_contains_all(tokens)
tokens = tokens.select { |token| token.length >= 2 }
return none if tokens.empty?
scope = self
tokens.each do |token|
scope = scope.where('name ILIKE ? ESCAPE ?', "%#{escape_for_like(token)}%", '\\')
end
scope.order(:name)
end
def self.escape_for_like(text)
# Escape LIKE wildcards so that package name characters like "+" or "."
# cannot break the pattern
text.gsub(/[\\%_]/) { |char| "\\#{char}" }
end
# Check whether the given text shares at least one lexeme with any
# package name/description in the database. Queries without such an
# overlap (gibberish, keyboard mash, unsupported languages) can
# neither match full-text nor produce meaningful semantic results -
# the embedding model maps unknown tokens close to the corpus
# centroid, making them look like good matches.
def self.lexical_overlap?(text)
tokens = text.to_s.split(/\s+/).select { |t| t.match?(/[[:alpha:]]{2,}/) }
return false if tokens.empty?
# Pin the same text search dictionary that the packages_fts index
# uses - the server-wide default can be anything.
tsquery = tokens.map do |token|
sanitize_sql(["plainto_tsquery('english', ?)", token])
end.join(' || ')
# The expression must match the packages_fts GIN index definition
# so that Postgres can use the index.
where(
"setweight(to_tsvector('english', coalesce(name::text, '')), 'A') || " \
"setweight(to_tsvector('english', coalesce(description::text, '')), 'B') || " \
"setweight(to_tsvector('english', coalesce(long_description, '')), 'C') @@ (#{tsquery})"
).exists?
end
# Return a query of all packages that have screenshots # Return a query of all packages that have screenshots
def self.with_screenshots def self.with_screenshots
# Query for all packages who's ID appears in a screenshot's "package_id" field # Query for all packages who's ID appears in a screenshot's "package_id" field

View file

@ -14,7 +14,7 @@
- if @packages.any? - if @packages.any?
- if @view_style==:grid - if @view_style==:grid
.grid-x.grid-margin-x.small-up-1.medium-up-2.large-up-3 data-equalizer=true data-equalize-on="medium" .grid-x.grid-margin-x.small-up-1.medium-up-2.large-up-3 data-equalizer=true data-equalize-on="medium"
- @packages.all.each do |pkg| - @packages.each do |pkg|
.cell.pkgcard data-equalizer-watch=true .cell.pkgcard data-equalizer-watch=true
a.black href=package_path(name: pkg.name) a.black href=package_path(name: pkg.name)
.image .image

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Enable will_paginate's Array#paginate so that merged search results
# (name prefix matches + semantic results) can be paginated like
# ActiveRecord relations.
require 'will_paginate/array'

View file

@ -0,0 +1,5 @@
class InstallNeighborVector < ActiveRecord::Migration[8.1]
def change
enable_extension "vector"
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
# Adds a vector column to store text embeddings of the package
# description and long description. The vectors are computed by an
# external web service and used for nearest neighbor search.
#
# The pgvector extension is enabled by the InstallNeighborVector migration.
class AddEmbeddingToPackages < ActiveRecord::Migration[8.1]
def change
add_column :packages, :embedding, :vector, limit: 384
# HNSW index for fast approximate nearest neighbor searches
add_index :packages, :embedding, using: :hnsw, opclass: :vector_cosine_ops
end
end

218
db/schema.rb generated
View file

@ -10,133 +10,131 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2023_11_26_173151) do ActiveRecord::Schema[8.1].define(version: 2026_08_21_210000) do
# These are extensions that must be enabled to support this database # These are extensions that must be enabled in order to support this database
enable_extension 'pg_catalog.plpgsql' enable_extension "pg_catalog.plpgsql"
enable_extension "vector"
create_table 'action_mailbox_inbound_emails', force: :cascade do |t| create_table "action_mailbox_inbound_emails", force: :cascade do |t|
t.integer 'status', default: 0, null: false t.datetime "created_at", null: false
t.string 'message_id', null: false t.string "message_checksum", null: false
t.string 'message_checksum', null: false t.string "message_id", null: false
t.datetime 'created_at', null: false t.integer "status", default: 0, null: false
t.datetime 'updated_at', null: false t.datetime "updated_at", null: false
t.index ['message_id', 'message_checksum'], t.index ["message_id", "message_checksum"], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true
name: 'index_action_mailbox_inbound_emails_uniqueness', unique: true
end end
create_table 'action_text_rich_texts', force: :cascade do |t| create_table "action_text_rich_texts", force: :cascade do |t|
t.string 'name', null: false t.text "body"
t.text 'body' t.datetime "created_at", null: false
t.string 'record_type', null: false t.string "name", null: false
t.bigint 'record_id', null: false t.bigint "record_id", null: false
t.datetime 'created_at', null: false t.string "record_type", null: false
t.datetime 'updated_at', null: false t.datetime "updated_at", null: false
t.index ['record_type', 'record_id', 'name'], name: 'index_action_text_rich_texts_uniqueness', t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
unique: true
end end
create_table 'active_storage_attachments', force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string 'name', null: false t.bigint "blob_id", null: false
t.string 'record_type', null: false t.datetime "created_at", precision: nil, null: false
t.bigint 'record_id', null: false t.string "name", null: false
t.bigint 'blob_id', null: false t.bigint "record_id", null: false
t.datetime 'created_at', precision: nil, null: false t.string "record_type", null: false
t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ['record_type', 'record_id', 'name', 'blob_id'], t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
name: 'index_active_storage_attachments_uniqueness', unique: true
end end
create_table 'active_storage_blobs', force: :cascade do |t| create_table "active_storage_blobs", force: :cascade do |t|
t.string 'key', null: false t.bigint "byte_size", null: false
t.string 'filename', null: false t.string "checksum"
t.string 'content_type' t.string "content_type"
t.text 'metadata' t.datetime "created_at", precision: nil, null: false
t.bigint 'byte_size', null: false t.string "filename", null: false
t.string 'checksum' t.string "key", null: false
t.datetime 'created_at', precision: nil, null: false t.text "metadata"
t.string 'service_name', null: false t.string "service_name", null: false
t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end end
create_table 'active_storage_variant_records', force: :cascade do |t| create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint 'blob_id', null: false t.bigint "blob_id", null: false
t.string 'variation_digest', null: false t.string "variation_digest", null: false
t.index ['blob_id', 'variation_digest'], t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
name: 'index_active_storage_variant_records_uniqueness', unique: true
end end
create_table 'logs', id: :serial, force: :cascade do |t| create_table "logs", id: :serial, force: :cascade do |t|
t.string 'message' t.datetime "created_at", precision: nil, null: false
t.string 'level' t.inet "ip_address"
t.string 'section' t.string "level"
t.inet 'ip_address' t.string "message"
t.datetime 'created_at', precision: nil, null: false t.integer "package_id"
t.datetime 'updated_at', precision: nil, null: false t.integer "screenshot_id"
t.integer 'user_id' t.string "section"
t.integer 'package_id' t.datetime "updated_at", precision: nil, null: false
t.integer 'screenshot_id' t.integer "user_id"
end end
create_table 'packages', id: :serial, force: :cascade do |t| create_table "packages", id: :serial, force: :cascade do |t|
t.string 'name', null: false t.datetime "created_at", precision: nil
t.string 'description', limit: 80 t.string "description", limit: 80
t.string 'section', limit: 50 t.vector "embedding", limit: 384
t.string 'maintainer', limit: 100 t.string "homepage", limit: 400
t.string 'maintainer_email', limit: 100 t.text "long_description"
t.string 'homepage', limit: 400 t.string "maintainer", limit: 100
t.string 'version', limit: 200 t.string "maintainer_email", limit: 100
t.text 'long_description' t.string "name", null: false
t.string 'origin', limit: 80 t.string "origin", limit: 80
t.datetime 'created_at', precision: nil t.string "section", limit: 50
t.datetime 'updated_at', precision: nil t.datetime "updated_at", precision: nil
t.integer 'visits', default: 0 t.string "version", limit: 200
t.index "(((setweight(to_tsvector('english'::regconfig, COALESCE((name)::text, ''::text)), 'A'::\"char\") || setweight(to_tsvector('english'::regconfig, COALESCE((description)::text, ''::text)), 'B'::\"char\")) || setweight(to_tsvector('english'::regconfig, COALESCE(long_description, ''::text)), 'C'::\"char\")))", t.integer "visits", default: 0
name: 'packages_fts', using: :gin t.index "(((setweight(to_tsvector('english'::regconfig, COALESCE((name)::text, ''::text)), 'A'::\"char\") || setweight(to_tsvector('english'::regconfig, COALESCE((description)::text, ''::text)), 'B'::\"char\")) || setweight(to_tsvector('english'::regconfig, COALESCE(long_description, ''::text)), 'C'::\"char\")))", name: "packages_fts", using: :gin
t.unique_constraint ['name'], name: 'packages_name_key' t.index ["embedding"], name: "index_packages_on_embedding", opclass: :vector_cosine_ops, using: :hnsw
t.unique_constraint ["name"], name: "packages_name_key"
end end
create_table 'screenshots', id: :serial, force: :cascade do |t| create_table "screenshots", id: :serial, force: :cascade do |t|
t.integer 'package_id' t.boolean "approved", default: false, null: false
t.string 'version', limit: 50 t.datetime "created_at", precision: nil
t.datetime 'created_at', precision: nil t.text "description"
t.string 'uploaderhash', limit: 72 t.boolean "hidden", default: false
t.boolean 'approved', default: false, null: false t.string "image_fingerprint"
t.text 'description' t.integer "package_id"
t.datetime 'updated_at', precision: nil t.text "simage_data"
t.string 'image_fingerprint' t.datetime "updated_at", precision: nil
t.integer 'user_id', default: 0 t.string "uploaderhash", limit: 72
t.text 'simage_data' t.inet "uploaderip"
t.inet 'uploaderip' t.integer "user_id", default: 0
t.boolean 'hidden', default: false t.string "version", limit: 50
t.index ['id', 'approved'], name: 'id_approved' t.index ["id", "approved"], name: "id_approved"
t.index ['id', 'uploaderhash'], name: 'id_uploaderhash' t.index ["id", "uploaderhash"], name: "id_uploaderhash"
end end
create_table 'users', id: :serial, force: :cascade do |t| create_table "users", id: :serial, force: :cascade do |t|
t.text 'name' t.boolean "admin_role", default: false
t.datetime 'created_at', precision: nil, null: false t.integer "approved_screenshots", default: 0
t.datetime 'updated_at', precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.string 'email', default: '', null: false t.datetime "current_sign_in_at", precision: nil
t.string 'encrypted_password', default: '', null: false t.inet "current_sign_in_ip"
t.integer 'sign_in_count', default: 0, null: false t.string "email", default: "", null: false
t.datetime 'current_sign_in_at', precision: nil t.string "encrypted_password", default: "", null: false
t.datetime 'last_sign_in_at', precision: nil t.integer "failed_attempts", default: 0, null: false
t.inet 'current_sign_in_ip' t.datetime "last_sign_in_at", precision: nil
t.inet 'last_sign_in_ip' t.inet "last_sign_in_ip"
t.integer 'failed_attempts', default: 0, null: false t.datetime "locked_at", precision: nil
t.string 'unlock_token' t.boolean "moderator_role", default: false
t.datetime 'locked_at', precision: nil t.text "name"
t.string 'provider' t.string "provider"
t.string 'uid' t.boolean "pseudo", default: false
t.boolean 'admin_role', default: false t.integer "rejected_screenshots", default: 0
t.boolean 'moderator_role', default: false t.integer "sign_in_count", default: 0, null: false
t.boolean 'pseudo', default: false t.string "uid"
t.integer 'approved_screenshots', default: 0 t.string "unlock_token"
t.integer 'rejected_screenshots', default: 0 t.datetime "updated_at", precision: nil, null: false
t.index ['email', 'provider'], name: 'index_users_on_email_and_provider', unique: true t.index ["email", "provider"], name: "index_users_on_email_and_provider", unique: true
end end
add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id' add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key 'screenshots', 'packages', name: 'screenshots_package_id_fkey' add_foreign_key "screenshots", "packages", name: "screenshots_package_id_fkey"
end end

View file

@ -0,0 +1,84 @@
# frozen_string_literal: true
namespace :debshots do
desc 'Compute embedding vectors for all packages based on their description ' \
'and long description (missing ones only unless FORCE=1). ' \
'Set CONCURRENCY=n to control parallel HTTP requests.'
task compute_vectors: :environment do
logger = Logger.new($stdout)
logger.level = Logger::INFO
force = ENV['FORCE'].present?
concurrency = [ENV.fetch('CONCURRENCY', '4').to_i, 1].max
# Never use more threads than the connection pool has connections.
# Keep one connection reserved for the main thread.
pool_size = ActiveRecord::Base.connection_pool.size
concurrency = [concurrency, pool_size - 1].min
concurrency = 0 if concurrency.negative?
scope = force ? Package.all : Package.where(embedding: nil)
total = scope.count
if total.zero?
logger.info 'No packages need vector computation.'
next
end
logger.info "Computing vectors for #{total} packages " \
"(#{force ? 'recomputing' : 'missing only'}, " \
"service: #{Vectorizer.service_url}, " \
"concurrency: #{concurrency.zero? ? 1 : concurrency})"
stats_mutex = Mutex.new
stats = { done: 0, failed: 0 }
process_package = lambda do |package|
stats_mutex.synchronize do
begin
raise ArgumentError, 'no description text' if package.embedding_text.blank?
package.update_embedding!
stats[:done] += 1
rescue Vectorizer::Error, ArgumentError => e
stats[:failed] += 1
logger.error "Failed for package #{package.name}: #{e.message}"
end
processed = stats[:done] + stats[:failed]
return unless (processed % 100).zero?
percent = (processed.to_f / total * 100).round(1)
logger.info "Progress: #{processed}/#{total} (#{percent}%)"
end
end
if concurrency.zero?
# Sequential fallback when the connection pool has no room for workers
scope.find_each { |package| process_package.call(package) }
else
# Feed package IDs into a queue that the worker threads pull from.
work_queue = Queue.new
scope.pluck(:id).each { |id| work_queue << id }
concurrency.times { work_queue << nil } # one poison pill per worker
workers = Array.new(concurrency) do
Thread.new do
# Each thread gets its own database connection from the pool.
ActiveRecord::Base.connection_pool.with_connection do
while (package_id = work_queue.pop)
package = Package.find_by(id: package_id)
process_package.call(package) if package
end
end
rescue StandardError => e
logger.error "Worker crashed: #{e.message}"
end
end
workers.each(&:join)
end
logger.info "Done. #{stats[:done]} vectors computed, #{stats[:failed]} failures."
end
end

70
lib/vectorizer.rb Normal file
View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'net/http'
require 'json'
# Client for the external web service that turns text into embedding
# vectors for nearest neighbor search (pgvector).
#
# Vectorizer.embed('A fast text editor') # => [0.123, -0.456, ...]
#
# The service URL can be overridden with the VECTOR_SERVICE_URL
# environment variable.
class Vectorizer
# Raised when the vector service cannot be reached or returns garbage
class Error < StandardError; end
DEFAULT_URL = 'https://vector-search.workaround.org/vector'
class << self
# Compute the embedding vector for the given text.
# Returns an array of floats.
def embed(text)
new.embed(text)
end
def service_url
ENV.fetch('VECTOR_SERVICE_URL', DEFAULT_URL)
end
end
def embed(text)
response = post_text(text)
unless response.is_a?(Net::HTTPSuccess)
raise Error, "Vector service returned HTTP #{response.code}"
end
JSON.parse(response.body).fetch('vector')
rescue JSON::ParserError => e
raise Error, "Vector service returned invalid JSON: #{e.message}"
rescue KeyError
raise Error, 'Vector service response did not contain a "vector" field'
end
private
def post_text(text)
uri = URI.parse(self.class.service_url)
request = build_request(uri, text)
options = { use_ssl: uri.scheme == 'https', read_timeout: 30, open_timeout: 10 }
Net::HTTP.start(uri.host, uri.port, **options) do |http|
http.request(request)
end
rescue SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e
raise Error, "Could not reach vector service: #{e.message}"
end
def build_request(uri, text)
request = Net::HTTP::Post.new(uri.request_uri)
request['Content-Type'] = 'application/json'
request.body = { text: text }.to_json
# Optional Bearer token if the service runs with API_SECRET set
secret = ENV['VECTOR_SERVICE_SECRET']
request['Authorization'] = "Bearer #{secret}" if secret.present?
request
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'test_helper' require 'test_helper'
class PackagesControllerTest < ActionController::TestCase class PackagesControllerTest < ActionController::TestCase
@ -20,6 +22,81 @@ class PackagesControllerTest < ActionController::TestCase
assert_select 'div', 'A program to browse web sites.' assert_select 'div', 'A program to browse web sites.'
end end
test 'exact package name search wins without calling the vector service' do
with_stubbed_method(Package, :nearest_to_text,
->(_text) { flunk 'should not call vector search' }) do
get :grid, params: { search: 'vim' }
assert_response :success
assert_select 'div', 'vim'
end
end
test 'gibberish search shows no results and never calls the vector service' do
with_stubbed_method(Package, :nearest_to_text,
->(_text) { flunk 'should not call vector search' }) do
get :grid, params: { search: 'asdfgh zzzz' }
assert_response :success
# the grid renders every package in the database when no search
# filter applies - so an empty result must not fall back to that
assert_select 'div', text: /package-\d+/, count: 0
end
end
test 'name prefix matches rank above semantic results' do
with_stubbed_method(Package, :nearest_to_text,
->(_text, **_opts) { Package.where(name: packages(:vim).name) }) do
get :grid, params: { search: 'package' }
assert_response :success
# fixtures create package-0..package-99 - prefix hits must render
assert_select 'div', 'package-0'
# ...and before any semantically found package
assert response.body.index('package-0') < response.body.index('vim')
end
end
test 'prefix matches and semantic results are deduplicated' do
with_stubbed_method(Package, :nearest_to_text,
->(_text, **_opts) { Package.where(name: packages(:firefox).name) }) do
get :grid, params: { search: 'fire' }
assert_response :success
assert_select 'div', text: 'firefox', count: 1
end
end
test 'compound words in search match packages with joined names' do
# "fire fox" should find "firefox" via name substring matching
with_stubbed_method(Package, :nearest_to_text,
->(_text, **_opts) { Package.none }) do
get :grid, params: { search: 'fire fox' }
assert_response :success
assert_select 'div', text: 'firefox', count: 1
end
end
test 'search uses semantic (vector) results' do
relation = Package.where(name: packages(:vim).name)
with_stubbed_method(Package, :nearest_to_text, ->(_text, **_opts) { relation }) do
get :grid, params: { search: 'editor' }
assert_response :success
assert_select 'div', 'vim'
end
end
# There is deliberately no fallback for an empty vector search
# result: KNN always returns something as long as packages have
# embeddings. Full-text search is only used when the vector service
# cannot be reached.
test 'search falls back to full-text search when vector service fails' do
with_stubbed_method(Vectorizer, :embed,
->(_text) { raise Vectorizer::Error, 'service down' }) do
get :grid, params: { search: 'editor' }
assert_response :success
assert_select 'div', 'vim'
assert flash[:error].present?
end
end
# test 'should get thumbnail for a package and a desired version' do # test 'should get thumbnail for a package and a desired version' do
# # get '/thumbnail-with-version/package-5/0.1' # # get '/thumbnail-with-version/package-5/0.1'
# get thumbnail_with_version_url('package-5', '0.1') # get thumbnail_with_version_url('package-5', '0.1')

View file

@ -1,7 +1,107 @@
# frozen_string_literal: true
require 'test_helper' require 'test_helper'
class PackageTest < ActiveSupport::TestCase class PackageTest < ActiveSupport::TestCase
# test "the truth" do test 'embedding_text combines description and long description' do
# assert true package = packages(:firefox)
# end 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 end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
ENV['RAILS_ENV'] ||= 'test' ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../config/environment', __dir__) require File.expand_path('../config/environment', __dir__)
require 'rails/test_help' require 'rails/test_help'
@ -15,6 +17,23 @@ module ActiveSupport
class TestCase class TestCase
# Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order.
fixtures :all fixtures :all
# Replace a class-level method with a fixed return value or lambda
# for the duration of the block. Used to keep tests away from
# external services like the vector embedding web service.
def with_stubbed_method(klass, method_name, value_or_lambda)
original = klass.method(method_name)
klass.define_singleton_method(method_name) do |*args, **kwargs|
if value_or_lambda.respond_to?(:call)
value_or_lambda.call(*args, **kwargs)
else
value_or_lambda
end
end
yield
ensure
klass.define_singleton_method(method_name, original)
end
end end
end end