fix: improve package search relevance with exact match priority

Package search now prioritizes results in this order:
1. Exact pname match
2. Exact attr_path match
3. pname starts with query
4. attr_path starts with query
5. FTS ranking (bm25 for SQLite, ts_rank for PostgreSQL)

This ensures searching for "git" returns the "git" package first,
rather than packages that merely mention "git" in their description.

Also update CLAUDE.md to clarify using `nix run` instead of
`go build -o` for testing binaries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 18:04:06 +01:00
parent 66145fab6c
commit d1285d1f80
3 changed files with 46 additions and 4 deletions

View File

@@ -223,7 +223,9 @@ hm-options --version # Show version
### Development Workflow
- **Always run `go fmt ./...` before committing Go code**
- **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`)
- **Use `nix run` to run binaries** (e.g., `nix run .#nixpkgs-search -- options serve`)
- **Use `nix run` to run/test binaries** (e.g., `nix run .#nixpkgs-search -- options serve`)
- Do NOT use `go build -o /tmp/...` to test binaries - always use `nix run`
- Remember: modified files must be tracked by git for `nix run` to see them
- File paths in responses should use format `path/to/file.go:123`
### Linting

View File

@@ -633,6 +633,12 @@ func (s *PostgresStore) GetPackage(ctx context.Context, revisionID int64, attrPa
// SearchPackages searches for packages matching a query.
func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
// Query includes exact match priority:
// - Priority 0: exact pname match
// - Priority 1: exact attr_path match
// - Priority 2: pname starts with query
// - Priority 3: attr_path starts with query
// - Priority 4: FTS match (ordered by ts_rank)
baseQuery := `
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
FROM packages
@@ -656,10 +662,24 @@ func (s *PostgresStore) SearchPackages(ctx context.Context, revisionID int64, qu
if filters.Insecure != nil {
baseQuery += fmt.Sprintf(" AND insecure = $%d", argNum)
args = append(args, *filters.Insecure)
_ = argNum // silence ineffassign - argNum tracks position but final value unused
argNum++
}
baseQuery += " ORDER BY attr_path"
// Order by exact match priority, then ts_rank, then attr_path
// CASE returns priority (lower = better), ts_rank returns positive scores (higher = better, so DESC)
baseQuery += fmt.Sprintf(` ORDER BY
CASE
WHEN pname = $%d THEN 0
WHEN attr_path = $%d THEN 1
WHEN pname LIKE $%d THEN 2
WHEN attr_path LIKE $%d THEN 3
ELSE 4
END,
ts_rank(to_tsvector('english', attr_path || ' ' || pname || ' ' || COALESCE(description, '')), plainto_tsquery('english', $2)) DESC,
attr_path`, argNum, argNum+1, argNum+2, argNum+3)
// For LIKE comparisons, escape % and _ characters for PostgreSQL
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_") + "%"
args = append(args, query, query, likeQuery, likeQuery)
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)

View File

@@ -696,6 +696,12 @@ func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath
// SearchPackages searches for packages matching a query.
func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
// Query includes exact match priority:
// - Priority 0: exact pname match
// - Priority 1: exact attr_path match
// - Priority 2: pname starts with query
// - Priority 3: attr_path starts with query
// - Priority 4: FTS match (ordered by bm25 rank)
baseQuery := `
SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure
FROM packages p
@@ -705,6 +711,8 @@ func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, quer
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
// For LIKE comparisons, escape % and _ characters
likeQuery := strings.ReplaceAll(strings.ReplaceAll(query, "%", "\\%"), "_", "\\_")
args := []interface{}{revisionID, escapedQuery}
if filters.Broken != nil {
@@ -722,7 +730,19 @@ func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, quer
args = append(args, *filters.Insecure)
}
baseQuery += " ORDER BY p.attr_path"
// Order by exact match priority, then FTS5 rank, then attr_path
// CASE returns priority (lower = better), bm25 returns negative scores (lower = better)
baseQuery += ` ORDER BY
CASE
WHEN p.pname = ? THEN 0
WHEN p.attr_path = ? THEN 1
WHEN p.pname LIKE ? ESCAPE '\' THEN 2
WHEN p.attr_path LIKE ? ESCAPE '\' THEN 3
ELSE 4
END,
bm25(packages_fts),
p.attr_path`
args = append(args, query, query, likeQuery+"%", likeQuery+"%")
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)