Skip to content
advanced Phase 118 · Advanced Projects

Project - Custom Search Functionality

Build custom search with relevance tuning, faceted search, autocomplete, and search analytics

2h
0 problems
Topic Progress 0%

Search Module Structure

Module Structure

app/code/Vendor/SmartSearch/
├── registration.php
├── etc/
│   ├── module.xml
│   ├── di.xml
│   ├── indexing.xml
│   └── search_request.xml
├── Search/
│   ├── RequestBuilder.php
│   ├── RelevanceConfig.php
│   └── SuggestionProvider.php
├── Indexer/
│   ├── Reindexer.php
│   └── DataProvider.php
├── Model/
│   ├── Search/
│   │   ├── FacetBuilder.php
│   │   ├── AutocompleteProvider.php
│   │   └── AnalyticsTracker.php
│   └── ResourceModel/
│       └── SearchLog.php
├── Observer/
│   └── TrackSearchObserver.php
├── Plugin/
│   └── CatalogSearch/
│       └── QueryFactoryPlugin.php
└── view/
    └── frontend/
        ├── web/
        │   ├── js/
        │   │   └── search-autocomplete.js
        │   └── template/
        │       └── search-autocomplete.html
        └── requirejs-config.js

registration.php and module.xml

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Vendor_SmartSearch',
    __DIR__
);
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Vendor_SmartSearch" setup_version="1.0.0">
        <sequence>
            <module name="Magento_CatalogSearch"/>
            <module name="Magento_LayeredNavigation"/>
        </sequence>
    </module>
</config>

OpenSearch Index Configuration

<!-- etc/indexing.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Indexer/etc/indexer.xsd">
    <indexer id="smart_search_relevance" class="Vendor\SmartSearch\Indexer\Reindexer"
             primary="true" label="Smart Search Relevance" visible="true">
        <title>Smart Search Relevance Index</title>
        <description>Custom relevance scoring for search results</description>
    </indexer>
</config>

Relevance Configuration

<?php
namespace Vendor\SmartSearch\Search;

class RelevanceConfig
{
    private array $defaultWeights = [
        'name' => 10,
        'sku' => 8,
        'description' => 3,
        'short_description' => 5,
        'category' => 6,
        'attribute_set' => 2,
    ];

    private array $boostRules = [
        'in_stock' => 1.5,
        'bestseller' => 1.3,
        'new' => 1.2,
        'on_sale' => 1.1,
    ];

    public function getWeights(): array
    {
        return $this->defaultWeights;
    }

    public function getBoostRules(): array
    {
        return $this->boostRules;
    }

    public function getMinScore(): float
    {
        return 0.1;
    }

    public function getMaxResults(): int
    {
        return 1000;
    }
}

Relevance Tuning and Faceted Search

Custom Request Builder

<?php
namespace Vendor\SmartSearch\Search;

use Magento\Search\Model\QueryFactory;
use Magento\CatalogSearch\Model\Search\IndexBuilder;

class RequestBuilder
{
    public function __construct(
        private RelevanceConfig $relevanceConfig,
        private QueryFactory $queryFactory,
    ) {
    }

