fix(search): tighten short meilisearch title queries

This commit is contained in:
Quick
2026-06-26 10:13:15 -04:00
parent 4473f8c60b
commit dd2784c0ee
3 changed files with 124 additions and 4 deletions
@@ -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"`
@@ -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) {
+81 -2
View File
@@ -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)