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?
2. What are aggregations used for in search?
3. How does autocomplete improve UX?
Flashcards
Question
How do you tune relevance?
Click to reveal answer
Answer
Weight fields, boost rules, match_phrase with boost
Question
What are aggregations?
Click to reveal answer
Answer
Facet counts for categories, price ranges, brands, etc.
Question
How does autocomplete work?
Click to reveal answer
Answer
AJAX query on keyup with debounce, returns suggestions
Question
What is fuzziness?
Click to reveal answer
Answer
Allows typo tolerance in search queries (AUTO)
Question
What search analytics to track?
Click to reveal answer
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