    public function buildRequest(string $query, array $filters = [], int $page = 1, int $pageSize = 20): array
    {
        $searchQuery = $this->queryFactory->create();
        $searchQuery->loadByQuery($query);

        $request = [
            'index' => 'magento2_product_1_v1',
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => [
                            'multi_match' => [
                                'query' => $query,
                                'fields' => $this->getWeightedFields(),
                                'type' => 'best_fields',
                                'fuzziness' => 'AUTO',
                                'prefix_length' => 2,
                                'max_expansions' => 50,
                            ]
                        ],
                        'should' => $this->buildBoostQueries($query),
                        'filter' => $this->buildFilters($filters),
                    ]
                ],
                'aggs' => $this->buildAggregations(),
                'sort' => $this->buildSort($query),
                'highlight' => [
                    'fields' => [
                        'name' => ['number_of_fragments' => 0],
                        'description' => ['fragment_size' => 150, 'number_of_fragments' => 2],
                    ],
                    'pre_tags' => ['<strong>'],
                    'post_tags' => ['</strong>'],
                ],
            ],
            'from' => ($page - 1) * $pageSize,
            'size' => $pageSize,
        ];

        return $request;
    }

    private function getWeightedFields(): array
    {
        $weights = $this->relevanceConfig->getWeights();
        $fields = [];

        foreach ($weights as $field => $weight) {
            $fields[] = "{$field}^{$weight}";
        }

        return $fields;
    }

    private function buildBoostQueries(string $query): array
    {
        $boostRules = $this->relevanceConfig->getBoostRules();
        $should = [];

        // Boost exact matches
        $should[] = [
            'match_phrase' => [
                'name' => [
                    'query' => $query,
                    'boost' => 5,
                ]
            ]
        ];

        // Boost in-stock products
        if (isset($boostRules['in_stock'])) {
            $should[] = [
                'term' => [
                    'in_stock' => [
                        'value' => true,
                        'boost' => $boostRules['in_stock'],
                    ]
                ]
            ];
        }

        // Boost bestsellers
        if (isset($boostRules['bestseller'])) {
            $should[] = [
                'range' => [
                    'sales_count' => [
                        'gte' => 100,
                        'boost' => $boostRules['bestseller'],
                    ]
                ]
            ];
        }

        return $should;
    }

    private function buildFilters(array $filters): array
    {
        $filterClauses = [];

        foreach ($filters as $field => $value) {
            if (is_array($value)) {
                $filterClauses[] = [
                    'terms' => [$field => $value]
                ];
            } else {
                $filterClauses[] = [
                    'term' => [$field => $value]
                ];
            }
        }

        return $filterClauses;
    }

    private function buildAggregations(): array
    {
        return [
            'categories' => [
                'terms' => ['field' => 'category_ids', 'size' => 20]
            ],
            'price_ranges' => [
                'range' => [
                    'field' => 'price',
                    'ranges' => [
                        ['to' => 25],
                        ['from' => 25, 'to' => 50],
                        ['from' => 50, 'to' => 100],
                        ['from' => 100],
                    ]
                ]
            ],
            'in_stock' => [
                'filter' => ['term' => ['in_stock' => true]],
                'aggs' => ['in_stock_count' => ['value_count' => ['field' => 'entity_id']]]
            ],
            'brands' => [
                'terms' => ['field' => 'manufacturer', 'size' => 10]
            ],
        ];
    }

    private function buildSort(string $query): array
    {
        return [
            '_score' => ['order' => 'desc'],
            'sales_count' => ['order' => 'desc', 'missing' => '_last'],
            'created_at' => ['order' => 'desc'],
        ];
    }
}

Facet Builder

<?php
namespace Vendor\SmartSearch\Model\Search;

class FacetBuilder
{
    public function buildFacets(array $aggregations, array $activeFilters): array
    {
        $facets = [];

        foreach ($aggregations as $name => $aggregation) {
            $facet = [
                'label' => $this->getFacetLabel($name),
                'type' => $this->getFacetType($name),
                'options' => [],
            ];

            if (isset($aggregation['buckets'])) {
                foreach ($aggregation['buckets'] as $bucket) {
                    $facet['options'][] = [
                        'value' => $bucket['key'],
                        'label' => $this->formatFacetLabel($name, $bucket['key']),
                        'count' => $bucket['doc_count'],
                        'active' => in_array($bucket['key'], $activeFilters[$name] ?? []),
                    ];
                }
            }

            $facets[$name] = $facet;
        }

        return $facets;
    }

    private function getFacetLabel(string $name): string
    {
        return match($name) {
            'categories' => 'Category',
            'price_ranges' => 'Price',
            'in_stock' => 'Availability',
            'brands' => 'Brand',
            default => ucfirst(str_replace('_', ' ', $name)),
        };
    }

    private function getFacetType(string $name): string
    {
        return match($name) {
            'price_ranges' => 'range',
            'in_stock' => 'boolean',
            default => 'select',
        };
    }

    private function formatFacetLabel(string $name, $value): string
    {
        if ($name === 'price_ranges') {
            return $value;
        }
        return (string) $value;
    }
}

