Image 1 of 5
Image 2 of 5
Image 3 of 5
Image 4 of 5
Image 5 of 5
Image 1 of 5
Image 2 of 5
Image 3 of 5
Image 4 of 5
Image 5 of 5

Vendure Advanced Search Plugin

Product information

By Vendure GmbH

Advanced, efficient, lightning-fast search with support for fuzzy matching, synonyms, curation, analytics and more

Updates and support
Save 20%
€96.00
/ month
billed annually
Updates and support
€120.00
/ month
Support by
Support languages
en
Integration type
Paid
Category
Search
Compatible with
^2.0.0||^3.0.0
Latest version
2.1.1
Last published
Jan 7, 2025

Vendure Advanced Search Plugin

This plugin enables lightning-fast, memory-efficient search backed by Typesense, as well as powerful search analytics.

This is the most advanced and feature-rich search solution available for Vendure.

Features

  • Typo tolerance: Handle spelling mistakes with configurable typo tolerance.
  • Tunable weightings: Define what relative weight to give title, description and SKU.
  • Index any kind of custom data alongside your products, including custom field data
  • Filter & sort by custom fields
  • Advanced faceted searching
  • Geopoint search & filtering: search by distance from a given location.
  • Result pinning: Pin specific results to a particular position e.g. for merchandising.
  • Synonyms: Define synonyms to increase accuracy of search results
  • Custom search indexes: index any dataset into Typesense and expose via Vendure APIs.
  • Advanced analytics giving insights into popular searches, click-through rate, result rate etc.

Getting Started

Pre-requisites

Typesense

You’ll need an instance of Typesense available, which is the underlying search engine used by this plugin.

You can quickly set up a local instance of Typesense using Docker:

export TYPESENSE_API_KEY=xyz
    
mkdir "$(pwd)"/typesense-data

docker run -p 8108:8108 \
            -v"$(pwd)"/typesense-data:/data typesense/typesense:27.0 \
            --data-dir /data \
            --api-key=$TYPESENSE_API_KEY \
            --enable-cors

or with Docker Compose:

services:
  typesense:
    image: typesense/typesense:27.0
    restart: on-failure
    ports:
      - "8108:8108"
    volumes:
      - ./typesense-data:/data
    command: '--data-dir /data --api-key=xyz --enable-cors'

Or you can set up an account with Typesense Cloud for a hosted solution.

Clickhouse (optional)

For the best support for search analytics, we strongly recommend Clickhouse:

docker pull yandex/clickhouse-server:24-alpine

Note: If you are self-hosting Clickhouse, the default configuration can use a lot of disk space due to the logging settings. Read Calming down Clickhouse for some tips on how to reduce disk usage.

If you do not want to use Clickhouse, we also support SQL-based analytics which will store the analytics data in your database.

It is also possible to use this plugin without analytics - see the Search Analytics guide.

Installation

npm install @vendure-hub/vendure-advanced-search-plugin

Configuration

Add the plugin to your VendureConfig:

import { AdvancedSearchPlugin, ClickhouseAnalyticsStrategy, SqlAnalyticsStrategy } from '@vendure-hub/vendure-advanced-search-plugin';

export const config = {
  //...
  plugins: [
    AdvancedSearchPlugin.init({
      // A secret string used to encrypt the Typesense Shop API key in the database. When this is set, a
      // new customField will be defined on the GlobalSettings entity, which is used to store
      // the encrypted shop API key.
      shopApiKeySecret: process.env.SHOP_API_KEY_SECRET,  
      // The license key can be found in your Vendure Hub account
      // at https://vendure.io/account/licenses, and then clicking
      // on the install instructions for this plugin's license
      licenseKey: process.env.ADVANCED_SEARCH_LICENSE_KEY,
      typeSenseClientOptions: {
        apiKey: process.env.TYPESENSE_API_KEY as string,
        nodes: [
        {
          host: process.env.TYPESENSE_HOST as string,
          port: 8108,
          protocol: 'http',
        },
        ],
      },
      analytics: {
        // In this example we show the use of both the Clickhouse and SQL analytics strategies.
        // You can also use just one of these, or none at all.
        analyticsStrategy: process.env.USE_CLICKHOUSE
          ? new ClickhouseAnalyticsStrategy({
              url: process.env.CLICKHOUSE_URL as string,
              port: 8123,
              database: 'vendure_search_analytics',
              debug: false,
            })
          : new SqlAnalyticsStrategy(),
      },
      // If set to true, any changes to product data will not get immediately updated in the
      // search index. Rather, you will need to manually "flush" the buffer via the notification
      // icon in the Admin UI header. This is useful for large catalogs where frequent updates
      // would be too resource-intensive. 
      bufferUpdates: true,
    }),
  ],
};

If not already installed, install the @vendure/ui-devkit package.

npm install @vendure/ui-devkit

