Show related packages based on description embeddings

Package pages get a "Related packages" section: a nearest neighbor
query against the package's own stored embedding (no external service
call), excluding the package itself. Packages without an embedding
render no section.
This commit is contained in:
Christoph Haas 2026-08-22 23:47:42 +02:00
parent fdf3289f54
commit 4d07f43a2e
5 changed files with 46 additions and 0 deletions

View file

@ -39,6 +39,10 @@ Queries that share no vocabulary with the package data (gibberish,
keyboard mash) return an honest empty result instead of random keyboard mash) return an honest empty result instead of random
semantic matches. semantic matches.
Package pages also show semantically **related packages**, computed as
a nearest neighbor query against the package's own description
embedding - no external service call involved.
## Deployment ## Deployment
Read the doc/README.Installation.md Read the doc/README.Installation.md

View file

@ -30,6 +30,9 @@ class PackagesController < ApplicationController
@screenshots = @package.screenshots.accessible_by(current_ability, :view).paginate( @screenshots = @package.screenshots.accessible_by(current_ability, :view).paginate(
page: @page, per_page: 6 page: @page, per_page: 6
) )
# Semantic recommendations based on the package's own description
# embedding - a plain nearest neighbor query, no service call.
@related_packages = @package.related_packages
end end
end end

View file

@ -112,6 +112,16 @@ class Package < ApplicationRecord
text.gsub(/[\\%_]/) { |char| "\\#{char}" } text.gsub(/[\\%_]/) { |char| "\\#{char}" }
end end
# Packages semantically similar to this one, based on the stored
# description embedding. Returns an empty relation for packages that
# have no embedding (yet).
def related_packages(limit: 6)
return Package.none unless embedding
Package.nearest_to_vector(embedding, limit: limit + 1)
.where.not(id: id).limit(limit)
end
# Check whether the given text shares at least one lexeme with any # Check whether the given text shares at least one lexeme with any
# package name/description in the database. Queries without such an # package name/description in the database. Queries without such an
# overlap (gibberish, keyboard mash, unsupported languages) can # overlap (gibberish, keyboard mash, unsupported languages) can
@ -209,3 +219,4 @@ class Package < ApplicationRecord
end end
end end
end end
# rubocop:enable Metrics/ClassLength

View file

@ -70,6 +70,19 @@
/ #metadata is the anchor for this sticky container. / #metadata is the anchor for this sticky container.
= render(partial: 'details_rightbox', locals: {pkg: @package}) = render(partial: 'details_rightbox', locals: {pkg: @package})
/ Semantically similar packages, based on the package's description embedding
- if @related_packages.any?
#related
h3 Related packages
.grid-x.grid-margin-x.medium-up-2.large-up-3 data-equalizer=true
- @related_packages.each do |pkg|
.cell.pkgcard data-equalizer-watch=true
a.black href=package_path(name: pkg.name)
.text.pkgname
= pkg.name
.text.small
= pkg.description
javascript: javascript:
// Where to send POST requests for uploads // Where to send POST requests for uploads
let ajax_upload_url = '#{{upload_receive_json_path}}'; let ajax_upload_url = '#{{upload_receive_json_path}}';

View file

@ -73,6 +73,21 @@ class PackagesControllerTest < ActionController::TestCase
end end
end end
test 'details shows semantically related packages' do
packages(:vim).update_column(:embedding, Array.new(384, 0.25))
packages(:firefox).update_column(:embedding, Array.new(384, 0.75))
get :details, params: { name: 'vim' }
assert_response :success
assert_select '#related .pkgname', text: 'firefox'
end
test 'details without embedding renders no related section' do
get :details, params: { name: 'vim' }
assert_response :success
assert_select '#related', count: 0
end
test 'search uses semantic (vector) results' do test 'search uses semantic (vector) results' do
relation = Package.where(name: packages(:vim).name) relation = Package.where(name: packages(:vim).name)
with_stubbed_method(Package, :nearest_to_text, ->(_text, **_opts) { relation }) do with_stubbed_method(Package, :nearest_to_text, ->(_text, **_opts) { relation }) do