Autocomplete Implementation

Autocomplete Provider

<?php
namespace Vendor\SmartSearch\Model\Search;

use Magento\Search\Model\AutocompleteInterface;
use Magento\Search\Model\Autocomplete\ItemFactory;

class AutocompleteProvider implements AutocompleteInterface
{
    private int $maxSuggestions = 10;
    private int $maxProducts = 5;
    private int $maxCategories = 3;

    public function __construct(
        private ItemFactory $itemFactory,
        private \Magento\CatalogSearch\Model\ResourceModel\Fulltext\CollectionFactory $fulltextCollection,
        private \Magento\Catalog\Model\ResourceModel\Category\CollectionFactory $categoryCollection,
        private \Psr\Log\LoggerInterface $logger,
    ) {
    }

    public function getItems(): array
    {
        $query = $this->getCleanQuery();
        if (empty($query)) {
            return [];
        }

        $items = [];

        // Get product suggestions
        $products = $this->getProductSuggestions($query);
        foreach ($products as $product) {
            $items[] = $this->itemFactory->create([
                'title' => $product->getName(),
                'url' => $product->getProductUrl(),
                'price' => $product->getPriceInfo()->getPrice('final_price')->getValue(),
                'image' => $this->getImageUrl($product),
                'type' => 'product',
            ]);
        }

        // Get category suggestions
        $categories = $this->getCategorySuggestions($query);
        foreach ($categories as $category) {
            $items[] = $this->itemFactory->create([
                'title' => $category->getName(),
                'url' => $category->getUrl(),
                'type' => 'category',
            ]);
        }

        // Get search suggestions
        $suggestions = $this->getSearchSuggestions($query);
        foreach ($suggestions as $suggestion) {
            $items[] = $this->itemFactory->create([
                'title' => $suggestion,
                'url' => $this->getSearchUrl($suggestion),
                'type' => 'suggestion',
            ]);
        }

        return array_slice($items, 0, $this->maxSuggestions);
    }

    private function getProductSuggestions(string $query): array
    {
        $collection = $this->fulltextCollection->create();
        $collection->addFieldToFilter('name', ['like' => '%' . $query . '%']);
        $collection->setPageSize($this->maxProducts);
        $collection->addStoreFilter();

        return $collection->getItems();
    }

    private function getCategorySuggestions(string $query): array
    {
        $collection = $this->categoryCollection->create();
        $collection->addFieldToFilter('name', ['like' => '%' . $query . '%']);
        $collection->addFieldToFilter('is_active', 1);
        $collection->setPageSize($this->maxCategories);

        return $collection->getItems();
    }

    private function getSearchSuggestions(string $query): array
    {
        $connection = $this->resource->getConnection();
        $tableName = $this->resource->getTableName('catalogsearch_query');

        return $connection->fetchCol(
            "SELECT query_text FROM {$tableName} WHERE query_text LIKE ? AND num_results > 0 ORDER BY popularity DESC LIMIT 5",
            ['%' . $query . '%']
        );
    }

    private function getCleanQuery(): string
    {
        return trim($this->queryFactory->create()->getQueryText());
    }

    private function getImageUrl($product): string
    {
        try {
            return $this->imageHelper->init($product, 'product_search_result')->getUrl();
        } catch (\Exception $e) {
            return '';
        }
    }

    private function getSearchUrl(string $query): string
    {
        return $this->storeManager->getStore()->getUrl('catalogsearch/result', ['q' => $query]);
    }
}

JavaScript Autocomplete Component