Then add the UI extensions. This plugin requires the ui-devkit in order to compile a custom Admin UI including the advanced search extensions. See our guide to extending the Admin UI.

import { compileUiExtensions } from '@vendure/ui-devkit/compiler';
import { AdvancedSearchPlugin } from '@vendure-hub/vendure-advanced-search-plugin';

// ...
plugins: [
  AdminUiPlugin.init({
    route: 'admin',
    port: 3002,
    app: compileUiExtensions({
      outputPath: path.join(__dirname, '../admin-ui'),
      extensions: [AdvancedSearchPlugin.uiExtensions],
      devMode: false,
    })
  }),
],

Migration

Generate a database migration for the new custom field that will be set on the GlobalSettings entity.

Document Overrides

It is possible to override the indexed value of the built-in fields on the SearchResult type. For example, let’s say you have a customField which stores a “sale price” which you want to use in your search results. Here’s how you can configure it:

AdvancedSearchPlugin.init({
  // ...
  documentOverrides: {
    priceWithTax: ({ variant }) => variant.customFields.salePrice,
  },
}),

The override function will receive an object as it’s single argument which looks like this:

export interface DocumentContext {
  ctx: RequestContext;
  languageCode: LanguageCode;
  channel: Channel;
  variant: ProductVariant;
  productTranslation: Translation<Product>;
  variantTranslation: Translation<ProductVariant>;
  optionTranslations: Array<Translation<ProductOption>>;
  collectionTranslations: Array<Translation<Collection>>;
}

Searching

The Advanced Search Plugin is a drop-in replacement for the DefaultSearchPlugin that comes with a standard Vendure installation. Therefore, the same search query is used to perform searches, and the same response shape is returned.

However, the Advanced Search Plugin provides a number of additional features which can be used to enhance the search experience.

Input fields

The regular SearchInput type is extended with the following additional fields:

The same search query is used to search in the Shop API, but the input object is extended as follows:

extend input SearchInput {
    """
    Whether to log this search in the analytics store. 
    If true, the result will include a queryId
    """
    logAnalytics: Boolean
    "Whether to apply curations to the results"
    applyCurations: Boolean
    maxFacetValues: Int
    "Allows filtering by stock status"
    inStock: Boolean
    "Allows filtering by price range"
    priceRange: PriceRangeInput
    "Allows filtering by price range (including taxes)"
    priceRangeWithTax: PriceRangeInput
    """
    If true, will use the search term as a prefix. 
    Intended in live search (autocomplete) use cases.
    """
    prefixMode: Boolean
    """
    Sample the top collections from the first n results when using the topCollections
    field. defaults to 10
    """
    topCollectionsFromTop: Int
    "Supply custom Typesense filter strings to apply to the search"
    filterBy: [String!]
    """
    Allows filtering by facetCode and facetValueCodes to get a subset of facets in the facets 
    object of the response to easily further refine the search
    """
    facetsFilter: FacetsFilterInput
}


Response fields

The search response is also extended to include data on facet counts, price ranges, top collections, and more.

extend type SearchResponse {
    """
    The ID of the search query, used for logging analytics. Will be set
    if the `logAnalytics` input is set to true. See the "Search Analytics" 
    section for more details.
    """
    queryId: String
    
    """
    Used for faceted search. See section below for more details.
    """
    facets: [FacetResult!]!
  
    """
    The "raw" facet counts from Typesense. This can be useful for debugging
    """
    facetCounts: [FacetCountData!]!
  
    """
    The price range of the search results
    """
    prices: SearchResponsePriceData!
  
    """
    Returns the top collections based on the search input.
    """
    topCollections: [TopCollectionResult]!
}

extend type SearchResult {
    """
    The Typesense document ID, which is composed from the
    channel ID, the language code, and the product ID.
    """
    id: String!
    """
    Whether the product is in stock
    """
    inStock: Boolean!
    """
    The slugs of any collections that the product belongs to
    """
    collectionSlugs: [String!]!
    """
    Information about the what fields matched the search query
    """
    highlights: [ResultHighlight!]!
    """
    The distance from the search origin, if a geopoint was provided
    """
    geoDistance: [GeoDistance!]
}

For reference, here are the definitions of the new types:

type Geopoint {
    latitude: Float!
    longitude: Float!
}

type ResultHighlight {
    field: String!
    matchedTokens: [String!]!
    snippet: String!
}

type FacetCountItem {
    count: Int!
    highlighted: String!
    value: String!
}

type FacetCountStats {
    avg: Float!
    min: Float!
    max: Float!
    sum: Float!
}

type FacetCountData {
    fieldName: String!
    counts: [FacetCountItem!]!
    stats: FacetCountStats
}

type SearchResponsePriceData {
    range: PriceRange!
    rangeWithTax: PriceRange!
}

type GeoDistance {
    field: String!
    distanceInMeters: Float!
}

