PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

OpenSearch 3.x vector search – embeddingi przez Ollama, k-NN, hybrid search

by Henryk Tews / środa, 08 kwietnia 2026 / Opublikowano w Magento 2

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.

About Henryk Tews

Co możesz przeczytać następne

Redis – trzy zastosowania, konfiguracja env.php, osobne instancje, monitoring
Varnish – VCL, cache tagging, ESI, invalidacja, DDEV setup
GraphQL – własny resolver, schemat, autoryzacja, testowanie w DDEV
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}