image search test
This commit is contained in:
@@ -480,15 +480,33 @@ func scoreCachedHits(cached []repositories.CatalogueHit) []scoredHit {
|
||||
return hits
|
||||
}
|
||||
|
||||
// sortHits ranks by blended score, then by the model's own similarity, and
|
||||
// only then by name. Name alone used to break every tie, which quietly made
|
||||
// punctuation decide relevance: "Parle Monaco Classic" sorts above "Parle-G
|
||||
// Original …" because a space precedes a hyphen in ASCII, so equal-scoring
|
||||
// crackers beat the biscuit that was actually scanned.
|
||||
func sortHits(hits []scoredHit) {
|
||||
sort.SliceStable(hits, func(i, j int) bool {
|
||||
if hits[i].score != hits[j].score {
|
||||
return hits[i].score > hits[j].score
|
||||
}
|
||||
di, dj := vectorRank(hits[i].Distance), vectorRank(hits[j].Distance)
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return hits[i].ProductName < hits[j].ProductName
|
||||
})
|
||||
}
|
||||
|
||||
// vectorRank orders by cosine distance, nearest first, with a row the model
|
||||
// never saw (-1, text-only) sorting behind every row it did.
|
||||
func vectorRank(d float64) float64 {
|
||||
if d < 0 {
|
||||
return math.MaxFloat64
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *scanService) modelName() string {
|
||||
if s.embedder == nil {
|
||||
return "none"
|
||||
@@ -516,8 +534,20 @@ func textScore(h repositories.CatalogueHit, label string, tokens []string) float
|
||||
name := strings.ToLower(h.ProductName)
|
||||
hay := name + " " + strings.ToLower(h.Title)
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if label != "" && strings.Contains(name, label) {
|
||||
return 0.95
|
||||
// The substring test compares separator-folded forms, so the brand's own
|
||||
// punctuation does not decide the match: "Parle G", "Parle-G" and
|
||||
// "ParleG" all have to reach "Parle-G Original Glucose Biscuits".
|
||||
if label != "" {
|
||||
foldedName, foldedLabel := utils.FoldSeparators(name), utils.FoldSeparators(label)
|
||||
if foldedLabel != "" && strings.Contains(foldedName, foldedLabel) {
|
||||
return 0.95
|
||||
}
|
||||
// Separators dropped rather than folded. Only for a label long enough
|
||||
// that a run of letters means something — "lay" inside "malayalam" is
|
||||
// not a match anyone wants.
|
||||
if tight := utils.TightenLabel(label); len(tight) >= 4 && strings.Contains(utils.TightenLabel(name), tight) {
|
||||
return 0.95
|
||||
}
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -501,3 +501,55 @@ func TestConfirmReportsDistanceToTheChosenStore(t *testing.T) {
|
||||
t.Fatalf("no position at all is -1; got %v", resp.Store)
|
||||
}
|
||||
}
|
||||
|
||||
var parleG = repositories.CatalogueHit{Brand: "parle", ID: 1, ProductName: "Parle-G Original Glucose Biscuits 250g", Title: "Parle-G", VariantKey: "parle_g", ImageID: "parle_parle_g_250g", Distance: 0.20}
|
||||
var monaco = repositories.CatalogueHit{Brand: "parle", ID: 2, ProductName: "Parle Monaco Classic Regular 200g", Title: "Monaco", VariantKey: "monaco", ImageID: "parle_monaco_200g", Distance: 0.20}
|
||||
|
||||
// Lens reads "Parle-G" off the packet and the customer types "Parle G". Both
|
||||
// spellings, and the run-together one, have to reach the biscuit — not the
|
||||
// salted cracker that merely shares a brand. In production "Parle G" returned
|
||||
// "Parle Monaco Classic Regular 200g" at a confident 0.9.
|
||||
func TestLookupMatchesAHyphenatedNameHoweverItIsWritten(t *testing.T) {
|
||||
for _, label := range []string{"Parle G", "Parle-G", "ParleG", "parle g"} {
|
||||
repo := newLookupFixture()
|
||||
repo.vector = []repositories.CatalogueHit{monaco, parleG} // model puts the cracker first
|
||||
repo.text = []repositories.CatalogueHit{monaco, parleG}
|
||||
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
||||
|
||||
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: label})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Match == nil {
|
||||
t.Fatalf("%q: a stocked product went unrecognised", label)
|
||||
}
|
||||
if resp.Match.Catalogueid != parleG.ID {
|
||||
t.Fatalf("%q: matched %q (%.3f), want Parle-G", label, resp.Match.ProductName, resp.Match.Score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Equal blended scores used to be settled by product name, which let ASCII
|
||||
// decide relevance: a space sorts before a hyphen, so "Parle Monaco …" beat
|
||||
// "Parle-G …". The model's own similarity settles it instead.
|
||||
func TestSortHitsBreaksTiesOnSimilarityNotPunctuation(t *testing.T) {
|
||||
near := parleG
|
||||
near.Distance = 0.10 // the model is surer about this one
|
||||
far := monaco
|
||||
far.Distance = 0.40
|
||||
|
||||
hits := []scoredHit{{CatalogueHit: far, score: 0.9}, {CatalogueHit: near, score: 0.9}}
|
||||
sortHits(hits)
|
||||
if hits[0].ID != near.ID {
|
||||
t.Fatalf("the nearer vector should win a tie, got %q", hits[0].ProductName)
|
||||
}
|
||||
|
||||
// A row the model never scored (-1, text-only) ranks behind one it did.
|
||||
textOnly := parleG
|
||||
textOnly.Distance = -1
|
||||
hits = []scoredHit{{CatalogueHit: textOnly, score: 0.9}, {CatalogueHit: far, score: 0.9}}
|
||||
sortHits(hits)
|
||||
if hits[0].ID != far.ID {
|
||||
t.Fatalf("a scored row outranks an unscored one, got %q", hits[0].ProductName)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user