type TopCollectionResult {
    collection: Collection!
    score: Int!
}

input PriceRangeInput {
    min: Int!
    max: Int!
}

input GeopointSort {
    latitude: Float!
    longitude: Float!
    sort: SortOrder!
}

type FacetResult {
    facet: Facet!
    facetValueCounts: [FacetValueResult!]!
}

Fulltext search

The heart of the search experience is the fulltext search. Typesense provides powerful, typo-tolerant and extremely fast fulltext search capabilities, which you use by providing a term input to the search query.

query {
  search(input: {
    term: "shoes"
  }) {
    totalItems
    items {
      productName
      priceWithTax
      # ...
    }
  }
}

Prefix search

The prefixMode input can be used to perform a prefix search, which is useful for live search (autocomplete) use cases. When set to true, the last word of the query will be treated as a prefix, rather than a whole word:

query {
  search(input: {
    term: "baske"
    prefixMode: true
    groupByProduct: true
  }) {
    totalItems
    items {
      productName
    }
  }
}

Usually this would return no results, as there is no product with the name “baske”. However, with prefixMode set to true, the search will return products with names such as “basketball”, “basket”, etc.

Highlights

The highlights field in the SearchResult type provides information about which fields matched the search query. This can be useful for displaying search results in a user-friendly way, e.g. by highlighting the matched terms in the product name.

query {
  search(input: {
    term: "keyboard"
    groupByProduct: true
  }) {
    totalItems
    items {
      productName
      highlights {
        field
        matchedTokens
        snippet
      }
    }
  }
}

The result will look like this:

{
  "data": {
    "search": {
      "totalItems": 2,
      "items": [
        {
          "productName": "Clacky Keyboard",
          "highlights": [
            {
              "field": "productName",
              "matchedTokens": ["Keyboard"],
              "snippet": "Clacky <mark>Keyboard</mark>"
            },
            {
              "field": "productVariantName",
              "matchedTokens": ["Keyboard"],
              "snippet": "Clacky <mark>Keyboard</mark>"
            }
          ]
        },
        {
          "productName": "Tablet",
          "highlights": [
            {
              "field": "description",
              "matchedTokens": ["keyboard"],
              "snippet": "wanted — with touch, a <mark>keyboard</mark>, or even a pencil."
            }
          ]
        }
      ]
    }
  }
}

You can use the snippet field directly in your client application to display the highlighted text by defining a CSS style for the <mark> tag.

Sorting

As with the DefaultSearchPlugin, the search query results can be sorted by price or name:

query {
  search(input: {
    sort: {
      price: ASC
      # or
      name: ASC  
    }
  }) {
    totalItems
    items {
      productName
      priceWithTax
      # ...
    }
  }
}

It is also possible to define extra fields by which to sort, by using the sortableFields option.

AdvancedSearchPlugin.init({
  // ...
  sortableFields: [
    // Here we are specifying that the 'sku' field
    // should also be sortable
    { name: 'sku' },
  ],
});

With the above configuration, we will then be able to perform queries such as:

query {
  search(input: {
    sort: {
      sku: DESC
    }
  }) {
    totalItems
    items {
      productName
      sku
      # ...
    }
  }
}

Filtering

Filtering of the search results can be done via the built-in filter inputs of the SearchInput object such as collectionSlug, facetValueFilters, inStock, priceRangeWithTax.

Search results can be filtered in various ways using dedicated input fields:

query {
  search(input: {
    # Only include products which are in stock  
    inStock: true
    # Only include products in the collection
    # with the given slug  
    collectionSlug: "my-collection"
    # Only include products with prices in the given range
    priceRangeWithTax: {
      min: 1000
      max: 2000
    }
    # Only include products with the given facet values.
    # See the "Faceted Search" section for more details.  
    facetsFilter: {
      facets: [
        {
          code: "category",
          facetValueCodes: ["footwear"]
        }
      ]
    }
  }) {
    totalItems
    items {
      productName
      inStock
      # ...
    }
  }
}

Advanced filtering

For even more control over filtering you can use the filterBy input to provide expressions directly as Typesense filter_by parameters.

Custom mappings can be filtered by prepending the name with customMapping_:

query {
  search(input: {
    groupByProduct: true,
    filterBy: [
      "inStock: true",
      "collectionIds:= [`345`, `525`]",
      "customMapping_reviewRating:[2..5]"
    ],
  }) {
    items {
      inStock
      productName
      customMappings {
        reviewRating
      }
    }
  }
}

Faceted Search

Faceted search is a powerful feature that allows users to refine search results by applying multiple filters. This can be implemented using the facetsFilter argument of the SearchInput object. This argument accepts the code of the facet and the codes of the selected facet values, and returns the updated facets with the remaining counts of their facet values in the facets property of the SearchResponse object.

Note: facets vs facetValues vs facetCounts