// view/frontend/web/js/search-autocomplete.js
define([
    'jquery',
    'ko',
    'uiComponent',
    'Magento_Search/js/search' 
], function ($, ko, Component, search) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_SmartSearch/search-autocomplete',
            searchUrl: '/catalogsearch/result/',
            minChars: 2,
            delay: 300,
        },

        initialize: function () {
            this._super();
            this.searchQuery = ko.observable('');
            this.results = ko.observableArray([]);
            this.isLoading = ko.observable(false);
            this.showResults = ko.observable(false);
            this.selectedIndex = ko.observable(-1);

            this.searchQuery.subscribe(this.debounce(this.onSearch.bind(this), this.delay));

            return this;
        },

        onSearch: function (query) {
            if (query.length < this.minChars) {
                this.results([]);
                this.showResults(false);
                return;
            }

            this.isLoading(true);

            $.ajax({
                url: '/rest/V1/smartsearch/autocomplete',
                method: 'GET',
                data: { "q": query },
                success: function (data) {
                    this.results(data);
                    this.showResults(data.length > 0);
                    this.selectedIndex(-1);
                }.bind(this),
                error: function () {
                    this.results([]);
                    this.showResults(false);
                }.bind(this),
                complete: function () {
                    this.isLoading(false);
                }.bind(this)
            });
        },

        selectResult: function (item) {
            window.location.href = item.url;
        },

        handleKeyup: function (data, event) {
            var results = this.results();
            var index = this.selectedIndex();

            if (event.key === 'ArrowDown') {
                this.selectedIndex(Math.min(index + 1, results.length - 1));
            } else if (event.key === 'ArrowUp') {
                this.selectedIndex(Math.max(index - 1, 0));
            } else if (event.key === 'Enter' && index >= 0) {
                this.selectResult(results[index]);
            }

            return true;
        },

        debounce: function (func, wait) {
            var timeout;
            return function () {
                var context = this;
                var args = arguments;
                clearTimeout(timeout);
                timeout = setTimeout(function () {
                    func.apply(context, args);
                }, wait);
            };
        },

        formatPrice: function (price) {
            return parseFloat(price).toFixed(2);
        }
    });
});

Search Analytics and Testing

Search Analytics Tracker

<?php
namespace Vendor\SmartSearch\Model\Search;

use Psr\Log\LoggerInterface;

class AnalyticsTracker
{
    public function __construct(
        private \Magento\Framework\App\ResourceConnection $resource,
        private LoggerInterface $logger,
    ) {
    }

    public function trackSearch(string $query, int $resultCount, int $customerId = null): void
    {
        $connection = $this->resource->getConnection();
        $tableName = $this->resource->getTableName('smart_search_log');

        $connection->insert($tableName, [
            'query' => $query,
            'result_count' => $resultCount,
            'customer_id' => $customerId,
            'store_id' => $this->getStoreId(),
            'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
        ]);
    }

    public function trackClick(string $query, string $productId, int $position): void
    {
        $connection = $this->resource->getConnection();
        $tableName = $this->resource->getTableName('smart_search_click');

        $connection->insert($tableName, [
            'query' => $query,
            'product_id' => $productId,
            'position' => $position,
            'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
        ]);
    }

    public function getZeroResultQueries(int $limit = 50): array
    {
        $connection = $this->resource->getConnection();
        $tableName = $this->resource->getTableName('smart_search_log');

        return $connection->fetchAll(
            "SELECT query, COUNT(*) as count FROM {$tableName} WHERE result_count = 0 GROUP BY query ORDER BY count DESC LIMIT ?",
            [$limit]
        );
    }

    public function getPopularQueries(int $limit = 50): array
    {
        $connection = $this->resource->getConnection();
        $tableName = $this->resource->getTableName('smart_search_log');

        return $connection->fetchAll(
            "SELECT query, COUNT(*) as count, AVG(result_count) as avg_results FROM {$tableName} GROUP BY query ORDER BY count DESC LIMIT ?",
            [$limit]
        );
    }

    public function getSearchConversionRate(): array
    {
        $connection = $this->resource->getConnection();
        $searchTable = $this->resource->getTableName('smart_search_log');
        $orderTable = $this->resource->getTableName('sales_order');

        return $connection->fetchRow(
            "SELECT 
                (SELECT COUNT(DISTINCT query) FROM {$searchTable}) as total_searches,
                (SELECT COUNT(DISTINCT search_query) FROM {$orderTable} WHERE search_query IS NOT NULL) as converting_searches"
        );
    }
}

Search Analytics Table Schema

