query: precompute metricScore once per result

Normalize the search term and name/provider/desc at the metric boundary, drop the per-comparison distance cache, and score each result via prepareMetrics before sorting. Adds a search-flow benchmark to track regressions.
This commit is contained in:
Jo Garnier
2026-06-25 23:37:26 +02:00
parent 02fb6ba948
commit 73bd048c55
3 changed files with 130 additions and 25 deletions

View File

@@ -25,26 +25,23 @@ func (a *abstractResults) aurSortByMetric(pkg *abstractResult) float64 {
}
func (a *abstractResults) GetMetric(pkg *abstractResult) float64 {
if v, ok := a.distanceCache[pkg.name]; ok {
return v
}
if strings.EqualFold(pkg.name, a.search) {
name := strings.ToLower(pkg.name)
if name == a.search {
return 1.0
}
sim := strutil.Similarity(pkg.name, a.search, a.metric)
sim := strutil.Similarity(name, a.search, a.metric)
for _, prov := range pkg.provides {
// If the package provides search, it's a perfect match
// AUR packages don't populate provides
candidate := strutil.Similarity(prov, a.search, a.metric) * 0.80
candidate := strutil.Similarity(strings.ToLower(prov), a.search, a.metric) * 0.80
if candidate > sim {
sim = candidate
}
}
simDesc := strutil.Similarity(pkg.description, a.search, a.metric)
simDesc := strutil.Similarity(strings.ToLower(pkg.description), a.search, a.metric)
// slightly overweight sync sources by always giving them max popularity
popularity := 1.0
@@ -52,11 +49,7 @@ func (a *abstractResults) GetMetric(pkg *abstractResult) float64 {
popularity = a.aurSortByMetric(pkg)
}
sim = sim*0.35 + simDesc*0.15 + popularity*0.50
a.distanceCache[pkg.name] = sim
return sim
return sim*0.35 + simDesc*0.15 + popularity*0.50
}
func (a *abstractResults) separateSourceScore(source string, score float64) float64 {

View File

@@ -96,6 +96,7 @@ type abstractResult struct {
firstSubmitted int
lastModified int
provides []string
metricScore float64
}
type abstractResults struct {
@@ -106,7 +107,6 @@ type abstractResults struct {
sortByFunc SortFunc
repoOrder []string
distanceCache map[string]float64
separateSourceCache map[string]float64
}
@@ -158,9 +158,7 @@ func (a *abstractResults) GetSortFunc(sortBy string, bottomUp bool) SortFunc {
return cmpResult
}
metricA := a.calculateMetric(&pkgA)
metricB := a.calculateMetric(&pkgB)
return cmp.Compare(metricA, metricB)
return cmp.Compare(pkgA.metricScore, pkgB.metricScore)
}
}
@@ -175,23 +173,32 @@ func (a *abstractResults) GetSortFunc(sortBy string, bottomUp bool) SortFunc {
return sortFunc
}
// prepareMetrics computes the expensive Jaro-Winkler-based rank once per result
// before sorting so the comparator stays cheap.
func (a *abstractResults) prepareMetrics() {
a.separateSourceCache = make(map[string]float64, len(a.repoOrder))
for i := range a.results {
a.results[i].metricScore = a.calculateMetric(&a.results[i])
}
}
func (s *SourceQueryBuilder) Execute(ctx context.Context, dbExecutor db.Executor, pkgS []string) {
var aurErr error
pkgS = RemoveInvalidTargets(s.logger, pkgS, s.targetMode)
metric := &metrics.JaroWinkler{
CaseSensitive: false,
// Case-sensitive: we lower-case the corpus once in GetMetric, search string normalized in Execute.
CaseSensitive: true,
}
sortableResults := &abstractResults{
results: []abstractResult{},
search: strings.Join(pkgS, ""),
metric: metric,
separateSources: s.separateSources,
repoOrder: dbExecutor.Repos(),
distanceCache: map[string]float64{},
separateSourceCache: map[string]float64{},
results: []abstractResult{},
search: strings.ToLower(strings.Join(pkgS, "")),
metric: metric,
separateSources: s.separateSources,
repoOrder: dbExecutor.Repos(),
}
sortableResults.sortByFunc = sortableResults.GetSortFunc(s.sortBy, s.bottomUp)
@@ -251,6 +258,7 @@ func (s *SourceQueryBuilder) Execute(ctx context.Context, dbExecutor db.Executor
})
}
}
sortableResults.prepareMetrics()
slices.SortFunc(sortableResults.results, func(a, b abstractResult) int {
return sortableResults.sortByFunc(b, a)

View File

@@ -0,0 +1,104 @@
//go:build !integration
package query
import (
"context"
"fmt"
"io"
"strings"
"testing"
"github.com/Jguer/aur"
"github.com/Jguer/yay/v13/pkg/db/mock"
mockaur "github.com/Jguer/yay/v13/pkg/dep/mock"
"github.com/Jguer/yay/v13/pkg/settings/parser"
"github.com/Jguer/yay/v13/pkg/text"
)
func buildSearchFlowBenchmarkData(repoCount, aurCount int) (*mock.DBExecutor, *mockaur.MockAUR, *text.Logger, []string) {
logger := text.NewLogger(io.Discard, io.Discard, strings.NewReader(""), false, "bench")
extraDB := mock.NewDB("extra")
communityDB := mock.NewDB("community")
repoPkgs := make([]mock.IPackage, 0, repoCount)
for i := range repoCount {
db := extraDB
if i%2 == 1 {
db = communityDB
}
repoPkgs = append(repoPkgs, &mock.Package{
PBase: fmt.Sprintf("yay-tool-%04d", i),
PName: fmt.Sprintf("yay-tool-%04d", i),
PVersion: fmt.Sprintf("1.%d.0", i%17),
PDescription: fmt.Sprintf("Yet another yay tool package %04d for search benchmarking", i),
PSize: int64(1024 + i),
PISize: int64(2048 + i),
PDB: db,
PArchitecture: "x86_64",
PProvides: mock.DependList{Depends: []mock.Depend{{Name: fmt.Sprintf("yay-provider-%04d", i)}, {Name: "yay"}}},
})
}
aurPkgs := make([]aur.Pkg, 0, aurCount)
for i := range aurCount {
aurPkgs = append(aurPkgs, aur.Pkg{
Description: fmt.Sprintf("Yet another yay helper package %04d with search benchmark metadata", i),
FirstSubmitted: 1_500_000_000 + i,
ID: 2_000_000 + i,
LastModified: 1_760_000_000 + i,
Maintainer: "bench",
Name: fmt.Sprintf("yay-aur-%04d", i),
NumVotes: 100 + (i % 250),
OutOfDate: 0,
PackageBase: fmt.Sprintf("yay-aur-%04d", i),
PackageBaseID: 300_000 + i,
Popularity: 1.0 + float64(i%100)/10,
URL: "https://example.invalid/yay",
URLPath: "/snapshot.tar.gz",
Version: fmt.Sprintf("2.%d.0", i%23),
Provides: []string{"yay"},
})
}
mockDB := &mock.DBExecutor{
ReposFn: func() []string {
return []string{"extra", "community"}
},
SyncPackagesFn: func(pkgs ...string) []mock.IPackage {
return repoPkgs
},
LocalPackageFn: func(string) mock.IPackage {
return nil
},
}
mockAUR := &mockaur.MockAUR{
GetFn: func(ctx context.Context, query *aur.Query) ([]aur.Pkg, error) {
return aurPkgs, nil
},
}
return mockDB, mockAUR, logger, []string{"yay"}
}
func BenchmarkExecuteSearchFlowLarge(b *testing.B) {
mockDB, mockAUR, logger, searchTerms := buildSearchFlowBenchmarkData(1500, 1500)
b.ReportAllocs()
for b.Loop() {
qb := NewSourceQueryBuilder(
mockAUR,
logger,
"",
parser.ModeAny,
"",
false,
false,
true,
)
qb.Execute(b.Context(), mockDB, searchTerms)
}
}