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.
70 lines
2 KiB
Ruby
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
|