<!-- etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="smart_search_log" resource="default" engine="innodb">
        <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true"/>
        <column xsi:type="varchar" name="query" nullable="false" length="255"/>
        <column xsi:type="int" name="result_count" unsigned="true" nullable="false" default="0"/>
        <column xsi:type="int" name="customer_id" unsigned="true" nullable="true"/>
        <column xsi:type="int" name="store_id" unsigned="true" nullable="false"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
        <constraint referenceType="primary">
            <column name="id"/>
        </constraint>
        <index referenceType="btree">
            <column name="query"/>
        </index>
        <index referenceType="btree">
            <column name="created_at"/>
        </index>
    </table>

    <table name="smart_search_click" resource="default" engine="innodb">
        <column xsi:type="int" name="id" padding="10" unsigned="true" nullable="false" identity="true"/>
        <column xsi:type="varchar" name="query" nullable="false" length="255"/>
        <column xsi:type="int" name="product_id" unsigned="true" nullable="false"/>
        <column xsi:type="int" name="position" unsigned="true" nullable="false"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
        <constraint referenceType="primary">
            <column name="id"/>
        </constraint>
    </table>
</schema>

Search Query Test

<?php
namespace Vendor\SmartSearch\Test\Integration\Search;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

class SmartSearchTest extends TestCase
{
    public function testSearchReturnsResults(): void
    {
        $objectManager = Bootstrap::getObjectManager();

        $searchEngine = $objectManager->create(
            \Magento\Search\Model\EngineResolver::class
        );

        $this->assertEquals('elasticsearch7', $searchEngine->getCurrentSearchEngine());
    }

    public function testAutocompleteReturnsSuggestions(): void
    {
        $objectManager = Bootstrap::getObjectManager();

        $autocomplete = $objectManager->create(
            \Vendor\SmartSearch\Model\Search\AutocompleteProvider::class
        );

        // Test with valid query
        $items = $autocomplete->getItems();
        $this->assertIsArray($items);
    }

    public function testRelevanceWeights(): void
    {
        $config = new \Vendor\SmartSearch\Search\RelevanceConfig();
        $weights = $config->getWeights();

        $this->assertArrayHasKey('name', $weights);
        $this->assertGreaterThan($weights['description'], $weights['name']);
    }
}

Quiz

1. What boosts exact phrase matches in relevance tuning?

Question 1 options

2. What are aggregations used for in search?

Question 2 options

3. How does autocomplete improve UX?

Question 3 options

Flashcards

Question

How do you tune relevance?

Answer

Weight fields, boost rules, match_phrase with boost

Question

What are aggregations?

Answer

Facet counts for categories, price ranges, brands, etc.

Question

How does autocomplete work?

Answer

AJAX query on keyup with debounce, returns suggestions

Question

What is fuzziness?

Answer

Allows typo tolerance in search queries (AUTO)

Question

What search analytics to track?

Answer

Popular queries, zero-result queries, click positions, conversion rate

Revision Notes

Key Takeaways

  • 1. Relevance tuning uses field weights and boost rules
  • 2. Aggregations provide faceted navigation counts
  • 3. Autocomplete uses debounced AJAX with prefix matching
  • 4. Fuzziness allows typo tolerance in search
  • 5. Search analytics track popular queries, zero-results, and conversions

Interview Tips

  • Explain how to tune search relevance for better results
  • Describe faceted search implementation with aggregations
  • Discuss autocomplete architecture and performance considerations
  • Talk about search analytics and how to use the data

Cheat Sheet

Search Relevance:
  Field weights: name^10, sku^8, desc^3
  Boost rules: in_stock 1.5x, bestseller 1.3x
  match_phrase: Exact match boost
  fuzziness: AUTO for typo tolerance

Faceted Search:
  Aggregations → Bucket counts
  Category, Price, Brand, Stock filters
  Active filter state tracking

Autocomplete:
  Debounced AJAX (300ms delay)
  Min 2 characters
  Product + Category + Suggestion results

Analytics:
  Zero-result queries → Improve content
  Popular queries → Boost visibility
  Click positions → Measure relevance
  Conversion rate → Search ROI