debshots/lib/vectorizer.rb
Christoph Haas 4948fe50e3 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.
2026-08-22 22:07:56 +02:00

70 lines
2 KiB
Ruby

# 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