Tutorial: Boost cart-based search
Edit on GitHubThis tutorial describes how you can improve the cart-based search in your project.
Based on the colors of the products in the user’s cart, the catalog must first display products that have the same color. For example, if there’s a red product in the cart, the top results in the catalog must also contain red products.
To solve the challenge, use instructions from the following sections.
Preparation
Full-text search engines like Elasticsearch provide a possibility to influence the sorting of products by tweaking the scoring function. The scoring function assigns weights to each result based on a formula, which in its turn is usually based on text similarity or synonyms, but we can change it to boost specific products higher than others. In this challenge, you will try to affect the scoring function based on the products that are already in the cart.
The second idea leverages the fact that Spryker’s implementation of search is very flexible and allows configuring additional plugins that are used to build search queries.
To solve this task, you will work in the client layer of the Catalog
module located in src/Pyz/Client/Catalog/
.
Step-by-step solution
- If you trace the execution flow of the search starting from
Pyz\Yves\Catalog\Controller\CatalogController
, you can find aCatalogClient
. The client uses a stack of plugins which implements\Spryker\Client\SearchExtension\Dependency\Plugin\QueryExpanderPluginInterface
. It is needed to create a new plugin, which modifies your search queries accordingly. - Implement
Spryker\Client\SearchExtension\Dependency\Plugin\QueryExpanderPluginInterface
and replaceSortedCategoryQueryExpanderPlugin
with your new plugin inPyz\Client\Catalog\CatalogDependencyProvider::createCatalogSearchQueryExpanderPlugins()
. - Name the new plugin
Pyz\Client\Catalog\Plugin\Elasticsearch\QueryExpander\CartBoostQueryExpanderPlugin
. The final version of the plugin can be found here. - Use the
function_score
query modifier function, which lets you modify the scoring of the filtered results. Because Elasticsearch lets you combine multiple queries using different strategies and filtering is needed in the catalog, the bool query strategy is used by theSearch
module to build the base query (seePyz\Client\Catalog\Plugin\Elasticsearch\Query\CatalogSearchQueryPlugin
). It means that you need to extend the boolean query with your custom scoring function. - Check the plugin for a type of incoming query. After this, you can safely extend it when the instance of
BoolQuery
is passed. - To get the content of the cart, you can use
Spryker\Client\Cart\CartClientInterface::getQuote()
. The client must be added as a dependency toCatalogDependencyProvider
and provided toCartBoostQueryExpanderPlugin
through the client factory, like this:
<?php
namespace Pyz\Client\Catalog;
use Pyz\Client\Catalog\Plugin\Elasticsearch\Query\FeaturedProductsQueryPlugin;
use Spryker\Client\Catalog\CatalogFactory as SprykerCatalogFactory;
class CatalogFactory extends SprykerCatalogFactory
{
...
public function getCartClient()
{
return $this->getProvidedDependency(CatalogDependencyProvider::CART_CLIENT);
}
...
}
- To get the color of a product from the cart, you need to read the product data from the key-value storage (Redis) using
\Spryker\Client\Product\ProductClientInterface::getProductConcreteByIdForCurrentLocale()
. The product client must be added toCatalogDependencyProvider
and provided to the plugin in the same way as in the previous step. See the full source code ofCartBoostQueryExpanderPlugin
. - Cleanup: the example code of
CartBoostQueryExpanderPlugin
is good for educational purposes, but needs a minor adjustment to match Spryker architecture:FunctionScore
andMultiMatch
objects must be instantiated inCatalogFactory
of the catalog client. Now, move the instantiation of these objects to the factory and use the factory inside the plugin.
Check out the example code of the CartBoostQueryExpanderPlugin
plugin:
src/Pyz/Client/Catalog/Plugin/Elasticsearch/QueryExpander/CartBoostQueryExpanderPlugin.php
<?php
namespace Pyz\Client\Catalog\Plugin\Elasticsearch\QueryExpander;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\FunctionScore;
use Elastica\Query\MultiMatch;
use Generated\Shared\Search\PageIndexMap;
use Generated\Shared\Transfer\ItemTransfer;
use Generated\Shared\Transfer\QuoteTransfer;
use InvalidArgumentException;
use Spryker\Client\Kernel\AbstractPlugin;
use Spryker\Client\SearchExtension\Dependency\Plugin\QueryExpanderPluginInterface;
use Spryker\Client\SearchExtension\Dependency\Plugin\QueryInterface;
/**
* @method \Pyz\Client\Catalog\CatalogFactory getFactory()
*/
class CartBoostQueryExpanderPlugin extends AbstractPlugin implements QueryExpanderPluginInterface
{
/**
* @param \Spryker\Client\SearchExtension\Dependency\Plugin\QueryInterface $searchQuery
* @param array $requestParameters
*
* @return \Spryker\Client\SearchExtension\Dependency\Plugin\QueryInterface
*/
public function expandQuery(QueryInterface $searchQuery, array $requestParameters = [])
{
$quoteTransfer = $this->getFactory()
->getCartClient()
->getQuote();
// Don't need to change query when cart is empty.
if (!$quoteTransfer->getItems()->count()) {
return $searchQuery;
}
// Make sure that the query we are extending is compatible with our expectations.
$boolQuery = $this->getBoolQuery($searchQuery->getSearchQuery());
// Boost query based on cart.
$this->boostByCartItemColors($boolQuery, $quoteTransfer);
return $searchQuery;
}
/**
* @param \Elastica\Query $query
*
* @throws \InvalidArgumentException
*
* @return \Elastica\Query\BoolQuery
*/
protected function getBoolQuery(Query $query)
{
$boolQuery = $query->getQuery();
if (!$boolQuery instanceof BoolQuery) {
throw new InvalidArgumentException(sprintf(
'Cart boost query expander available only with %s, got: %s',
BoolQuery::class,
get_class($boolQuery)
));
}
return $boolQuery;
}
/**
* @param \Elastica\Query\BoolQuery $boolQuery
* @param \Generated\Shared\Transfer\QuoteTransfer $quoteTransfer
*
* @return void
*/
protected function boostByCartItemColors(BoolQuery $boolQuery, QuoteTransfer $quoteTransfer)
{
$functionScoreQuery = new FunctionScore();
// Define how the computed scores are combined for the used functions.
$functionScoreQuery->setScoreMode(FunctionScore::SCORE_MODE_MULTIPLY);
// Define how the newly computed score is combined with the score of the query.
$functionScoreQuery->setBoostMode(FunctionScore::BOOST_MODE_MULTIPLY);
foreach ($quoteTransfer->getItems() as $itemTransfer) {
$color = $this->getProductColor($itemTransfer);
if ($color) {
// Create filter for all products that contains the same color.
$filter = $this->createFulltextSearchQuery($color);
// Boost the results with a custom number.
$functionScoreQuery->addFunction('weight', 20, $filter);
}
}
// Extend the original search query with function_score that will change the score of the results.
$boolQuery->addMust($functionScoreQuery);
}
/**
* @param \Generated\Shared\Transfer\ItemTransfer $itemTransfer
*
* @return string|null
*/
protected function getProductColor(ItemTransfer $itemTransfer)
{
// We get the concrete product from the key-value storage (Redis).
$productData = $this->getFactory()
->getProductClient()
->getProductConcreteByIdForCurrentLocale($itemTransfer->getId());
return isset($productData['attributes']['color']) ? $productData['attributes']['color'] : null;
}
/**
* @param string $searchString
*
* @return \Elastica\Query\MultiMatch
*/
protected function createFulltextSearchQuery($searchString)
{
// We search for color in the "full-text" and "full-text-boosted" fields.
$matchQuery = (new MultiMatch())
->setFields([
PageIndexMap::FULL_TEXT,
PageIndexMap::FULL_TEXT_BOOSTED . '^3', // Boost results with custom number.
])
->setQuery($searchString)
->setType(MultiMatch::TYPE_CROSS_FIELDS);
return $matchQuery;
}
}
Testing
To test the results, follow these steps:
- Go to a category having products of different colors—for example, Cameras & Camcoders.
- Add any red product to your cart and return to the catalog page. The order of products must be changed accordingly, and you must see red products first.
Thank you!
For submitting the form