Expose which search strategy produced each result

query_packages now assigns @search_sources - a hash mapping package
ids to the matching strategy that put them into the results: :exact,
:name_prefix, :name_contains, :semantic or :fulltext. The grid and
list views render a small badge with a humanized label per result,
making the search tiers observable. semantic_results() was folded
into search_packages() so each branch tags its own source. Adds the
rails-controller-testing gem for assigns() in tests.
This commit is contained in:
Christoph Haas 2026-08-23 10:39:50 +02:00
parent 61b64fae25
commit 323b7004ed
6 changed files with 69 additions and 24 deletions

View file

@ -362,6 +362,11 @@ class PackagesController < ApplicationController
def query_packages
packages = Package # .order(visits: :desc)
# Maps package id to the search strategy that put it into the
# result list: :exact, :name_prefix, :name_contains, :semantic or
# :fulltext. Empty when browsing without a search term.
@search_sources = {}
# text search
if params[:search].present?
logger.debug "Searching for #{params[:search]}"
@ -393,15 +398,34 @@ class PackagesController < ApplicationController
def search_packages(query)
exact_match = Package.find_by(name: query)
return Package.where(id: exact_match.id) if exact_match
if exact_match
@search_sources[exact_match.id] = :exact
return Package.where(id: exact_match.id)
end
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)
prefix_matches = Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES).to_a
token_matches = Package.name_contains_all(query.to_s.split(/\s+/)).limit(
MAX_PROMOTED_NAME_MATCHES
).to_a
prefix_matches.each { |package| @search_sources[package.id] ||= :name_prefix }
token_matches.each { |package| @search_sources[package.id] ||= :name_contains }
results = (name_matches + semantic_results(query)).uniq(&:id)
semantic = []
if Package.lexical_overlap?(query)
begin
semantic = Package.nearest_to_text(query, limit: MAX_SEMANTIC_RESULTS).to_a
semantic.each { |package| @search_sources[package.id] ||= :semantic }
rescue Vectorizer::Error => e
logger.error "Semantic search failed: #{e.message}"
flash.now['error'] = 'Semantic search is unavailable right now. Showing full-text matches.'
semantic = Package.general_search(query).limit(100).to_a
semantic.each { |package| @search_sources[package.id] ||= :fulltext }
end
else
logger.debug "Query '#{query}' has no lexical overlap with package data"
end
results = (prefix_matches + token_matches + semantic).uniq(&:id)
# Queries that share no vocabulary with our package data can produce
# no meaningful results. Skip the vector service round-trip and show
@ -409,23 +433,6 @@ class PackagesController < ApplicationController
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
# for later identification.
# def create_user_token