The SearchResponse type contains 3 fields related to facets, which can be a source of confusion. Here’s what they mean:

  • facetValues: This contains a flat list of all the facet values that are present in the search results. This exists for backwards compatibility with the default search plugin, but is not recommended.
  • facetCounts: This contains the raw counts of faceted values from Typesense. This is mainly useful for debugging, as the returned values contain limited information, e.g. just the IDs for facet values.
  • facets: This is the recommended way to get faceted values. It groups the facet values by facet, and gives you more control over the results based on the facetsFilter input.

Likewise, the SearchInput object has 2 fields related to facets:

  • facetValueFilters: The backward-compatible way of filtering by facet value ID and is not recommended.
  • facetsFilter: This is the recommended way to implemented faceted search together with the facets field in the SearchResponse.

By setting includeNullValues: true in the facetsFilter argument, the response will also include facet values with a count of zero.

Here’s an example of a GraphQL query implementing a faceted search:

query {
  search(input: {
    groupByProduct: true
    facetsFilter: {
      includeNullValues: false,
      facets: [
        {
          code: "category",
          facetValueCodes: ["equipment"]
        },
        {
          code: "brand",
          facetValueCodes: ["nike", "adidas"]
        }
      ]
    }
  }) {
    totalItems
    items {
      productName
      productId
    }
    facets {
      facet {
        code
      }
      facetValueCounts {
        count
        facetValue {
          code
        }
      }
    }
  }
}

This query will return a list of products filtered by the selected facets, along with the total number of items and the updated facets with their counts.

{
  "data": {
    "search": {
      "totalItems": 1,
      "items": [
        { "productName": "Football", "productId": "26" }
      ],
      "facets": [
        {
          "facet": {
            "code": "category"
          },
          "facetValueCounts": [
            { "count": 6, "facetValue": { "code": "sports-outdoor" } },
            { "count": 5, "facetValue": { "code": "footwear" } },
            { "count": 1, "facetValue": { "code": "equipment" } }
          ]
        },
        {
          "facet": {
            "code": "brand"
          },
          "facetValueCounts": [
            { "count": 2, "facetValue": { "code": "everlast" } },
            { "count": 2, "facetValue": { "code": "wilson" } },
            { "count": 1, "facetValue": { "code": "nike" } },
            { "count": 1, "facetValue": { "code": "pinarello" } },
            { "count": 0, "facetValue": { "code": "adidas" } }
          ]
        }
      ]
    }
  }
}

The results returned in the facets object will include the updated counts of the facet values for the selected facets. These can be used in the frontend to display the available facet values and their counts to the user.

This would then be used as the basis for a faceted search UI, where the user can select multiple facets to refine the search results.

Top Collections

The topCollections field in the SearchResponse object returns the top collections based on the search input. If differs from the collections field in the following way:

  • collections is a backward-compatible field that returns all collections in the result set. So if you are returning the first 10 results, you will get the collections of those 10 results. This means that the collections returned are affected by the pagination (skip, take) of the search query.
  • topCollections is more powerful because it allows you to return the collections related to the top n results, regardless of the pagination, by using the topCollectionsFromTop input.

Custom Mappings

Custom mappings allow you to index arbitrary data alongside your product variants. For example, if you have defined custom fields on your products or variants, you can index these fields and make them searchable.

Here are some example use cases for custom mappings:

AdvancedSearchPlugin.init({
  customMappings: {
    // Example: you have a Reviews plugin that defines a 
    // `reviewRating` and `reviewCount` custom field
    // on the Product entity.  
    reviewRating: {
      graphQlType: 'Float',
      // The valueFn defines the value that gets stored in the Typesense
      // index. The return value must be compatible with the `graphQlType`.  
      valueFn: ({ variant }) => variant.product.customFields.reviewRating,
    },
    reviewCount: {
      graphQlType: 'Int!',
      valueFn: ({ variant }) => variant.product.customFields.reviewCount,
    },
      
    // Example: you want to index the facet values of a product
    // to make them searchable  
    facetValues: {
      graphQlType: 'String!',
      // Setting `searchable` to true means that this field will be
      // matched against when performing a search query. This may only
      // be set for String and Geopoint types.  
      searchable: true,
      // Since our valueFn relies on the variant's product & its facet values,
      // we need to hydrate these relations  
      hydrateRelations: ['product.facetValues'],
      valueFn: ({ ctx, variant, languageCode, injector }) =>
        variant.product.facetValues
          .map((fv) => injector.get(TranslatorService).translate(fv, ctx).name)
          .join(' '),
    },
      
      
    featuredAssetName: {
      graphQlType: 'String!',
      // Since our valueFn relies on the variant's product & its featured asset,
      // we need to hydrate these relations  
      hydrateRelations: ['product.featuredAsset'],
      valueFn: ({ variant }) => variant.product.featuredAsset?.name ?? '',
      searchable: true,
    },
    discountPercentage: {
      graphQlType: 'Float',
      valueFn: ({ variant }) => variant.customFields?.discountPercentage,
      // This function defines how the stored value is converted to the GraphQL 
      // type when returned from the search query.
      outputFn: (values: number[], groupByProduct: boolean) => {
         if (groupByProduct) {
            return Math.max(...values);
         } else {
            return values[0];
         }
      },
    },
  },
}),

