OpenSearch 3.x przyniósł dojrzałe wsparcie dla k-NN (k-Nearest Neighbors) i vector search – fundamentu wyszukiwania semantycznego. Zamiast dopasowania słów kluczowych („buty sportowe”), możesz teraz wyszukiwać po znaczeniu – klient wpisuje „coś do biegania w górach” i dostaje trafne produkty bez dopasowania słów kluczowych. Pokazuję jak zintegrować to z Magento 2 przez własny moduł, bez Adobe Commerce SaaS.
Wyszukiwanie tradycyjne vs semantyczne
| Zapytanie klienta | Wyniki tradycyjne (keyword) | Wyniki semantyczne (vector) |
|---|---|---|
| „coś do biegania w górach” | Produkty zawierające słowa „bieganie” lub „góry” | Buty trailowe, kijki, plecaki biegowe |
| „prezent dla taty” | Zero wyników (brak takich słów w produkcie) | Produkty popularnie kupowane jako prezenty dla mężczyzn |
| „niebieska elegancka sukienka na wesele” | Sukienki z „niebieski” w nazwie | Sukienki koktajlowe, formalne, w odcieniach granatu i błękitu |
Architektura rozwiązania
1. Generowanie embeddingów
Produkty (nazwa + opis + atrybuty)
↓
Model embeddingów (lokalny lub API)
↓
Wektory float[] (np. 384 wymiary dla all-MiniLM-L6)
↓
OpenSearch k-NN index
2. Wyszukiwanie
Zapytanie użytkownika
↓
Ten sam model embeddingów → wektor zapytania
↓
k-NN search w OpenSearch (cosine similarity)
↓
Top-K najbliższych produktów
Konfiguracja OpenSearch 3.x z k-NN
# OpenSearch 3.x ma k-NN plugin wbudowany
# Sprawdź czy plugin jest aktywny
curl -X GET "http://localhost:9200/_cat/plugins?v"
# Powinien być: opensearch-knn
# Utwórz indeks produktów z k-NN field
curl -X PUT "http://localhost:9200/magento_products_semantic" \
-H 'Content-Type: application/json' \
-d '{
"settings": {
"index.knn": true,
"number_of_shards": 2,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"product_id": { "type": "integer" },
"sku": { "type": "keyword" },
"name": { "type": "text" },
"description": { "type": "text" },
"price": { "type": "float" },
"categories": { "type": "keyword" },
"is_active": { "type": "boolean" },
"embedding": {
"type": "knn_vector",
"dimension": 384,
"method": {
"name": "hnsw",
"space_type": "cosinesimil",
"engine": "lucene"
}
}
}
}
}'
Generowanie embeddingów w PHP – lokalny model przez Ollama
<?php
declare(strict_types=1);
// Użyj lokalnego modelu embeddingów przez Ollama
// Zamiast OpenAI API – zero kosztów, dane nie wychodzą z serwera
class LocalEmbeddingGenerator
{
private const OLLAMA_URL = 'http://localhost:11434';
private const MODEL = 'nomic-embed-text'; // 384 wymiary, szybki
public function __construct(
private \Magento\Framework\HTTP\Client\Curl $curl
) {}
// Generuj embedding dla jednego tekstu
public function generate(string $text): array
{
$this->curl->post(self::OLLAMA_URL . '/api/embeddings', json_encode([
'model' => self::MODEL,
'prompt' => $text,
]));
$response = json_decode($this->curl->getBody(), true, 512, JSON_THROW_ON_ERROR);
return $response['embedding']
?? throw new \RuntimeException('Failed to generate embedding');
}
// Batch – generuj embeddingi dla wielu tekstów
public function generateBatch(array $texts): array
{
return array_map([$this, 'generate'], $texts);
}
}
// Serwis przygotowujący tekst produktu do embeddingu
class ProductTextExtractor
{
public function extract(\Magento\Catalog\Api\Data\ProductInterface $product): string
{
$parts = [
$product->getName(),
];
// Dodaj opis (bez HTML tagów)
$description = $product->getCustomAttribute('description')?->getValue();
if ($description) {
$parts[] = strip_tags($description);
}
// Dodaj short description
$shortDesc = $product->getCustomAttribute('short_description')?->getValue();
if ($shortDesc) {
$parts[] = strip_tags($shortDesc);
}
// Dodaj kategorie (poprawiają trafność)
$categoryIds = $product->getCategoryIds();
// ... pobierz nazwy kategorii i dodaj
return implode(' ', array_filter($parts));
}
}
Indeksowanie produktów
<?php
declare(strict_types=1);
class SemanticIndexer
{
private const BATCH_SIZE = 50;
private const INDEX_NAME = 'magento_products_semantic';
private const OPENSEARCH_URL = 'http://opensearch:9200';
public function __construct(
private \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $collectionFactory,
private ProductTextExtractor $textExtractor,
private LocalEmbeddingGenerator $embedder,
private \Magento\Framework\HTTP\Client\Curl $curl
) {}
public function reindexAll(): void
{
$collection = $this->collectionFactory->create();
$collection->addAttributeToSelect(['name', 'description', 'short_description', 'price', 'status']);
$collection->addAttributeToFilter('status', 1);
$collection->setPageSize(self::BATCH_SIZE);
$lastPage = $collection->getLastPageNumber();
for ($page = 1; $page <= $lastPage; $page++) {
$collection->setCurPage($page)->load();
$documents = [];
foreach ($collection as $product) {
$text = $this->textExtractor->extract($product);
$vector = $this->embedder->generate($text);
$documents[] = [
'product_id' => (int) $product->getId(),
'sku' => $product->getSku(),
'name' => $product->getName(),
'price' => (float) $product->getPrice(),
'is_active' => true,
'embedding' => $vector,
];
}
$this->bulkIndex($documents);
$collection->clear();
echo "Zaindeksowano stronę {$page}/{$lastPage}\n";
}
}
private function bulkIndex(array $documents): void
{
// OpenSearch Bulk API
$body = '';
foreach ($documents as $doc) {
$body .= json_encode(['index' => ['_index' => self::INDEX_NAME, '_id' => $doc['product_id']]]) . "\n";
$body .= json_encode($doc) . "\n";
}
$this->curl->setHeaders(['Content-Type' => 'application/x-ndjson']);
$this->curl->post(self::OPENSEARCH_URL . '/_bulk', $body);
}
}
Wyszukiwanie semantyczne
<?php
declare(strict_types=1);
class SemanticSearchService
{
private const INDEX_NAME = 'magento_products_semantic';
private const OPENSEARCH_URL = 'http://opensearch:9200';
public function __construct(
private LocalEmbeddingGenerator $embedder,
private \Magento\Framework\HTTP\Client\Curl $curl
) {}
public function search(string $query, int $limit = 12): array
{
// Zamień tekst zapytania na wektor
$queryVector = $this->embedder->generate($query);
// k-NN search w OpenSearch
$body = json_encode([
'size' => $limit,
'query' => [
'knn' => [
'embedding' => [
'vector' => $queryVector,
'k' => $limit,
],
],
],
// Opcjonalnie: połącz z tradycyjnym wyszukiwaniem (hybrid search)
'_source' => ['product_id', 'sku', 'name', 'price'],
]);
$this->curl->setHeaders(['Content-Type' => 'application/json']);
$this->curl->post(self::OPENSEARCH_URL . '/' . self::INDEX_NAME . '/_search', $body);
$response = json_decode($this->curl->getBody(), true, 512, JSON_THROW_ON_ERROR);
$hits = $response['hits']['hits'] ?? [];
return array_map(fn($hit) => [
'product_id' => $hit['_source']['product_id'],
'sku' => $hit['_source']['sku'],
'name' => $hit['_source']['name'],
'score' => $hit['_score'],
], $hits);
}
// Hybrid search - łączy semantyczne i keyword
public function hybridSearch(string $query, int $limit = 12): array
{
$queryVector = $this->embedder->generate($query);
$body = json_encode([
'size' => $limit,
'query' => [
'hybrid' => [
'queries' => [
// Semantyczne (vector)
['knn' => ['embedding' => ['vector' => $queryVector, 'k' => $limit]]],
// Keyword (BM25)
['multi_match' => [
'query' => $query,
'fields' => ['name^3', 'description'],
]],
],
],
],
]);
$this->curl->setHeaders(['Content-Type' => 'application/json']);
$this->curl->post(self::OPENSEARCH_URL . '/' . self::INDEX_NAME . '/_search', $body);
$response = json_decode($this->curl->getBody(), true, 512, JSON_THROW_ON_ERROR);
return $response['hits']['hits'] ?? [];
}
}
Podsumowanie
Wyszukiwanie semantyczne w Magento 2 przez OpenSearch k-NN i lokalne embeddingi przez Ollama jest teraz dostępne bez SaaS i kosztów API. Model nomic-embed-text przez Ollama działa na serwerze, dane klientów nie wychodzą na zewnątrz. Jakość wyników jest porównywalna z płatnymi rozwiązaniami dla typowych zapytań produktowych. Kluczowe: hybrid search (semantyczne + keyword) daje lepsze wyniki niż każda metoda osobno – warto wdrożyć obie i połączyć przez normalizację score’ów.
