Die Strategy API ermoeglicht es, verschiedene Suchansaetze pro Projekt zu testen und direkt zu vergleichen — ohne die bestehende Suchlogik zu veraendern.
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.
Simpelster Ansatz: Rohe Query direkt an Meilisearch, ohne Facet-Erkennung, ohne Admin-Rules, ohne Fallback. Dient als Kontrollgruppe zum Vergleich.
Nuetzlich um zu sehen: Wie gut sind die Ergebnisse ohne die ganze Query-Analyse-Pipeline?
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.
search_text_boosted (Title + Brand + Category + Color + Size) als hoechste Prioritaet in searchableAttributes. Description bewusst ausgelassen.filter gesendet.Braucht search_text_boosted im Index. Deployment:
php scripts/update-settings.php # searchableAttributes + localizedAttributes
php scripts/import-feed.php # generiert search_text_boosted Feld
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.
facet-search statt aus parseSearchQuery oder facet-detection-cache.json. Keine hardcodierten Farb-/Groessen-Listen.color, category_path, brand, gender. Der Facet-Hit mit dem hoechsten Count pro Token gewinnt.facet-search Synonyme nicht automatisch expandiert.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).| Konstante | Wert | Bedeutung |
|---|---|---|
MIN_TOKEN_LENGTH | 4 | Tokens < 4 Zeichen werden nicht facet-gesucht (zu viele False-Positives durch Prefix-Match) |
MIN_FACET_COUNT | 2 | Facet-Hit zaehlt erst ab Count ≥ 2 als Zuordnung |
| Distribution-Schwelle | 10× MIN_FACET_COUNT | Fallback-Signal braucht mindestens 20 Hits |
| Dominanz | >60% & ≥3× #2 | Top-Wert muss deutlich dominieren, sonst ignorieren |
| Aspekt | eb-hybrid | facet-search-hybrid |
|---|---|---|
| Facet-Detection | App-seitig (Listen, Cache) | Meili-nativ (facet-search + Synonyms) |
| Filter-Typ | Soft (optionalFilter) | Hart mit Fallback |
| Synonyme | Expandiert via WG-Logik | Meili-Synonyms aus Admin-DB gesynct |
| Token-Entfernung | Nein (Blob-first) | Nein (Query bleibt vollstaendig) |
| 0-Hit-Risiko | Minimal (keine harten Filter) | Minimal (Fallback ohne Auto-Filter) |
| Meili-Calls | 1 | 1–N (N = Tokens × Facets) + 1–2 Haupt-Searches |
rote → rot etc.).color, category_path, brand, gender als filterableAttributes konfiguriert.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.
vector-Param + hybrid.embedder=clip_image
→
Keyword + NN-Fusion
semanticRatio aus Projekt-Settings steuert die Mischung, Default 0.5.facet-search-hybrid sind identisch erhalten._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.use_ai_hybrid wird auf true gezwungen — wer Keyword-only will, waehlt facet-search-hybrid oder plain-keyword.| Aspekt | facet-search-hybrid | facet-search-jinaai |
|---|---|---|
| Embedder | OpenAI text-embedding-3-small (512 Dim) | Jina jina-clip-v2 (1024 Dim) |
| Embed-Zeitpunkt | Meili-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-Dim | Text-Raum | Multimodal (Text+Bild gemeinsam) |
| Staerke | Semantische Text-Aehnlichkeit (Synonyme, Umschreibungen) | Visuelle Aehnlichkeit (Farbe, Form, Stil) |
| Voraussetzung im Index | openai-Embedder + Text-Embeds | clip_image-Embedder + Bild-Embeds (separater Import) |
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).
| Variable | Zweck |
|---|---|
MULTIMODAL_PROVIDER | Provider-Switch, aktuell nur jina implementiert; Interface liegt bereit fuer Cohere/Voyage/Vertex/TEI |
JINA_API_KEY | API-Key aus jina.ai/api-dashboard |
JINA_TOKENS_PER_MINUTE | Konfigurierter Throttle-Wert (Free: 100.000, Paid: 2M, Premium: 50M). 0 deaktiviert das proaktive Throttling. |
EMBEDDING_CACHE_DRIVER | PSR-16-Driver (filesystem|apcu|redis|memcached) |
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.
semanticRatio=1.0)
→
Produkte nach Bild-Aehnlichkeit sortiert
| Nutzerintent | Empfohlene Strategy |
|---|---|
| Klassische Produkt-Suche mit Filter-Unterstuetzung | facet-search-hybrid |
| Mischung Keyword + visuelle Aehnlichkeit | facet-search-jinaai |
| Rein visueller/stilistischer Begriff ohne Wortmatches | facet-search-image |
| „Zeig mir aehnliche Produkte zu diesem hier“ | Demo-Seite /jina-image-test.php (nutzt gespeicherte Vektoren direkt, kein Jina-Call) |
Identisch zu facet-search-jinaai: Bild-Embeddings per scripts/import-ai-index.php --with-image-embeddings im AI-Index.
Eine neue Strategy braucht nur 2 Schritte:
<?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],
);
}
}
// 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.
GET /api/v1/search.php (mit API-Key) oder GET /api/v1/proxy-search.php (ohne Key, intern)
| Parameter | Pflicht | Beschreibung |
|---|---|---|
project_id | Ja | Projekt-ID aus der Datenbank |
q | Ja | Suchbegriff |
strategy | Nein | Strategy-Name (Default: facet-search-hybrid) |
compare | Nein | Komma-getrennte Strategy-Namen fuer Parallelvergleich |
limit | Nein | Max. Treffer (Default: 12, Max: 24) |
offset | Nein | Pagination-Offset (Default: 0) |
facets | Nein | Komma-getrennte Facet-Felder |
filter_brand[] | Nein | Marken-Filter (mehrere moeglich) |
filter_color[] | Nein | Farb-Filter |
filter_size[] | Nein | Groessen-Filter |
filter_gender | Nein | Geschlecht (male/female/unisex/kids) |
# 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"
{
"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\""
}
}
{
"comparison": true,
"query": "rote golfschuhe damen",
"results": {
"facet-search-hybrid": { "estimatedTotalHits": 145, "wallTimeMs": 23, ... },
"plain-keyword": { "estimatedTotalHits": 1478, "wallTimeMs": 9, ... }
}
}
| Datei | Zweck |
|---|---|
src/Search/SearchStrategyInterface.php | Strategy-Vertrag (Interface) |
src/Search/SearchContext.php | Value Object: Projekt + Meilisearch-Client |
src/Search/SearchResult.php | Value Object: Hits + Timing + Debug |
src/Search/ProjectResolver.php | Laedt Projekt aus DB, baut SearchContext |
src/Search/StrategyRegistry.php | Statische Map: Name → Strategy-Klasse |
src/Search/Strategy/PlainKeywordStrategy.php | Baseline: rohe Query ohne Parsing |
src/Search/Strategy/EBHybridStrategy.php | E+B Hybrid — Schema-Boost ueber search_text_boosted |
src/Search/Strategy/FacetSearchHybridStrategy.php | Meili-native Query-Understanding via facet-search + Synonyms |
src/Search/Strategy/FacetSearchJinaAiStrategy.php | Hybrid-Variante mit Jina CLIP v2 als multimodalem Embedder |
src/Search/Strategy/FacetSearchImageStrategy.php | Reine Text→Bild-Suche, semanticRatio=1.0 |
src/Embedding/MultimodalEmbedderInterface.php | Provider-agnostisches Interface (Text+Bild, einzeln und im Batch) |
src/Embedding/JinaClipClient.php | Jina-CLIP-v2-Client mit Throttle + Retry + Auto-Discovery |
src/Embedding/EmbedderFactory.php | Factory, liest MULTIMODAL_PROVIDER aus .env |
src/Embedding/TokenBucket.php | Sliding-Window-Throttler fuer Jinas Tokens-per-Minute-Limit |
src/Embedding/EmbeddingCache.php | PSR-16-Wrapper, Text- und Image-Cache mit Provider/Modell/Dim im Key |
src/Embedding/CacheFactory.php | Cache-Driver-Switch (filesystem|apcu|redis|memcached) |
src/Embedding/CachingMultimodalEmbedder.php | Decorator: cached embedText + embedImage (inkl. Batch-Split) |
scripts/import-ai-index.php | Katalog-Import inkl. --with-image-embeddings, Resume-State, Category-Filter, Batch-Embedding |
public/api/v1/search.php | API-Endpoint (mit Key-Auth) |
public/api/v1/proxy-search.php | Proxy (Key aus .env, fuer Frontend) |
public/jina-image-rec-api.php | Produkt-zu-Produkt-Aehnlichkeit auf Basis gespeicherter Bild-Vektoren (ohne Jina-Call) |
public/jina-image-test.php | Demo-UI: Text-Suche → visuelle Empfehlungen; Klick wechselt Anchor |
public/index.php | Such-UI (nutzt Strategy API) |
Die Infrastruktur ist bereit fuer:
default_strategy-Spalte auf meili_projects