For more information on each specific option, see:

Primitive & Object types

Primitive custom mappings return a primitive value (a GraphQL scalar or list of scalars such as Int or [String!]). The examples above are of this type, and these are suitable for most cases.

Sometimes, however, you may want to return a more complex type from a custom mapping. In this case, you can use an object custom mapping, which can return any kind of GraphQL object type.

An object custom mapping must:

  1. Specify a typesenseType which is one of the Typesense field types. This is required because we cannot infer the Typesense type from the GraphQL type, since the GraphQL type can be anything.
  2. Specify an outputFn which has the task of transforming the data stored in Typesense into the correct shape according to the chosen graphQlType. The values it receives will be an array, and in the case that the search has been performed with groupByProduct: true, it will contain all the values for each variant in the given product. The groupByProduct argument will be true if the search was performed with groupByProduct: true, otherwise it will be false.
  3. If a GraphQL type has been used which does not yet exist in your schema, you can define it using the graphQlSchemaExtension property.

In this example, we have defined a custom rrp (recommended retail price) field on the Product, and we want to expose it as a min/max range:

AdvancedSearchPlugin.init({
    // ...
    customMappings: {
      rrp: {
        graphQlType: 'RRPRange',
        graphQlSchemaExtension: `
          type RRPRange {
            min: Int!
            max: Int!
          }
        `,
        typesenseType: 'int32',
        valueFn: ({variant}) => variant.customFields?.rrp,
        outputFn: (values, groupByProduct) => {
          return {
            min: Math.min(...values),
            max: Math.max(...values),
          };
        },
      },
    },
}),

Querying custom mappings

Custom mappings are exposed via the customMappings file in the search response type.

query {
  search(input: {
    groupByProduct: true,
  }) {
    items {
      inStock
      productName
      customMappings {
        # Primitive custom mappings are returned as-is
        reviewRating
        discountPercentage
        
        # Object custom mappings should be queried as a subselection
        rrp {
          min
          max
        }  
      }
    }
  }
}

Sorting by custom mappings

Search results can be sorted by custom mappings by configuring the sortableFields config option.

AdvancedSearchPlugin.init({
  // ...
  sortableFields: [
    // Assuming we have defined a customMapping for reviewRating
    // (as in the example above), we can also make this sortable.
    {
      // This name must match the field name internally in TypeSense,
      // which, for customMappings is always of the format
      // `customMapping_<name>`.
      name: 'customMapping_reviewRating',
      // We can optionally alias this with a more friendly name that will be used
      // in the GraphQL API "sort" input.
      alias: 'rating'
    }
  ],
});

Then, in your GraphQL query, you can sort by the custom mapping:

query {
  search(input: {
    sort: {
      rating: DESC
    }
  }) {
    totalItems
    items {
      productName
      customMappings {
        reviewRating
      }
      # ...
    }
  }
}

Filtering by custom mappings

Results can be filtered by custom mappings by using the filterBy input field and specifying the custom mapping name in the format customMapping_<name>. The value should be a string in the format of the Typesense filter syntax.

query {
  search(input: {
    groupByProduct: true,
    filterBy: [
      # This will filter for products with a 
      # reviewRating between 2 and 5
      "customMapping_reviewRating:[2..5]"
    ],
  }) {
    items {
      productName
      customMappings {
        reviewRating
      }
    }
  }
}

Geosearch

Typesense supports powerful geosearch capabilities, which you use to sort results based on distance from a given point. Here’s an example of how this would work.

First, we need to store coordinates somewhere. In this example, we will store them in custom fields on the Product entity:

import { VendureConfig } from '@vendure/core';

export const config: VendureConfig = {
  // ...
  customFields: {
    Product: [
      {
        name: 'latitude',
        type: 'float',
        defaultValue: 0,
      },
      {
        name: 'longitude',
        type: 'float',
        defaultValue: 0,
      },
    ],
  },
};

Next, we need to define a custom mapping for the latitude and longitude fields:

(here we are assuming that the custom fields have been correctly typed)

AdvancedSearchPlugin.init({
  // ...
  customMappings: {
    location: {
      graphQlType: 'Geopoint',
      valueFn: ({ variant }) => {
        // A "Geopoint" type will expect a tuple of [latitude, longitude]
        return [
          variant.product.customFields.latitude,
          variant.product.customFields.longitude,
        ] as [number, number];
      },
    },
  },
}),

We also need to specify that the location custom mapping is sortable:

