# 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