weinhalle-search

Search Strategy API

Die Strategy API ermoeglicht es, verschiedene Suchansaetze pro Projekt zu testen und direkt zu vergleichen — ohne die bestehende Suchlogik zu veraendern.

Architektur

Browser / index.php proxy-search.php Key-Injection search.php Strategy API Meilisearch

Der Proxy haelt den API-Key server-seitig — das Frontend braucht keinen Key. Die Strategy API laedt das Projekt aus der Datenbank, waehlt die Strategy und fuehrt die Suche aus.

Verfuegbare Strategien

plain-keyword Einfache Keyword-Suche (Baseline)

Simpelster Ansatz: Rohe Query direkt an Meilisearch, ohne Facet-Erkennung, ohne Admin-Rules, ohne Fallback. Dient als Kontrollgruppe zum Vergleich.

Query Meilisearch search() Ergebnisse

Nuetzlich um zu sehen: Wie gut sind die Ergebnisse ohne die ganze Query-Analyse-Pipeline?

eb-hybrid E+B Hybrid — Schema-Boost

Umsetzung von Approach E aus der Spec specs/search-facet-filter-vs-boosting.md: Query bleibt vollstaendig, das neue Blob-Feld search_text_boosted (Title + Brand + Category + Color + Size) liegt an Position 1 der searchableAttributes und uebernimmt den Ranking-Boost.

Hinweis: Approach B (optionalFilter) ist in Meilisearch 1.13 nicht verfuegbar — die Spec hatte den Feature-Support fehlerhaft angegeben. Detected Facets werden deshalb nur im Debug-Output angezeigt, nicht als Soft-Boost gesendet. Bei Version-Upgrade kann der Codeblock in EBHybridStrategy.php reaktiviert werden.

Ablauf

Volle Query behalten Facetten erkennen (ohne Entfernung) optionalFilter bauen 1 Meili-Call (Blob-first)

Kern-Ideen

  • E — Schema: Neues Feld search_text_boosted (Title + Brand + Category + Color + Size) als hoechste Prioritaet in searchableAttributes. Description bewusst ausgelassen.
  • B — optionalFilter: Erkannte Facetten (Farbe, Groesse, Gender, Brand) werden als Array von unabhaengigen Soft-Boosts gesendet — jede matchende Facette hebt den Rank, non-matching wird nicht ausgeschlossen.
  • Admin-Rules bleiben hart: Merchandiser-Intent (Pins, Banner, Filter) ist autoritativ. Nur die Token-Entfernung wird deaktiviert.
  • Sidebar-Filter bleiben hart: Explizite UI-Klicks sind User-Intent und werden als harte filter gesendet.

Voraussetzungen

Braucht search_text_boosted im Index. Deployment:

php scripts/update-settings.php   # searchableAttributes + localizedAttributes
php scripts/import-feed.php       # generiert search_text_boosted Feld
facet-search-hybrid Facet-Search Hybrid — Meili-native Query-Understanding

Query-Understanding ohne App-seitige Token-Regeln. Statt eigener Facet-Erkennung nutzt die Strategy Meilis /facet-search-Endpoint als Fan-Out: jedes Query-Token wird gegen mehrere Facet-Felder gesucht, das Facet-Feld mit dem hoechsten Treffer-Count gewinnt und wird zu einem harten Filter. Synonym-Luecke in facet-search wird ueber das Meili-Synonyms-Setting (Flexion "rote" → "rot", "graue" → "grau") geschlossen.

Ablauf

Query tokenisieren Meili-Synonyme laden facet-search Fan-Out pro Token Auto-Filter bauen Suche + Fallback bei 0 Treffern

Kern-Ideen

  • Meili-native Detection: Token-zu-Facet-Mapping kommt aus facet-search statt aus parseSearchQuery oder facet-detection-cache.json. Keine hardcodierten Farb-/Groessen-Listen.
  • Detectable Facets: color, category_path, brand, gender. Der Facet-Hit mit dem hoechsten Count pro Token gewinnt.
  • Synonym-Normalisierung: Pro Token werden Original + alle Meili-Synonym-Werte gegen jedes Facet getestet. Kompensiert, dass facet-search Synonyme nicht automatisch expandiert.
  • Distribution-Fallback: Bei kurzen Flexionen ohne facet-search-Hit wird ein Mini-Search mit limit=0 durchgefuehrt und die facetDistribution als Ersatz-Signal genutzt — aber nur fuer color und category_path (gender/brand wuerden verzerren).
  • Dominanz-Schwelle: Distribution-Fallback nur wenn Top-Wert >60% aller Hits abdeckt UND ≥3× Count des zweitstaerksten Werts. Sonst Rauschen.
  • Harter Filter + Fallback: Auto-Filter wird hart gesendet. Bei 0 Treffern: zweite Suche ohne Auto-Filter (nur UI-Filter bleibt).
  • UI-Filter hat Vorrang: Setzt der User in der Sidebar bereits ein Facet, wird der Auto-Filter-Teil dieses Facets verworfen.