AdvancedSearchPlugin.init({
  customMappings: {
    // ...as above
  },
  sortableFields: [
    {
      name: 'customMapping_location',
      alias: 'location',
    },
  ],
}),

Finally, we can use the sort input to sort by distance from a given point, filter by distance from that point, and use the geoDistance field to get the distance in meters.

  • The sort input is used to sort results by distance from a given latitude and longitude.
  • The filterBy input is used to only include results within a certain distance from a given latitude and longitude.
query {
  search(input: {
    groupByProduct: true,
    sort: {
      location: {
        latitude: 48.205809,
        longitude: 16.366315,
        sort: ASC
      }
    }
    filterBy: ["customMapping_location:(48.205809, 16.366315, 1.5 km)"],
  }) {
    items {
      productName
      customMappings {
        location { latitude longitude }
      }
      geoDistance {
        field
        distanceInMeters
      }
    }
  }
}

External Indexes

An external index allows you to index and search any dataset alongside your product data. This data can come from any source, such as within Vendure itself or from an external system.

Example: Indexing collections

Here’s an example where we index Vendure collections, so we can display them in the search results:

import { ExternalIndex, TypesenseDocument } from '@vendure-hub/vendure-advanced-search-plugin';
import {
   ChannelService,
   Collection,
   CollectionEvent,
   CollectionService,
   EventBus,
   RequestContextService,
   TransactionalConnection,
} from '@vendure/core';
import { filter, map} from 'rxjs/operators';

interface CollectionDocument extends TypesenseDocument {
   name: string;
   slug: string;
   breadcrumb: string[];
   rank: number;
}

export const collectionExternalIndex = new ExternalIndex<CollectionDocument>({
   name: 'collections',
   getAllIds: async (injector) => {
      const connection = injector.get(TransactionalConnection);
      return connection.rawConnection
              .getRepository(Collection)
              .createQueryBuilder('collection')
              .select('collection.id')
              .where('collection.isPrivate = :isPrivate', {isPrivate: false})
              .getMany()
              .then((collections) => collections.map((c) => c.id));
   },
   fields: {
      id: {
         type: 'string',
         facet: false,
      },
      name: {
         type: 'string',
         facet: false,
         /**
          * Set to `true` if it's a string field and you want to sort by it. 
          * Number fields are sortable by default.
          */
         sort: true,
      },
      slug: {
         type: 'string',
         facet: false,
      },
      breadcrumb: {
         type: 'string[]',
         facet: false,
      },
      rank: {
         type: 'int32',
         isDefaultSortingField: true,
         public: true,
         facet: false,
      },
   },
   createDocuments: async (ctx, injector, ids) => {
      const channelService = injector.get(ChannelService);
      const requestContextService = injector.get(RequestContextService);
      const collectionService = injector.get(CollectionService);
      const defaultChannelCtx = await requestContextService.create({
         apiType: 'admin',
         channelOrToken: await channelService.getDefaultChannel(ctx),
      });
      const collections = await collectionService.findByIds(defaultChannelCtx, ids);
      return Promise.all(
              collections
                      .filter((collection) => !collection.isPrivate)
                      .map(async (collection) => {
                         const breadcrumb = await collectionService
                                 .getBreadcrumbs(defaultChannelCtx, collection)
                                 .then((result) => result.slice(1).map((b) => b.name));
                         return {
                            id: collection.id.toString(),
                            name: collection.name,
                            slug: collection.slug,
                            breadcrumb,
                            // We weight the rank by the depth of the 
                            // collection in the tree
                            rank: 10 - breadcrumb.length,
                         };
                      }),
      );
   },
   createTypesenseSearchParams: (ctx, injector, input) => {
      const per_page = input.take ?? 10;
      const page = input.skip && 0 < input?.skip ? Math.ceil(input.skip / per_page) + 1 : undefined;
      return {
         q: input.term,
         query_by: ['name', 'slug'],
         prefix: input.prefixMode ?? false,
         sort_by: 'rank:desc,_text_match:desc',
         // We can support arbitrary Typesense filter expressions by
         // using the `filter_by` parameter and passing it any input
         // which has been provided in the `filterBy` input field
         filter_by: input.filterBy?.join(' && '),
         per_page,
         page,
      };
   },
   updateStream: (injector) => {
      const eventBus = injector.get(EventBus);
      return eventBus.ofType(CollectionEvent).pipe(
              filter((event) => event.type !== 'deleted'),
              map((event) => [event.entity.id]),
      );
   },
   removeStream: (injector) => {
      const eventBus = injector.get(EventBus);
      return eventBus.ofType(CollectionEvent).pipe(
              filter((event) => event.type === 'deleted'),
              map((event) => [event.entity.id]),
      );
   },
});

Example: Indexing blog posts

As another example, you might want to index blog posts or information pages. This is done by defining an ExternalIndex instance. Here’s an example which indexes articles from a CMS:

