feat(search): binary-quantized embedder vectors — optional, default-on for fresh installs (#351)

* feat(search): binary quantization setting for the Meilisearch embedder

New server setting catalog.search.meilisearch.binary_quantized
(default false) threads into the embedder index settings
("binaryQuantized": true) and into the schema-version hash, so flipping
it closes the sync gate and mandates a rebuild in both directions —
Meilisearch cannot de/re-quantize an index in place.

With 3072-dimensional embeddings this cuts vector storage ~32x
(≈12KB → 384B per document), keeping the whole vector store in page
cache: rebuilds and hybrid queries get sharply cheaper. Hybrid search
(keyword + semantic) cushions the small relevance cost of sign-only
vectors.

The hash token is appended only when the flag is set, so indexes built
before this change keep their schema version while it stays off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(search): binary quantization default-on for fresh installs + admin toggle

- Migration seeds catalog.search.meilisearch.binary_quantized=true only
  when no active catalog search index exists. Existing deployments stay
  unset (= off): flipping quantization changes the index schema-version
  identity, which closes the incremental-sync gate until a full rebuild
  runs — that must never happen implicitly on upgrade. Fresh installs
  have no index yet, so their first rebuild simply starts quantized.
- Search settings page gains the toggle with an explicit
  "requires a full index rebuild" warning, a status row, and settings-
  search keywords.

Prod benchmark (607.9k docs, 3072-dim vectors, N=10 medians, replicated):
hybrid 0.5 unchanged (7.5ms float vs 8.0ms quantized, within ±2ms
keyword-control jitter); pure semantic 9ms → 4ms; on-disk index 18G →
8.3G; rebuild duration unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(search): address review feedback on binary-quantized embedder

- Add catalog.search.meilisearch.binary_quantized to the restart-required
  registry. The provider freezes BinaryQuantized into MeilisearchProviderConfig
  at construction, so without this a toggle-then-rebuild in the same process
  builds a quantized schema while the live provider still compares against the
  old value and falls back until restart (Codex P2).
- Validate binary_quantized in HandleUpdateSetting, mirroring semantic_enabled.
  A raw API write of a non-bool previously persisted unnormalized, then failed
  CatalogSearchSettingsFromMap on load and silently reverted the entire search
  config to Postgres defaults.
- Gate the binary_quantized token in the schema-version identity on
  semanticEnabled: with semantic off the index has no embedders, so the flag
  has no on-index effect and must not force a pointless rebuild. Stays
  byte-identical to a pre-flag index. Covered by a new test.
- Clarify the seed migration comment (guard is "no active index", which also
  covers Meilisearch-configured-but-never-indexed deployments) and the UI hint
  (~30x smaller raw vectors, index roughly halves; only applies with semantic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-07-09 09:55:51 -04:00
committed by GitHub
co-authored by rxwatcher Claude Fable 5
parent 3e340552a8
commit e96a8a0cf8
11 changed files with 148 additions and 35 deletions
+7
View File
@@ -2334,6 +2334,13 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
req.Value = embedder
case catalog.SearchSettingMeilisearchBinaryQuantized:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.binary_quantized must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
+12 -12
View File
@@ -89,7 +89,7 @@ func (i *CatalogSearchIndexer) ShouldSyncRun(ctx context.Context) (bool, error)
if err != nil {
return false, err
}
if state.ActiveIndexUID == "" || state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled) {
if state.ActiveIndexUID == "" || state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled, settings.BinaryQuantized) {
return false, nil
}
pending, err := i.events.PendingCount(ctx, SearchProviderMeilisearch)
@@ -131,7 +131,7 @@ func (i *CatalogSearchIndexer) SyncOutbox(ctx context.Context, progress SearchIn
if err != nil {
return stats, err
}
if state.ActiveIndexUID == "" || state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled) {
if state.ActiveIndexUID == "" || state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled, settings.BinaryQuantized) {
stats.Skipped = true
stats.Reason = "active search index is missing or stale; run rebuild_catalog_search_index"
setSearchIndexTaskResult(progress, stats)
@@ -166,7 +166,7 @@ func (i *CatalogSearchIndexer) SyncOutbox(ctx context.Context, progress SearchIn
upsertIDs, deleteIDs := coalesceSearchIndexEvents(events)
reportSearchIndexProgress(progress, 20, "Building changed catalog search documents")
docs, err := i.LoadDocumentsByIDs(ctx, upsertIDs, settings.IndexTypes, settings.Embedder, settings.SemanticEnabled)
docs, err := i.LoadDocumentsByIDs(ctx, upsertIDs, settings.IndexTypes, settings.Embedder, settings.SemanticEnabled, settings.BinaryQuantized)
if err != nil {
_ = i.events.MarkFailed(ctx, ids, err)
return stats, err
@@ -283,7 +283,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex
if err := client.WaitTask(ctx, taskID); err != nil {
return stats, err
}
taskID, err = client.UpdateSettings(ctx, buildIndexUID, catalogSearchMeilisearchSettings(settings.Embedder, settings.SemanticEnabled))
taskID, err = client.UpdateSettings(ctx, buildIndexUID, catalogSearchMeilisearchSettings(settings.Embedder, settings.SemanticEnabled, settings.BinaryQuantized))
if err != nil {
return stats, err
}
@@ -294,7 +294,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex
lastID := ""
queuedTasks := make([]queuedMeilisearchTask, 0, settings.RebuildQueueDepth)
for {
docs, err := i.LoadDocumentsAfter(ctx, lastID, settings.RebuildBatchSize, settings.IndexTypes, settings.Embedder, settings.SemanticEnabled)
docs, err := i.LoadDocumentsAfter(ctx, lastID, settings.RebuildBatchSize, settings.IndexTypes, settings.Embedder, settings.SemanticEnabled, settings.BinaryQuantized)
if err != nil {
return stats, err
}
@@ -339,7 +339,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex
// active index as idempotent upserts on the next sync. The reverse order
// would mark events processed while the old index is still active, losing
// those changes from the served index until the next rebuild.
if err := i.events.UpdateStateAfterRebuild(ctx, SearchProviderMeilisearch, buildIndexUID, catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled), docCount, rebuildEventHighWater); err != nil {
if err := i.events.UpdateStateAfterRebuild(ctx, SearchProviderMeilisearch, buildIndexUID, catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled, settings.BinaryQuantized), docCount, rebuildEventHighWater); err != nil {
return stats, err
}
if err := i.events.MarkProcessedThrough(ctx, SearchProviderMeilisearch, rebuildEventHighWater); err != nil {
@@ -532,7 +532,7 @@ func (i *CatalogSearchIndexer) CheckConnection(ctx context.Context, settings Cat
return client.Health(ctx)
}
func catalogSearchMeilisearchSettings(embedder string, semanticEnabled bool) map[string]any {
func catalogSearchMeilisearchSettings(embedder string, semanticEnabled, binaryQuantized bool) map[string]any {
settings := map[string]any{
"displayedAttributes": []string{"content_id", "type"},
"filterableAttributes": []string{"type"},
@@ -558,7 +558,7 @@ func catalogSearchMeilisearchSettings(embedder string, semanticEnabled bool) map
if err != nil {
embedder = DefaultMeilisearchEmbedder
}
settings["embedders"] = catalogSearchMeilisearchEmbedderSettings(embedder)
settings["embedders"] = catalogSearchMeilisearchEmbedderSettings(embedder, binaryQuantized)
}
return settings
}
@@ -627,7 +627,7 @@ type catalogSearchDocument struct {
Vectors map[string][]float32 `json:"_vectors,omitempty"`
}
func (i *CatalogSearchIndexer) LoadDocumentsAfter(ctx context.Context, afterContentID string, limit int, itemTypes []string, embedder string, semanticEnabled bool) ([]catalogSearchDocument, error) {
func (i *CatalogSearchIndexer) LoadDocumentsAfter(ctx context.Context, afterContentID string, limit int, itemTypes []string, embedder string, semanticEnabled, binaryQuantized bool) ([]catalogSearchDocument, error) {
if i == nil || i.pool == nil || limit <= 0 {
return nil, nil
}
@@ -653,14 +653,14 @@ func (i *CatalogSearchIndexer) LoadDocumentsAfter(ctx context.Context, afterCont
if err != nil {
return nil, err
}
setCatalogSearchDocumentSchemaVersion(docs, catalogSearchMeilisearchSchemaVersion(embedder, itemTypes, semanticEnabled))
setCatalogSearchDocumentSchemaVersion(docs, catalogSearchMeilisearchSchemaVersion(embedder, itemTypes, semanticEnabled, binaryQuantized))
if err := i.attachDocumentVectors(ctx, docs, embedder, semanticEnabled); err != nil {
return nil, err
}
return docs, nil
}
func (i *CatalogSearchIndexer) LoadDocumentsByIDs(ctx context.Context, contentIDs []string, itemTypes []string, embedder string, semanticEnabled bool) ([]catalogSearchDocument, error) {
func (i *CatalogSearchIndexer) LoadDocumentsByIDs(ctx context.Context, contentIDs []string, itemTypes []string, embedder string, semanticEnabled, binaryQuantized bool) ([]catalogSearchDocument, error) {
contentIDs = compactNonEmptyStrings(contentIDs)
if i == nil || i.pool == nil || len(contentIDs) == 0 {
return nil, nil
@@ -686,7 +686,7 @@ func (i *CatalogSearchIndexer) LoadDocumentsByIDs(ctx context.Context, contentID
if err != nil {
return nil, err
}
setCatalogSearchDocumentSchemaVersion(docs, catalogSearchMeilisearchSchemaVersion(embedder, itemTypes, semanticEnabled))
setCatalogSearchDocumentSchemaVersion(docs, catalogSearchMeilisearchSchemaVersion(embedder, itemTypes, semanticEnabled, binaryQuantized))
if err := i.attachDocumentVectors(ctx, docs, embedder, semanticEnabled); err != nil {
return nil, err
}
@@ -58,6 +58,7 @@ type MeilisearchProviderConfig struct {
MatchingStrategy string
IndexTypes []string
SemanticEnabled bool
BinaryQuantized bool
SemanticRatio float64
Embedder string
Vectorizer CatalogSearchQueryVectorizer
@@ -173,7 +174,7 @@ func (p *MeilisearchSearchProvider) Search(ctx context.Context, req CatalogSearc
if strings.TrimSpace(state.ActiveIndexUID) == "" {
return p.fallbackSearch(ctx, req, "meilisearch index has not been built")
}
if state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(p.config.Embedder, p.config.IndexTypes, p.config.SemanticEnabled) {
if state.SchemaVersion != catalogSearchMeilisearchSchemaVersion(p.config.Embedder, p.config.IndexTypes, p.config.SemanticEnabled, p.config.BinaryQuantized) {
return p.fallbackSearch(ctx, req, "meilisearch index schema mismatch")
}
@@ -591,6 +592,7 @@ func (p *MeilisearchSearchProvider) Status() CatalogSearchMeiliStatus {
MatchingStrategy: p.config.MatchingStrategy,
IndexTypes: p.config.IndexTypes,
SemanticEnabled: p.config.SemanticEnabled,
BinaryQuantized: p.config.BinaryQuantized,
SemanticRatio: p.config.SemanticRatio,
Embedder: p.config.Embedder,
}
+36 -10
View File
@@ -32,6 +32,7 @@ const (
SearchSettingMeilisearchSemanticEnabled = "catalog.search.meilisearch.semantic_enabled"
SearchSettingMeilisearchSemanticRatio = "catalog.search.meilisearch.semantic_ratio"
SearchSettingMeilisearchEmbedder = "catalog.search.meilisearch.embedder"
SearchSettingMeilisearchBinaryQuantized = "catalog.search.meilisearch.binary_quantized"
DefaultMeilisearchIndex = "silo_media_items"
DefaultMeilisearchTimeoutMS = 800
@@ -44,6 +45,7 @@ const (
DefaultMeilisearchSemanticEnabled = false
DefaultMeilisearchSemanticRatio = 0.50
DefaultMeilisearchEmbedder = "silo_recommendations"
DefaultMeilisearchBinaryQuantized = false
MaxMeilisearchSyncBatchSize = 10000
MaxMeilisearchRebuildBatchSize = 25000
@@ -147,6 +149,7 @@ type CatalogSearchSettings struct {
SemanticEnabled bool
SemanticRatio float64
Embedder string
BinaryQuantized bool
}
func DefaultCatalogSearchSettings() CatalogSearchSettings {
@@ -161,6 +164,7 @@ func DefaultCatalogSearchSettings() CatalogSearchSettings {
SemanticEnabled: DefaultMeilisearchSemanticEnabled,
SemanticRatio: DefaultMeilisearchSemanticRatio,
Embedder: DefaultMeilisearchEmbedder,
BinaryQuantized: DefaultMeilisearchBinaryQuantized,
}
}
@@ -239,6 +243,13 @@ func CatalogSearchSettingsFromMap(values map[string]string) (CatalogSearchSettin
}
settings.SemanticEnabled = enabled
}
if raw := strings.TrimSpace(values[SearchSettingMeilisearchBinaryQuantized]); raw != "" {
enabled, err := strconv.ParseBool(raw)
if err != nil {
return settings, fmt.Errorf("%s must be true or false", SearchSettingMeilisearchBinaryQuantized)
}
settings.BinaryQuantized = enabled
}
if raw := strings.TrimSpace(values[SearchSettingMeilisearchSemanticRatio]); raw != "" {
ratio, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(ratio) || ratio < 0 || ratio > 1 {
@@ -399,6 +410,7 @@ type CatalogSearchMeiliStatus struct {
MatchingStrategy string `json:"matching_strategy"`
IndexTypes []string `json:"index_types,omitempty"`
SemanticEnabled bool `json:"semantic_enabled"`
BinaryQuantized bool `json:"binary_quantized"`
SemanticRatio float64 `json:"semantic_ratio"`
Embedder string `json:"embedder"`
}
@@ -429,13 +441,18 @@ type CatalogSearchStatusProvider interface {
Status(ctx context.Context) CatalogSearchRuntimeStatus
}
func catalogSearchMeilisearchEmbedderSettings(embedder string) map[string]any {
return map[string]any{
embedder: map[string]any{
"source": "userProvided",
"dimensions": embeddingvectors.CanonicalDimensions,
},
func catalogSearchMeilisearchEmbedderSettings(embedder string, binaryQuantized bool) map[string]any {
cfg := map[string]any{
"source": "userProvided",
"dimensions": embeddingvectors.CanonicalDimensions,
}
if binaryQuantized {
// One-way per index: Meilisearch cannot de-quantize an index in
// place, so turning this off again requires a rebuild, exactly like
// turning it on. The schema-version hash enforces both transitions.
cfg["binaryQuantized"] = true
}
return map[string]any{embedder: cfg}
}
// catalogSearchMeilisearchSchemaVersion derives the expected index schema
@@ -447,19 +464,28 @@ func catalogSearchMeilisearchEmbedderSettings(embedder string) map[string]any {
// rebuilt rather than serving silently degraded hybrid ranking. A mismatch forces
// a rebuild (SyncOutbox skips, the provider falls back to keyword) and surfaces as
// an ExpectedSchemaVersion divergence in the admin status.
func catalogSearchMeilisearchSchemaVersion(embedder string, itemTypes []string, semanticEnabled bool) int {
func catalogSearchMeilisearchSchemaVersion(embedder string, itemTypes []string, semanticEnabled, binaryQuantized bool) int {
embedder, err := NormalizeCatalogSearchEmbedderName(embedder)
if err != nil {
embedder = DefaultMeilisearchEmbedder
}
h := fnv.New32a()
_, _ = fmt.Fprintf(
h,
identity := fmt.Sprintf(
"embedder=%s;dimensions=%d;index_types=%s;semantic=%t",
embedder,
embeddingvectors.CanonicalDimensions,
strings.Join(normalizeCatalogSearchItemTypes(itemTypes), ","),
semanticEnabled,
)
// Gated on semanticEnabled because binary quantization only affects the
// embedder settings (catalogSearchMeilisearchSettings), which are omitted
// entirely when semantic search is off — so with no vectors on the index
// the flag has no on-index effect and must not force a rebuild. Appended
// only when both are set, so pre-existing indexes built before this flag
// existed (and any index with semantic off) keep their schema version.
if semanticEnabled && binaryQuantized {
identity += ";binary_quantized=true"
}
h := fnv.New32a()
_, _ = h.Write([]byte(identity))
return SearchMeilisearchSchemaVersion*1_000_000 + int(h.Sum32()%1_000_000)
}
+43 -11
View File
@@ -128,7 +128,7 @@ func TestCatalogSearchDocumentVectorsUseEmbedderAndOptOutMissing(t *testing.T) {
}
func TestCatalogSearchMeilisearchSettingsOmitEmbeddersWhenSemanticDisabled(t *testing.T) {
settings := catalogSearchMeilisearchSettings("silo_recommendations", false)
settings := catalogSearchMeilisearchSettings("silo_recommendations", false, false)
if _, ok := settings["embedders"]; ok {
t.Fatalf("semantic-disabled settings should not include embedders: %#v", settings["embedders"])
@@ -139,7 +139,7 @@ func TestCatalogSearchMeilisearchSettingsOmitEmbeddersWhenSemanticDisabled(t *te
}
func TestCatalogSearchMeilisearchSettingsIncludeEmbeddersWhenSemanticEnabled(t *testing.T) {
settings := catalogSearchMeilisearchSettings("custom_embedder", true)
settings := catalogSearchMeilisearchSettings("custom_embedder", true, false)
embedders, ok := settings["embedders"].(map[string]any)
if !ok {
@@ -512,7 +512,7 @@ func TestMeilisearchProviderCachesIndexStateAcrossRequests(t *testing.T) {
store := &countingMeilisearchIndexStateStore{
state: SearchIndexState{
ActiveIndexUID: "search-index",
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false),
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false),
},
pending: 3,
}
@@ -557,7 +557,7 @@ func TestMeilisearchProviderInvalidatesStateCacheOnSearchFailure(t *testing.T) {
store := &countingMeilisearchIndexStateStore{
state: SearchIndexState{
ActiveIndexUID: "search-index",
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false),
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false),
},
}
provider := &MeilisearchSearchProvider{
@@ -607,7 +607,7 @@ func TestMeilisearchProviderUsesActiveIndexWhenPendingUpdatesExist(t *testing.T)
stateRepo: fakeMeilisearchIndexStateStore{
state: SearchIndexState{
ActiveIndexUID: "search-index",
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false),
SchemaVersion: catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false),
},
pending: 7,
},
@@ -683,8 +683,8 @@ func TestNormalizeCatalogSearchIndexTypesValueFormatsCanonicalList(t *testing.T)
}
func TestMeilisearchSchemaVersionChangesWithEmbedder(t *testing.T) {
defaultVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false)
customVersion := catalogSearchMeilisearchSchemaVersion("custom_embedder", nil, false)
defaultVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false)
customVersion := catalogSearchMeilisearchSchemaVersion("custom_embedder", nil, false, false)
if defaultVersion == customVersion {
t.Fatal("schema version should change when embedder changes")
}
@@ -694,8 +694,8 @@ func TestMeilisearchSchemaVersionChangesWithEmbedder(t *testing.T) {
}
func TestMeilisearchSchemaVersionChangesWithIndexTypes(t *testing.T) {
allTypesVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false)
videoOnlyVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, []string{"movie", "series"}, false)
allTypesVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false)
videoOnlyVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, []string{"movie", "series"}, false, false)
if allTypesVersion == videoOnlyVersion {
t.Fatal("schema version should change when indexed media scope changes")
}
@@ -707,8 +707,8 @@ func TestMeilisearchSchemaVersionChangesWithSemanticEnabled(t *testing.T) {
// rebuild. Without this, enabling semantic without a rebuild leaves indexed
// documents missing _vectors while the Postgres coverage gate reports ready,
// silently degrading hybrid ranking.
disabledVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false)
enabledVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, true)
disabledVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false)
enabledVersion := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, true, false)
if disabledVersion == enabledVersion {
t.Fatal("schema version should change when semantic search is toggled")
}
@@ -760,3 +760,35 @@ type fakeModelVectorizer struct {
func (f *fakeModelVectorizer) ActiveEmbeddingModel(_ context.Context) (string, error) {
return f.model, nil
}
func TestCatalogSearchMeilisearchSchemaVersionBinaryQuantized(t *testing.T) {
plain := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, true, false)
quantized := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, true, true)
if plain == quantized {
t.Fatal("binary quantization must change the schema version")
}
}
func TestCatalogSearchMeilisearchSchemaVersionBinaryQuantizedIgnoredWhenSemanticDisabled(t *testing.T) {
// With semantic search off, the index carries no embedders (see
// catalogSearchMeilisearchSettings), so binary quantization has no on-index
// effect and must not shift the schema version — otherwise toggling it would
// force a pointless full rebuild of a vector-less index. It must also stay
// byte-identical to a pre-flag index so upgrades don't spuriously invalidate.
off := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, false)
on := catalogSearchMeilisearchSchemaVersion(DefaultMeilisearchEmbedder, nil, false, true)
if off != on {
t.Fatal("binary quantization must not change the schema version when semantic search is disabled")
}
}
func TestCatalogSearchMeilisearchEmbedderSettingsBinaryQuantized(t *testing.T) {
off := catalogSearchMeilisearchEmbedderSettings("e", false)["e"].(map[string]any)
if _, ok := off["binaryQuantized"]; ok {
t.Fatal("binaryQuantized key must be absent when off (preserves legacy index settings shape)")
}
on := catalogSearchMeilisearchEmbedderSettings("e", true)["e"].(map[string]any)
if on["binaryQuantized"] != true {
t.Fatal("binaryQuantized must be set when enabled")
}
}
+3 -1
View File
@@ -75,6 +75,7 @@ func NewCatalogSearchServiceFromSettings(
MatchingStrategy: settings.MatchingStrategy,
IndexTypes: settings.IndexTypes,
SemanticEnabled: settings.SemanticEnabled,
BinaryQuantized: settings.BinaryQuantized,
SemanticRatio: settings.SemanticRatio,
Embedder: settings.Embedder,
Vectorizer: queryVectorizer,
@@ -144,11 +145,12 @@ func (s *CatalogSearchService) Status(ctx context.Context) CatalogSearchRuntimeS
MatchingStrategy: settings.MatchingStrategy,
IndexTypes: settings.IndexTypes,
SemanticEnabled: settings.SemanticEnabled,
BinaryQuantized: settings.BinaryQuantized,
SemanticRatio: settings.SemanticRatio,
Embedder: settings.Embedder,
},
Index: CatalogSearchIndexStateStatus{
ExpectedSchemaVersion: catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled),
ExpectedSchemaVersion: catalogSearchMeilisearchSchemaVersion(settings.Embedder, settings.IndexTypes, settings.SemanticEnabled, settings.BinaryQuantized),
},
Tasks: []CatalogSearchTaskLink{
{Key: "sync_catalog_search_index", Name: "Sync Catalog Search Index", Href: "/admin/tasks/sync_catalog_search_index"},
+1
View File
@@ -84,6 +84,7 @@ var restartRequiredKeys = map[string]bool{
"catalog.search.meilisearch.semantic_enabled": true,
"catalog.search.meilisearch.semantic_ratio": true,
"catalog.search.meilisearch.embedder": true,
"catalog.search.meilisearch.binary_quantized": true,
}
// restartRequiredPrefixes covers whole namespaces of infrastructure settings:
@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
-- Binary quantization defaults ON for any install with no active catalog
-- search index — fresh installs, and also deployments that configured
-- Meilisearch but never built an index. Deployments with an active index are
-- left unset (= off): flipping quantization changes the index schema-version
-- identity, which closes the incremental-sync gate until a full rebuild runs
-- — that must never happen implicitly on upgrade. Installs with no index yet
-- have no gate to close, so their first rebuild simply starts quantized.
INSERT INTO server_settings (key, value)
SELECT 'catalog.search.meilisearch.binary_quantized', 'true'
WHERE NOT EXISTS (
SELECT 1 FROM server_settings
WHERE key = 'catalog.search.meilisearch.binary_quantized')
AND NOT EXISTS (
SELECT 1 FROM catalog_search_index_state
WHERE active_index_uid IS NOT NULL AND active_index_uid <> '');
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Intentionally a no-op: deleting the setting could silently change the
-- schema-version identity of an index that was built with quantization.
SELECT 1;
-- +goose StatementEnd
+1
View File
@@ -32,6 +32,7 @@ export interface CatalogSearchStatus {
matching_strategy: string;
index_types?: string[];
semantic_enabled: boolean;
binary_quantized: boolean;
semantic_ratio: number;
embedder: string;
};
+2
View File
@@ -143,6 +143,8 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [
"vectors",
"embeddings",
"embedder",
"binary quantization",
"quantized",
],
settings: settingIndex(
"Preferred Provider",
@@ -28,6 +28,7 @@ const MEILI_KEYS = [
"catalog.search.meilisearch.semantic_enabled",
"catalog.search.meilisearch.semantic_ratio",
"catalog.search.meilisearch.embedder",
"catalog.search.meilisearch.binary_quantized",
];
const KEYS = ["catalog.search.provider", ...MEILI_KEYS];
@@ -190,6 +191,16 @@ export default function SearchSettings() {
onChange={(value) => form.setValue("catalog.search.meilisearch.embedder", value)}
disabled={!meiliEnabled}
/>
<SettingField
label="Binary Quantized Vectors"
type="toggle"
value={form.getValue("catalog.search.meilisearch.binary_quantized") || "false"}
onChange={(value) =>
form.setValue("catalog.search.meilisearch.binary_quantized", value)
}
hint="~30x smaller raw vectors (overall index size roughly halves) with a small semantic-relevance cost. Only affects the index when Semantic Search is enabled. Changing this requires a full index rebuild (Rebuild Catalog Search Index) before sync resumes."
disabled={!meiliEnabled}
/>
<div className="py-3">
<ConnectionCheckAction
onClick={handleCheckConnection}
@@ -229,6 +240,10 @@ export default function SearchSettings() {
label="Indexed Types"
value={formatIndexedTypes(status.meilisearch.index_types)}
/>
<StatusRow
label="Binary Quantized"
value={status.meilisearch.binary_quantized ? "Enabled" : "Disabled"}
/>
<StatusRow
label="Semantic Search"
value={status.meilisearch.semantic_enabled ? "Enabled" : "Disabled"}