Schwellen (Konstanten)

KonstanteWertBedeutung
MIN_TOKEN_LENGTH4Tokens < 4 Zeichen werden nicht facet-gesucht (zu viele False-Positives durch Prefix-Match)
MIN_FACET_COUNT2Facet-Hit zaehlt erst ab Count ≥ 2 als Zuordnung
Distribution-Schwelle10× MIN_FACET_COUNTFallback-Signal braucht mindestens 20 Hits
Dominanz>60% & ≥3× #2Top-Wert muss deutlich dominieren, sonst ignorieren

Unterschiede zu eb-hybrid

Aspekteb-hybridfacet-search-hybrid
Facet-DetectionApp-seitig (Listen, Cache)Meili-nativ (facet-search + Synonyms)
Filter-TypSoft (optionalFilter)Hart mit Fallback
SynonymeExpandiert via WG-LogikMeili-Synonyms aus Admin-DB gesynct
Token-EntfernungNein (Blob-first)Nein (Query bleibt vollstaendig)
0-Hit-RisikoMinimal (keine harten Filter)Minimal (Fallback ohne Auto-Filter)
Meili-Calls11–N (N = Tokens × Facets) + 1–2 Haupt-Searches

Voraussetzungen

  • Synonyme im Meilisearch-Index (wird vom Laravel-Admin-Sync gepflegt — Flexion roterot etc.).
  • Facets color, category_path, brand, gender als filterableAttributes konfiguriert.
facet-search-jinaai Facet-Search JinaAI — Keyword + Text→Bild-Semantic

Variante von facet-search-hybrid, die den Semantic-Anteil nicht ueber den klassischen Text-Embedder (OpenAI) macht, sondern ueber den multimodalen Jina CLIP v2. Der User-Query-Text wird in PHP via Jina zu einem 1024-Dim-Vektor embedded und gegen die im Index gespeicherten Produkt-Bild-Vektoren (_vectors.clip_image) gematcht. Keyword-Treffer und visuelle Aehnlichkeit werden ueber semanticRatio gemischt.

Praktisches Beispiel: "rotes polo" findet sowohl Produkte mit "Polo"/"rot" im Titel (Keyword) als auch Produkte, deren Foto visuell zu "rotes Polo" passt (Semantic) — auch wenn im Titel nie das Wort "rot" steht.

Ablauf

Query-Text PHP: Jina-API-Call cached 1024-Dim-Vektor Meili: vector-Param + hybrid.embedder=clip_image Keyword + NN-Fusion

Kern-Ideen

  • Multimodaler Embedder: Jina CLIP v2 liegt Text und Bild im selben 1024-Dim-Raum ab. Eine Text-Query kann direkt gegen Bild-Embeddings abstandsgemessen werden.
  • Pre-computed Query-Vektor: Der Embed-Call passiert in PHP (nicht von Meili intern), sodass der PSR-16-Cache greift — gleiche Query zweimal eingegeben = 0 Jina-Calls.
  • Hybrid bleibt hybrid: Keyword-Komponente ist erhalten (Fallback bei fehlenden Bild-Embeddings). semanticRatio aus Projekt-Settings steuert die Mischung, Default 0.5.
  • Admin-Logik bleibt: Facet-Detection, Autofilter, Admin-Rules, Federation, Compound-Split — alle Mechanismen aus facet-search-hybrid sind identisch erhalten.
  • AI-Index erzwungen: Strategy haengt _ai an den Projekt-Index an, wenn er nicht ohnehin so endet. Damit funktioniert sie auch in Projekten, die noch keinen expliziten AI-Index konfiguriert haben.
  • Hybrid-Toggle ueberschrieben: use_ai_hybrid wird auf true gezwungen — wer Keyword-only will, waehlt facet-search-hybrid oder plain-keyword.

Unterschiede zu facet-search-hybrid

Aspektfacet-search-hybridfacet-search-jinaai
EmbedderOpenAI text-embedding-3-small (512 Dim)Jina jina-clip-v2 (1024 Dim)
Embed-ZeitpunktMeili-intern (1 HTTP zu OpenAI pro Query)PHP-extern (cached via symfony/cache)
Doc-Vektoren_vectors.openai = Text-Embed der Produkttexte_vectors.clip_image = Bild-Embed der Produktfotos
Query-DimText-RaumMultimodal (Text+Bild gemeinsam)
StaerkeSemantische Text-Aehnlichkeit (Synonyme, Umschreibungen)Visuelle Aehnlichkeit (Farbe, Form, Stil)
Voraussetzung im Indexopenai-Embedder + Text-Embedsclip_image-Embedder + Bild-Embeds (separater Import)