import { ExternalIndex, TypesenseDocument } from '@vendure-hub/plugin-advanced-search';
import { EventBus, TransactionalConnection } from '@vendure/core';
import { Observable } from 'rxjs';
// Some imaginary SDK from our CMS vendor
import { cmsSDK } from 'cms-sdk';

interface CmsArticleDocument extends TypesenseDocument {
  title: string;
  slug: string;
  breadcrumb: string[];
}

export const cmsExternalIndex = new ExternalIndex<CmsArticleDocument>({
   // A unique name to identify this index. It is used when searching via the
   // `searchExternal` GraphQL query.
   name: 'cms-articles',
   
   // Since v1.6.0, the plugin will dynamically generate a new query for searching
   // this index. By default, it will be `search<Name>`, where `Name` is the name
   // of the index converted to PascalCase. So in this example the default query
   // name would be `searchCmsArticles`. If you want to override this, you can
   // specify the `queryName` property.
   queryName: 'searchArticles',
   
   // This defines the fields that will be indexed in Typesense.
   fields: {
      id: {
         type: 'string',
         facet: false,
      },
      title: {
         type: 'string',
         facet: false,
      },
      slug: {
         type: 'string',
         facet: false,
      },
      breadcrumb: {
         type: 'string[]',
         facet: false,
      },
   },
   // This method should return the ids of _all_ documents from the external
   // system. This is used in doing a full reindex operation.
   getAllIds: async (injector) => {
      return cmsSDK.fetchAllIds();
   },
   // When given an array of IDs, this function fetches the necessary
   // data to populate a CmsArticleDocument in the search index and
   // returns those documents.
   createDocuments: async (ctx, injector, ids) => {
      const articles = await cmsSDK.findPostsByIds(ids);
      return articles.map(article => {
         return {
            id: article.id,
            title: article.title,
            slug: article.slug,
            breadcrumb: article.breadcrumb,
         };
      });
   },
   // This is used to specify how the search term and pagination arguments
   // are converted to an object to pass to Typesese. In general, only the `query_by`
   // should require changes to point to the field(s) that are to be searched.
   createTypesenseSearchParams: (ctx, injector, input) => {
      const per_page = input.take ?? 10;
      const page = input.skip && 0 < input?.skip ? Math.ceil(input.skip / per_page) + 1 : undefined;
      return {
         q: input.term,
         query_by: ['title'],
         prefix: input.prefixMode ?? false,
         per_page,
         page,
      };
   },
   // This method should return an Rxjs observable stream of IDs
   // which emits a value whenever the external source is updated
   // and requires reindexing.
   updateStream: (injector) => {
      return new Observable((subscriber) => {
         cmsSDK.on('update', article => {
            subscriber.next([article.id]);
         })
      });
   },
   // This method should return an Rxjs observable stream of IDs
   // which emits a value whenever the external source is deleted
   // and requires removing from the index.
   removeStream: (injector) => {
      return new Observable((subscriber) => {
         cmsSDK.on('delete', article => {
            subscriber.next([article.id]);
         })
      });
   },
});

You then pass this ExternalIndex instance to the AdvancedSearchPlugin:

import { VendureConfig } from '@vendure/core';
import { AdvancedSearchPlugin } from '@vendure-hub/vendure-advanced-search-plugin';

import { cmsExternalIndex } from './cms-external-index';
import { collectionExternalIndex } from './collection-external-index';

export const config: VendureConfig = {
  // ...
  plugins: [
    AdvancedSearchPlugin.init({
      // ...
      externalIndexes: [collectionExternalIndex, cmsExternalIndex],
    }),
  ],
};

Searching external indexes

For each external index defined, the plugin will automatically generate a new query in the GraphQL API. The name of the query will be search<Name>, where Name is the name of the index converted to PascalCase. This can be overridden by specifying the queryName property when defining the ExternalIndex.

query SearchCollections($input: ExternalSearchScopedInput!) {
   searchCollections(input: $input) {
      items {
         slug
         name
         breadcrumb
         highlights {
            field
            snippet
         }
      }
   }
}
{
   "term": "foo",
   "skip": 0,
   "take": 5,
   "prefixMode": true,
   "filterBy": ["rank:>8"],
   "sortBy": "rank:desc"
}

Searching multiple external indexes

It is also possible to search multiple external indexes in a single query using the searchExternal query. The response will be a union type of all the external indexes you have defined. The plugin will automatically create a GraphQL type for your external index, which will be named as a PascalCase version of the name property with a Response suffix. In the example above, the name is 'cms-articles', so the resulting GraphQL type would be CmsArticlesResponse.

query SearchExternal($externalInput: ExternalSearchInput!) {
   searchExternal(input: $externalInput) {
      ... on CollectionsResponse {
         items {
            breadcrumb
            highlights {
               field
               snippet
            }
            name
            slug
         }
      }
      ... on CmsArticlesResponse {
         items {
            breadcrumb
            highlights {
               field
               snippet
            }
            title
            slug
         }
      }
   }
}

