From dd2784c0eef598b1e1c82956cffc0488548a4c62 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:13:15 -0400 Subject: [PATCH] fix(search): tighten short meilisearch title queries --- internal/catalog/search_meilisearch_client.go | 1 + .../catalog/search_meilisearch_provider.go | 44 +++++++++- internal/catalog/search_provider_test.go | 83 ++++++++++++++++++- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/internal/catalog/search_meilisearch_client.go b/internal/catalog/search_meilisearch_client.go index 590a7146..14b91cab 100644 --- a/internal/catalog/search_meilisearch_client.go +++ b/internal/catalog/search_meilisearch_client.go @@ -82,6 +82,7 @@ type meilisearchSearchRequest struct { Limit int `json:"limit"` Filter string `json:"filter,omitempty"` AttributesToRetrieve []string `json:"attributesToRetrieve"` + AttributesToSearchOn []string `json:"attributesToSearchOn,omitempty"` MatchingStrategy string `json:"matchingStrategy,omitempty"` Vector []float32 `json:"vector,omitempty"` Hybrid *meilisearchHybridRequest `json:"hybrid,omitempty"` diff --git a/internal/catalog/search_meilisearch_provider.go b/internal/catalog/search_meilisearch_provider.go index c930d920..8d19a2c0 100644 --- a/internal/catalog/search_meilisearch_provider.go +++ b/internal/catalog/search_meilisearch_provider.go @@ -19,6 +19,9 @@ const ( meilisearchDefaultBatchSize = 100 meilisearchDefaultCandidateScanCap = 1000 meilisearchDefaultDeepOffsetLimit = 500 + meilisearchShortHybridMinTerms = 2 + meilisearchStrictMatchingTermCount = 2 + meilisearchTitleOnlyTermCount = 2 meilisearchCircuitCooldown = 30 * time.Second meilisearchQueryVectorCacheTTL = 15 * time.Minute meilisearchQueryVectorCacheMax = 1024 @@ -28,6 +31,13 @@ const ( semanticCapabilityProbeTTL = 5 * time.Minute ) +var meilisearchTitleSearchAttributes = []string{ + "title", + "original_title", + "sort_title", + "title_variants", +} + type MeilisearchProviderConfig struct { URL string APIKey string @@ -324,7 +334,8 @@ func (p *MeilisearchSearchProvider) buildMeilisearchSearchRequest(ctx context.Co Query: strings.TrimSpace(req.Query), Filter: meilisearchTypeFilter(req.ItemTypes), AttributesToRetrieve: []string{"content_id"}, - MatchingStrategy: p.config.MatchingStrategy, + AttributesToSearchOn: p.attributesToSearchOnForRequest(req), + MatchingStrategy: p.matchingStrategyForRequest(req), } if !p.shouldUseSemanticSearch(req) { return searchReq, "" @@ -356,7 +367,36 @@ func (p *MeilisearchSearchProvider) shouldUseSemanticSearch(req CatalogSearchReq if p == nil || !p.config.SemanticEnabled { return false } - return len(strings.Fields(normalizeCatalogSearchQueryForVector(req.Query))) >= 3 + return len(catalogSearchQueryTerms(req.Query)) >= meilisearchShortHybridMinTerms +} + +func (p *MeilisearchSearchProvider) matchingStrategyForRequest(req CatalogSearchRequest) string { + strategy := strings.TrimSpace(strings.ToLower(p.config.MatchingStrategy)) + if strategy == "" { + strategy = DefaultMeilisearchMatchingStrategy + } + if strategy != "last" { + return strategy + } + // Two-term title searches are easy for Meili's "last" strategy to over-relax + // after typo correction (e.g. "spnge bob" degenerating into "spnge"). + if len(catalogSearchQueryTerms(req.Query)) == meilisearchStrictMatchingTermCount { + return "all" + } + return strategy +} + +func (p *MeilisearchSearchProvider) attributesToSearchOnForRequest(req CatalogSearchRequest) []string { + if len(catalogSearchQueryTerms(req.Query)) != meilisearchTitleOnlyTermCount { + return nil + } + return append([]string(nil), meilisearchTitleSearchAttributes...) +} + +func catalogSearchQueryTerms(query string) []string { + parsed := parseSearchQuery(query) + normalized := normalizeTitleForComparison(firstNonEmptySearchValue(parsed.Text, query)) + return strings.Fields(normalized) } func (p *MeilisearchSearchProvider) cachedQueryVector(ctx context.Context, query string) ([]float32, error) { diff --git a/internal/catalog/search_provider_test.go b/internal/catalog/search_provider_test.go index 01307f94..22a890d6 100644 --- a/internal/catalog/search_provider_test.go +++ b/internal/catalog/search_provider_test.go @@ -161,6 +161,9 @@ func TestMeilisearchSearchRequestBuildsKeywordOnlyByDefault(t *testing.T) { if req.Vector != nil || req.Hybrid != nil { t.Fatalf("keyword request should not include vector or hybrid: %#v", req) } + if req.MatchingStrategy != DefaultMeilisearchMatchingStrategy { + t.Fatalf("matching strategy = %q, want %q", req.MatchingStrategy, DefaultMeilisearchMatchingStrategy) + } if req.Filter != `type = "movie"` { t.Fatalf("filter = %q, want movie filter", req.Filter) } @@ -192,6 +195,12 @@ func TestMeilisearchSearchRequestBuildsHybridWhenSemanticEnabled(t *testing.T) { if req.Hybrid.SemanticRatio != 0.4 { t.Fatalf("semantic ratio = %v, want 0.4", req.Hybrid.SemanticRatio) } + if req.MatchingStrategy != DefaultMeilisearchMatchingStrategy { + t.Fatalf("long semantic query matching strategy = %q, want %q", req.MatchingStrategy, DefaultMeilisearchMatchingStrategy) + } + if req.AttributesToSearchOn != nil { + t.Fatalf("long semantic query should search all attributes, got %#v", req.AttributesToSearchOn) + } if len(req.Vector) != 3072 { t.Fatalf("vector len = %d, want 3072", len(req.Vector)) } @@ -302,7 +311,71 @@ func TestMeilisearchSearchRequestBuildsHybridForApproximateInteractiveSearch(t * } } -func TestMeilisearchSearchRequestSkipsHybridForShortTitleSearch(t *testing.T) { +func TestMeilisearchSearchRequestBuildsHybridAndStrictMatchingForTwoTermSearch(t *testing.T) { + vectorizer := &fakeCatalogSearchVectorizer{vector: []float32{0.5, 0.25}} + provider := &MeilisearchSearchProvider{ + config: MeilisearchProviderConfig{ + MatchingStrategy: DefaultMeilisearchMatchingStrategy, + SemanticEnabled: true, + SemanticRatio: 0.3, + Embedder: "silo_recommendations", + Vectorizer: vectorizer, + }, + } + req, fallback := provider.buildMeilisearchSearchRequest(context.Background(), CatalogSearchRequest{ + Query: "spnge bob", + }) + if fallback != "" { + t.Fatalf("fallback = %q, want empty", fallback) + } + if req.Vector == nil || req.Hybrid == nil { + t.Fatalf("two-term title search should include hybrid search: %#v", req) + } + if req.MatchingStrategy != "all" { + t.Fatalf("two-term title search matching strategy = %q, want all", req.MatchingStrategy) + } + if !reflect.DeepEqual(req.AttributesToSearchOn, meilisearchTitleSearchAttributes) { + t.Fatalf("two-term title search attributes = %#v, want %#v", req.AttributesToSearchOn, meilisearchTitleSearchAttributes) + } + if vectorizer.calls != 1 || vectorizer.lastQuery != "spnge bob" { + t.Fatalf("vectorizer calls/query = %d/%q", vectorizer.calls, vectorizer.lastQuery) + } +} + +func TestMeilisearchSearchRequestUsesStrictMatchingWhenTwoTermHybridNotReady(t *testing.T) { + vectorizer := &fakeCatalogSearchVectorizer{vector: []float32{0.5, 0.25}} + provider := &MeilisearchSearchProvider{ + config: MeilisearchProviderConfig{ + MatchingStrategy: DefaultMeilisearchMatchingStrategy, + SemanticEnabled: true, + SemanticRatio: 0.3, + Embedder: "silo_recommendations", + Vectorizer: vectorizer, + Coverage: fakeCoverageGate{ready: false, reason: `type "movie" coverage 40% below threshold`}, + }, + } + req, fallback := provider.buildMeilisearchSearchRequest(context.Background(), CatalogSearchRequest{ + Query: "spnge bob", + ItemTypes: []string{"movie"}, + }) + if req.Vector != nil || req.Hybrid != nil { + t.Fatalf("not-ready coverage should stay keyword-only: %#v", req) + } + if req.MatchingStrategy != "all" { + t.Fatalf("not-ready two-term search matching strategy = %q, want all", req.MatchingStrategy) + } + if !reflect.DeepEqual(req.AttributesToSearchOn, meilisearchTitleSearchAttributes) { + t.Fatalf("not-ready two-term search attributes = %#v, want %#v", req.AttributesToSearchOn, meilisearchTitleSearchAttributes) + } + if fallback != `semantic_not_ready: type "movie" coverage 40% below threshold` { + t.Fatalf("fallback = %q, want semantic_not_ready diagnostic", fallback) + } + if vectorizer.calls != 0 { + t.Fatalf("not-ready coverage should not call vectorizer, calls = %d", vectorizer.calls) + } +} + +func TestMeilisearchSearchRequestSkipsHybridForSingleTermTitleSearch(t *testing.T) { vectorizer := &fakeCatalogSearchVectorizer{vector: []float32{0.5, 0.25}} provider := &MeilisearchSearchProvider{ config: MeilisearchProviderConfig{ @@ -313,7 +386,7 @@ func TestMeilisearchSearchRequestSkipsHybridForShortTitleSearch(t *testing.T) { Vectorizer: vectorizer, }, } - for _, query := range []string{"sponge", "spongebob square"} { + for _, query := range []string{"sponge", "spongebob"} { req, fallback := provider.buildMeilisearchSearchRequest(context.Background(), CatalogSearchRequest{ Query: query, }) @@ -323,6 +396,12 @@ func TestMeilisearchSearchRequestSkipsHybridForShortTitleSearch(t *testing.T) { if req.Vector != nil || req.Hybrid != nil { t.Fatalf("short title search %q should stay keyword-only: %#v", query, req) } + if req.MatchingStrategy != DefaultMeilisearchMatchingStrategy { + t.Fatalf("single-term search %q matching strategy = %q, want %q", query, req.MatchingStrategy, DefaultMeilisearchMatchingStrategy) + } + if req.AttributesToSearchOn != nil { + t.Fatalf("single-term search %q should search all attributes, got %#v", query, req.AttributesToSearchOn) + } } if vectorizer.calls != 0 { t.Fatalf("short title searches should not call vectorizer, calls = %d", vectorizer.calls)