Voraussetzungen

Produkt-Bilder muessen via Jina embedded und im AI-Index als _vectors.clip_image abgelegt sein:

# Vollstaendiger Import aller Bilder (Premium-Key empfohlen)
php scripts/import-ai-index.php --with-image-embeddings

# Nur eine Kategorie (z. B. Bekleidung) embedden
php scripts/import-ai-index.php --with-image-embeddings --category=Bekleidung

# Nach Ctrl-C einfach nochmal starten — setzt automatisch per State-File fort
php scripts/import-ai-index.php --with-image-embeddings

Laufzeit bei Jina Premium (50M TPM): ~22 Minuten fuer 30.000 Produkte. Bei Free-Tier (100k TPM) ca. 7,7 Tage — deshalb nur mit Paid/Premium-Key betreiben. Cache und Resume-State ueberleben Neustarts; der Jina-Adapter hat Auto-Discovery fuer das tatsaechliche TPM-Limit (drosselt sich nach erstem 429 selbst).

Env-Variablen

VariableZweck
MULTIMODAL_PROVIDERProvider-Switch, aktuell nur jina implementiert; Interface liegt bereit fuer Cohere/Voyage/Vertex/TEI
JINA_API_KEYAPI-Key aus jina.ai/api-dashboard
JINA_TOKENS_PER_MINUTEKonfigurierter Throttle-Wert (Free: 100.000, Paid: 2M, Premium: 50M). 0 deaktiviert das proaktive Throttling.
EMBEDDING_CACHE_DRIVERPSR-16-Driver (filesystem|apcu|redis|memcached)
facet-search-image Facet-Search Image — reine Text→Bild-Suche

Fokus-Variante fuer den Anwendungsfall „Finde Produkte, die visuell zu diesem Text passen“ — z. B. "knallgelb", "blumenmuster", "minimalistisch". Keine Keyword-Komponente, reiner Vektor-Match mit semanticRatio=1.0.

Technisch wie facet-search-jinaai, nur ohne den Keyword-Anteil. Nuetzlich wenn der Query-Text nicht im Produkttitel/-beschreibung steht, aber visuell klar ist.

Ablauf

Query-Text Jina embedText Meili NN (semanticRatio=1.0) Produkte nach Bild-Aehnlichkeit sortiert

Wann welche Strategy?

NutzerintentEmpfohlene Strategy
Klassische Produkt-Suche mit Filter-Unterstuetzungfacet-search-hybrid
Mischung Keyword + visuelle Aehnlichkeitfacet-search-jinaai
Rein visueller/stilistischer Begriff ohne Wortmatchesfacet-search-image
„Zeig mir aehnliche Produkte zu diesem hier“Demo-Seite /jina-image-test.php (nutzt gespeicherte Vektoren direkt, kein Jina-Call)

Voraussetzungen

Identisch zu facet-search-jinaai: Bild-Embeddings per scripts/import-ai-index.php --with-image-embeddings im AI-Index.

Neue Strategy hinzufuegen

Eine neue Strategy braucht nur 2 Schritte:

1. Klasse anlegen

<?php
// src/Search/Strategy/OptionalFilterStrategy.php
declare(strict_types=1);

namespace App\Search\Strategy;

use App\Search\{SearchContext, SearchResult, SearchStrategyInterface};

final class OptionalFilterStrategy implements SearchStrategyInterface
{
    public function name(): string { return 'optional-filter'; }
    public function label(): string { return 'optionalFilter-basierte Suche'; }

    public function execute(SearchContext $context, string $query, array $params): SearchResult
    {
        $start = hrtime(true);

        // Hier: eigene Suchlogik (z.B. optionalFilter statt harter Filter)
        $results = $context->client->index($context->indexName)
            ->search($query, [
                'limit'          => $params['limit'] ?? 12,
                'optionalFilter' => 'color = "rot"',  // Soft-Boost statt Hard-Filter
                // ...
            ]);

        $wallMs = (hrtime(true) - $start) / 1_000_000;

        return new SearchResult(
            hits:             array_map([SearchResult::class, 'formatHit'], $results->getHits()),
            estimatedTotalHits: $results->getEstimatedTotalHits(),
            processingTimeMs: (float) $results->getProcessingTimeMs(),
            wallTimeMs:       round($wallMs, 2),
            strategy:         $this->name(),
            debug:            ['queryUsed' => $query],
        );
    }
}

2. In der Registry registrieren

// src/Search/StrategyRegistry.php — eine Zeile hinzufuegen:
private static array $strategies = [
    'plain-keyword'       => Strategy\PlainKeywordStrategy::class,
    'facet-search-hybrid' => Strategy\FacetSearchHybridStrategy::class,
    'optional-filter'     => Strategy\OptionalFilterStrategy::class,  // NEU
];

