Advanced, efficient, lightning-fast search with support for fuzzy matching, synonyms, curation, analytics and more
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 thefacetsFilter
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 thefacets
field in theSearchResponse
.
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 thetopCollectionsFromTop
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:
- 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. - Specify an
outputFn
which has the task of transforming the data stored in Typesense into the correct shape according to the chosengraphQlType
. Thevalues
it receives will be an array, and in the case that the search has been performed withgroupByProduct: true
, it will contain all the values for each variant in the given product. ThegroupByProduct
argument will betrue
if the search was performed withgroupByProduct: true
, otherwise it will befalse
. - 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,
})
}),
],
};
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.
Documentation
Newsletter
Get the latest product news and announcements delivered directly to your inbox.