The ExternalSearchInput object must then specify the name of the external index(es) to search:

{
   "term": "foo",
   "skip": 0,
   "take": 5,
   "prefixMode": true,
   "indexes": ["collections", "cms-articles"],
   "filterBy": {
      "collections": ["rank:>8"]
   }
}

In this example we are also providing some custom filterBy data, which allows your external index to support arbitrary filtering expressions supported by Typesense.

Search Analytics

Analytics on searches can be collected based on the configured analytics strategy. The plugin comes with two strategies out of the box:

  • SqlAnalyticsStrategy: This strategy stores analytics data in a SQL database. This is the default strategy.
  • ClickhouseAnalyticsStrategy: This strategy stores analytics data in a Clickhouse database.
import { AdvancedSearchPlugin, ClickhouseAnalyticsStrategy, SqlAnalyticsStrategy } from '@vendure-hub/vendure-advanced-search-plugin';

export const config = {
    //...
    plugins: [
        AdvancedSearchPlugin.init({
            // A secret string used to encrypt the Typesense Shop API key in the database. When this is set, a
            // new customField will be defined on the GlobalSettings entity, which is used to store
            // the encrypted shop API key.
            shopApiKeySecret: process.env.SHOP_API_KEY_SECRET,
            // The license key can be found in your Vendure Hub account
            // at https://vendure.io/account/licenses, and then clicking
            // on the install instructions for this plugin's license
            licenseKey: process.env.ADVANCED_SEARCH_LICENSE_KEY,
            typeSenseClientOptions: {
                apiKey: process.env.TYPESENSE_API_KEY as string,
                nodes: [
                    {
                        host: process.env.TYPESENSE_HOST as string,
                        port: 8108,
                        protocol: 'http',
                    },
                ],
            },
            analytics: {
                analyticsStrategy: process.env.USE_CLICKHOUSE
                    ? new ClickhouseAnalyticsStrategy({
                        url: process.env.CLICKHOUSE_URL as string,
                        port: 8123,
                        database: 'vendure_search_analytics',
                        debug: false,
                    })
                    : new SqlAnalyticsStrategy(),
            },
            bufferUpdates: true,
        }),
    ],
};

When performing a search, setting the logAnalytics input field to true will record that search in the analytics store.

query {
  search(input: {
    term: "red shoes",
    logAnalytics: true
  }) {
    totalItems
    items {
      productName
      priceWithTax
      # ...
    }
  }
}

Note that the analytics engine will automatically “debounce” intermediate searches during an autocomplete, so that only the final search is recorded. For example, if the user types “red”, then “red sh” and then “red shoes”, only the “red shoes” search will be recorded. The window of time during which intermediate searches are ignored can be configured via the analytics.aggregationWindowMs option.

In your storefront, you should then implement the following mutations in order to track search result views and click-throughs:

input SearchListViewedEventInput {
  queryId: String!
}
input SearchResultClickedEventInput {
  queryId: String!
  position: Int!
  resultId: String!
}
extend type Mutation {
  logSearchListViewed(input: SearchListViewedEventInput!): Boolean!
  logSearchResultClicked(input: SearchResultClickedEventInput!): Boolean!
}

The logSearchListViewed mutation allows us to register the fact that the search results were viewed. This can be used for both a full results page, or an autocomplete list.

The logSearchResultClicked mutation allows us to register the fact that a specific search result was clicked. This in turn allows the analytics engine to calculate click rate and average click position for each search term.

Disabling Analytics

If you do not need the analytics features, they can be disabled with the following configuration (since v1.4.0):

import { AdvancedSearchPlugin } from '@vendure-hub/vendure-advanced-search-plugin';

export const config = {
   //...
   plugins: [
      AdvancedSearchPlugin.init({
         typeSenseClientOptions: {
            // ...
         },
         // Set to false to disable analytics on the server
         analytics: false,
      }),
      AdminUiPlugin.init({
         route: 'admin',
         port: 3002,
         app: compileUiExtensions({
            outputPath: path.join(__dirname, '../admin-ui'),
            // Use the `uiExtensionsNoAnalytics` variant to remove the "analytics" menu item
            // from the Admin UI.
            extensions: [AdvancedSearchPlugin.uiExtensionsNoAnalytics],
            devMode: false,
         })
      }),
   ],
};
Vendure
Get started

Create your first commerce experience with Vendure in less than 2 minutes

Vendure is a registered trademark. Our trademark policy ensures that our brand and products are protected. Feel free to reach out if you have any questions about our trademarks.

Newsletter

Get the latest product news and announcements delivered directly to your inbox.

© Copyright 2022 - 2024, Vendure GmbH. All rights reserved.