Fertig. Die neue Strategy erscheint sofort im Dropdown und im Compare-Modus.

API-Referenz

Endpoint

GET /api/v1/search.php (mit API-Key) oder GET /api/v1/proxy-search.php (ohne Key, intern)

Parameter

ParameterPflichtBeschreibung
project_idJaProjekt-ID aus der Datenbank
qJaSuchbegriff
strategyNeinStrategy-Name (Default: facet-search-hybrid)
compareNeinKomma-getrennte Strategy-Namen fuer Parallelvergleich
limitNeinMax. Treffer (Default: 12, Max: 24)
offsetNeinPagination-Offset (Default: 0)
facetsNeinKomma-getrennte Facet-Felder
filter_brand[]NeinMarken-Filter (mehrere moeglich)
filter_color[]NeinFarb-Filter
filter_size[]NeinGroessen-Filter
filter_genderNeinGeschlecht (male/female/unisex/kids)

Beispiele

# Einzelne Strategy
curl "/api/v1/proxy-search.php?project_id=1&q=golfschuhe&strategy=plain-keyword&limit=5"

# Vergleich: zwei Strategies nebeneinander
curl "/api/v1/proxy-search.php?project_id=1&q=rote+golfschuhe+damen&compare=facet-search-hybrid,plain-keyword"

Response (Einzel)

{
  "hits": [...],
  "estimatedTotalHits": 142,
  "processingTimeMs": 4.2,
  "wallTimeMs": 18.7,
  "strategy": "facet-search-hybrid",
  "query": "rote golfschuhe damen",
  "debug": {
    "queryUsed": "golfschuhe",
    "mergedFilter": "color IN [\"rot\"] AND gender = \"female\""
  }
}

Response (Vergleich)

{
  "comparison": true,
  "query": "rote golfschuhe damen",
  "results": {
    "facet-search-hybrid": { "estimatedTotalHits": 145, "wallTimeMs": 23, ... },
    "plain-keyword":    { "estimatedTotalHits": 1478, "wallTimeMs": 9, ... }
  }
}

Datei-Uebersicht

DateiZweck
src/Search/SearchStrategyInterface.phpStrategy-Vertrag (Interface)
src/Search/SearchContext.phpValue Object: Projekt + Meilisearch-Client
src/Search/SearchResult.phpValue Object: Hits + Timing + Debug
src/Search/ProjectResolver.phpLaedt Projekt aus DB, baut SearchContext
src/Search/StrategyRegistry.phpStatische Map: Name → Strategy-Klasse
src/Search/Strategy/PlainKeywordStrategy.phpBaseline: rohe Query ohne Parsing
src/Search/Strategy/EBHybridStrategy.phpE+B Hybrid — Schema-Boost ueber search_text_boosted
src/Search/Strategy/FacetSearchHybridStrategy.phpMeili-native Query-Understanding via facet-search + Synonyms
src/Search/Strategy/FacetSearchJinaAiStrategy.phpHybrid-Variante mit Jina CLIP v2 als multimodalem Embedder
src/Search/Strategy/FacetSearchImageStrategy.phpReine Text→Bild-Suche, semanticRatio=1.0
src/Embedding/MultimodalEmbedderInterface.phpProvider-agnostisches Interface (Text+Bild, einzeln und im Batch)
src/Embedding/JinaClipClient.phpJina-CLIP-v2-Client mit Throttle + Retry + Auto-Discovery
src/Embedding/EmbedderFactory.phpFactory, liest MULTIMODAL_PROVIDER aus .env
src/Embedding/TokenBucket.phpSliding-Window-Throttler fuer Jinas Tokens-per-Minute-Limit
src/Embedding/EmbeddingCache.phpPSR-16-Wrapper, Text- und Image-Cache mit Provider/Modell/Dim im Key
src/Embedding/CacheFactory.phpCache-Driver-Switch (filesystem|apcu|redis|memcached)
src/Embedding/CachingMultimodalEmbedder.phpDecorator: cached embedText + embedImage (inkl. Batch-Split)
scripts/import-ai-index.phpKatalog-Import inkl. --with-image-embeddings, Resume-State, Category-Filter, Batch-Embedding
public/api/v1/search.phpAPI-Endpoint (mit Key-Auth)
public/api/v1/proxy-search.phpProxy (Key aus .env, fuer Frontend)
public/jina-image-rec-api.phpProdukt-zu-Produkt-Aehnlichkeit auf Basis gespeicherter Bild-Vektoren (ohne Jina-Call)
public/jina-image-test.phpDemo-UI: Text-Suche → visuelle Empfehlungen; Klick wechselt Anchor
public/index.phpSuch-UI (nutzt Strategy API)

Geplante Erweiterungen

Die Infrastruktur ist bereit fuer:

Hubble Explore