Bläddra i källkod

feat(elasticsearch-plugin): Add options for customising term query

Michael Bromley 6 år sedan
förälder
incheckning
71918426a1

+ 1 - 0
packages/elasticsearch-plugin/index.ts

@@ -1 +1,2 @@
 export * from './src/plugin';
+export * from './src/options';

+ 2 - 1
packages/elasticsearch-plugin/package.json

@@ -15,7 +15,8 @@
     "access": "public"
   },
   "dependencies": {
-    "@elastic/elasticsearch": "^7.1.0"
+    "@elastic/elasticsearch": "^7.1.0",
+    "deepmerge": "^4.0.0"
   },
   "devDependencies": {
     "@vendure/common": "^0.1.2-beta.12",

+ 83 - 53
packages/elasticsearch-plugin/src/build-elastic-body.spec.ts

@@ -1,11 +1,14 @@
 import { SortOrder } from '@vendure/common/lib/generated-types';
+import { DeepRequired } from '@vendure/core';
 
 import { buildElasticBody } from './build-elastic-body';
+import { defaultOptions, SearchConfig } from './options';
 
 describe('buildElasticBody()', () => {
+    const searchConfig = defaultOptions.searchConfig;
 
     it('search term', () => {
-        const result = buildElasticBody({ term: 'test' });
+        const result = buildElasticBody({ term: 'test' }, searchConfig);
         expect(result.query).toEqual({
             bool: {
                 must: [
@@ -13,12 +16,7 @@ describe('buildElasticBody()', () => {
                         multi_match: {
                             query: 'test',
                             type: 'best_fields',
-                            fields: [
-                                'productName',
-                                'productVariantName',
-                                'description',
-                                'sku',
-                            ],
+                            fields: ['productName^1', 'productVariantName^1', 'description^1', 'sku^1'],
                         },
                     },
                 ],
@@ -27,30 +25,25 @@ describe('buildElasticBody()', () => {
     });
 
     it('facetValueIds', () => {
-        const result = buildElasticBody({ facetValueIds: ['1', '2'] });
+        const result = buildElasticBody({ facetValueIds: ['1', '2'] }, searchConfig);
         expect(result.query).toEqual({
             bool: {
-                filter:  [
-                    { term: { facetValueIds: '1' } },
-                    { term: { facetValueIds: '2' } },
-                ],
+                filter: [{ term: { facetValueIds: '1' } }, { term: { facetValueIds: '2' } }],
             },
         });
     });
 
     it('collectionId', () => {
-        const result = buildElasticBody({ collectionId: '1' });
+        const result = buildElasticBody({ collectionId: '1' }, searchConfig);
         expect(result.query).toEqual({
             bool: {
-                filter:  [
-                    { term: { collectionIds: '1' } },
-                ],
+                filter: [{ term: { collectionIds: '1' } }],
             },
         });
     });
 
     it('paging', () => {
-        const result = buildElasticBody({ skip: 20, take: 10 });
+        const result = buildElasticBody({ skip: 20, take: 10 }, searchConfig);
         expect(result).toEqual({
             from: 20,
             size: 10,
@@ -61,57 +54,56 @@ describe('buildElasticBody()', () => {
 
     describe('sorting', () => {
         it('name', () => {
-            const result = buildElasticBody({ sort: { name: SortOrder.DESC } });
-            expect(result.sort).toEqual([
-                { productName: { order: 'desc' } },
-            ]);
+            const result = buildElasticBody({ sort: { name: SortOrder.DESC } }, searchConfig);
+            expect(result.sort).toEqual([{ productName: { order: 'desc' } }]);
         });
 
         it('price', () => {
-            const result = buildElasticBody({ sort: { price: SortOrder.ASC } });
-            expect(result.sort).toEqual([
-                { price: { order: 'asc' } },
-            ]);
+            const result = buildElasticBody({ sort: { price: SortOrder.ASC } }, searchConfig);
+            expect(result.sort).toEqual([{ price: { order: 'asc' } }]);
         });
 
         it('grouped price', () => {
-            const result = buildElasticBody({ sort: { price: SortOrder.ASC }, groupByProduct: true });
-            expect(result.sort).toEqual([
-                { priceMin: { order: 'asc' } },
-            ]);
+            const result = buildElasticBody(
+                { sort: { price: SortOrder.ASC }, groupByProduct: true },
+                searchConfig,
+            );
+            expect(result.sort).toEqual([{ priceMin: { order: 'asc' } }]);
         });
     });
 
     it('enabledOnly true', () => {
-        const result = buildElasticBody({}, true);
+        const result = buildElasticBody({}, searchConfig, true);
         expect(result.query).toEqual({
             bool: {
-                filter: [
-                    { term: { enabled: true } },
-                ],
+                filter: [{ term: { enabled: true } }],
             },
         });
     });
 
     it('enabledOnly false', () => {
-        const result = buildElasticBody({}, false);
+        const result = buildElasticBody({}, searchConfig, false);
         expect(result.query).toEqual({
             bool: {},
         });
     });
 
     it('combined inputs', () => {
-        const result = buildElasticBody({
-            term: 'test',
-            take: 25,
-            skip: 0,
-            sort: {
-                name: SortOrder.DESC,
+        const result = buildElasticBody(
+            {
+                term: 'test',
+                take: 25,
+                skip: 0,
+                sort: {
+                    name: SortOrder.DESC,
+                },
+                groupByProduct: true,
+                collectionId: '42',
+                facetValueIds: ['6', '7'],
             },
-            groupByProduct: true,
-            collectionId: '42',
-            facetValueIds: ['6', '7'],
-        }, true);
+            searchConfig,
+            true,
+        );
 
         expect(result).toEqual({
             from: 0,
@@ -123,12 +115,7 @@ describe('buildElasticBody()', () => {
                             multi_match: {
                                 query: 'test',
                                 type: 'best_fields',
-                                fields: [
-                                    'productName',
-                                    'productVariantName',
-                                    'description',
-                                    'sku',
-                                ],
+                                fields: ['productName^1', 'productVariantName^1', 'description^1', 'sku^1'],
                             },
                         },
                     ],
@@ -140,9 +127,52 @@ describe('buildElasticBody()', () => {
                     ],
                 },
             },
-            sort: [
-                { productName: { order: 'desc' } },
-            ],
+            sort: [{ productName: { order: 'desc' } }],
+        });
+    });
+
+    it('multiMatchType option', () => {
+        const result = buildElasticBody({ term: 'test' }, { ...searchConfig, multiMatchType: 'phrase' });
+        expect(result.query).toEqual({
+            bool: {
+                must: [
+                    {
+                        multi_match: {
+                            query: 'test',
+                            type: 'phrase',
+                            fields: ['productName^1', 'productVariantName^1', 'description^1', 'sku^1'],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
+    it('boostFields option', () => {
+        const config: DeepRequired<SearchConfig> = {
+            ...searchConfig,
+            ...{
+                boostFields: {
+                    description: 2,
+                    productName: 3,
+                    productVariantName: 4,
+                    sku: 5,
+                },
+            },
+        };
+        const result = buildElasticBody({ term: 'test' }, config);
+        expect(result.query).toEqual({
+            bool: {
+                must: [
+                    {
+                        multi_match: {
+                            query: 'test',
+                            type: 'best_fields',
+                            fields: ['productName^3', 'productVariantName^4', 'description^2', 'sku^5'],
+                        },
+                    },
+                ],
+            },
         });
     });
 });

+ 18 - 16
packages/elasticsearch-plugin/src/build-elastic-body.ts

@@ -1,11 +1,17 @@
 import { SearchInput, SortOrder } from '@vendure/common/lib/generated-types';
+import { DeepRequired } from '@vendure/core';
 
+import { SearchConfig } from './options';
 import { SearchRequestBody } from './types';
 
 /**
  * Given a SearchInput object, returns the corresponding Elasticsearch body.
  */
-export function buildElasticBody(input: SearchInput, enabledOnly: boolean = false): SearchRequestBody {
+export function buildElasticBody(
+    input: SearchInput,
+    searchConfig: DeepRequired<SearchConfig>,
+    enabledOnly: boolean = false,
+): SearchRequestBody {
     const { term, facetValueIds, collectionId, groupByProduct, skip, take, sort } = input;
     const query: any = {
         bool: {},
@@ -15,12 +21,12 @@ export function buildElasticBody(input: SearchInput, enabledOnly: boolean = fals
             {
                 multi_match: {
                     query: term,
-                    type: 'best_fields',
+                    type: searchConfig.multiMatchType,
                     fields: [
-                        'productName',
-                        'productVariantName',
-                        'description',
-                        'sku',
+                        `productName^${searchConfig.boostFields.productName}`,
+                        `productVariantName^${searchConfig.boostFields.productVariantName}`,
+                        `description^${searchConfig.boostFields.description}`,
+                        `sku^${searchConfig.boostFields.sku}`,
                     ],
                 },
             },
@@ -29,30 +35,26 @@ export function buildElasticBody(input: SearchInput, enabledOnly: boolean = fals
     if (facetValueIds && facetValueIds.length) {
         ensureBoolFilterExists(query);
         query.bool.filter = query.bool.filter.concat(
-            facetValueIds.map(id => ({ term: { facetValueIds: id }})),
+            facetValueIds.map(id => ({ term: { facetValueIds: id } })),
         );
     }
     if (collectionId) {
         ensureBoolFilterExists(query);
-        query.bool.filter.push(
-            { term: { collectionIds: collectionId } },
-        );
+        query.bool.filter.push({ term: { collectionIds: collectionId } });
     }
     if (enabledOnly) {
         ensureBoolFilterExists(query);
-        query.bool.filter.push(
-            { term: { enabled: true } },
-        );
+        query.bool.filter.push({ term: { enabled: true } });
     }
 
     const sortArray = [];
     if (sort) {
         if (sort.name) {
-            sortArray.push({ productName: { order: (sort.name === SortOrder.ASC) ? 'asc' : 'desc' } });
+            sortArray.push({ productName: { order: sort.name === SortOrder.ASC ? 'asc' : 'desc' } });
         }
         if (sort.price) {
             const priceField = groupByProduct ? 'priceMin' : 'price';
-            sortArray.push({ [priceField]: { order: (sort.price === SortOrder.ASC) ? 'asc' : 'desc' } });
+            sortArray.push({ [priceField]: { order: sort.price === SortOrder.ASC ? 'asc' : 'desc' } });
         }
     }
     return {
@@ -63,7 +65,7 @@ export function buildElasticBody(input: SearchInput, enabledOnly: boolean = fals
     };
 }
 
-function ensureBoolFilterExists(query: { bool: { filter?: any; } }) {
+function ensureBoolFilterExists(query: { bool: { filter?: any } }) {
     if (!query.bool.filter) {
         query.bool.filter = [];
     }

+ 13 - 6
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -1,7 +1,14 @@
 import { Client } from '@elastic/elasticsearch';
 import { Inject, Injectable } from '@nestjs/common';
 import { JobInfo, SearchInput, SearchResponse, SearchResult } from '@vendure/common/lib/generated-types';
-import { FacetValue, FacetValueService, Logger, RequestContext, SearchService } from '@vendure/core';
+import {
+    DeepRequired,
+    FacetValue,
+    FacetValueService,
+    Logger,
+    RequestContext,
+    SearchService,
+} from '@vendure/core';
 
 import { buildElasticBody } from './build-elastic-body';
 import {
@@ -14,13 +21,13 @@ import {
     VARIANT_INDEX_TYPE,
 } from './constants';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
-import { ElasticsearchOptions } from './plugin';
+import { ElasticsearchOptions } from './options';
 import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
 
 @Injectable()
 export class ElasticsearchService {
     constructor(
-        @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+        @Inject(ELASTIC_SEARCH_OPTIONS) private options: DeepRequired<ElasticsearchOptions>,
         @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
         private searchService: SearchService,
         private elasticsearchIndexService: ElasticsearchIndexService,
@@ -62,7 +69,7 @@ export class ElasticsearchService {
     ): Promise<Omit<SearchResponse, 'facetValues'>> {
         const { indexPrefix } = this.options;
         const { groupByProduct } = input;
-        const elasticSearchBody = buildElasticBody(input, enabledOnly);
+        const elasticSearchBody = buildElasticBody(input, this.options.searchConfig, enabledOnly);
         if (groupByProduct) {
             const { body }: { body: SearchResponseBody<ProductIndexItem> } = await this.client.search({
                 index: indexPrefix + PRODUCT_INDEX_NAME,
@@ -95,14 +102,14 @@ export class ElasticsearchService {
         enabledOnly: boolean = false,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
         const { indexPrefix } = this.options;
-        const elasticSearchBody = buildElasticBody(input, enabledOnly);
+        const elasticSearchBody = buildElasticBody(input, this.options.searchConfig, enabledOnly);
         elasticSearchBody.from = 0;
         elasticSearchBody.size = 0;
         elasticSearchBody.aggs = {
             facetValue: {
                 terms: {
                     field: 'facetValueIds.keyword',
-                    size: this.options.facetValueMaxSize,
+                    size: this.options.searchConfig.facetValueMaxSize,
                 },
             },
         };

+ 135 - 79
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -3,7 +3,17 @@ import { Controller, Inject } from '@nestjs/common';
 import { MessagePattern } from '@nestjs/microservices';
 import { InjectConnection } from '@nestjs/typeorm';
 import { unique } from '@vendure/common/lib/unique';
-import { FacetValue, ID, JobService, Logger, Product, ProductVariant, ProductVariantService, RequestContext, translateDeep } from '@vendure/core';
+import {
+    FacetValue,
+    ID,
+    JobService,
+    Logger,
+    Product,
+    ProductVariant,
+    ProductVariantService,
+    RequestContext,
+    translateDeep,
+} from '@vendure/core';
 import { defer, Observable } from 'rxjs';
 import { Connection, SelectQueryBuilder } from 'typeorm';
 import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
@@ -18,8 +28,14 @@ import {
     VARIANT_INDEX_NAME,
     VARIANT_INDEX_TYPE,
 } from './constants';
-import { ElasticsearchOptions } from './plugin';
-import { BulkOperation, BulkOperationDoc, BulkResponseBody, ProductIndexItem, VariantIndexItem } from './types';
+import { ElasticsearchOptions } from './options';
+import {
+    BulkOperation,
+    BulkOperationDoc,
+    BulkResponseBody,
+    ProductIndexItem,
+    VariantIndexItem,
+} from './types';
 
 export const variantRelations = [
     'product',
@@ -41,18 +57,27 @@ export interface ReindexMessageResponse {
 
 @Controller()
 export class ElasticsearchIndexerController {
-
-    constructor(@InjectConnection() private connection: Connection,
-                @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
-                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
-                private productVariantService: ProductVariantService,
-                private jobService: JobService) {}
+    constructor(
+        @InjectConnection() private connection: Connection,
+        @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+        @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
+        private productVariantService: ProductVariantService,
+        private jobService: JobService,
+    ) {}
 
     /**
      * Updates the search index only for the affected entities.
      */
     @MessagePattern(Message.UpdateProductOrVariant)
-    updateProductOrVariant({ ctx: rawContext, productId, variantId }: { ctx: any, productId?: ID, variantId?: ID }): Observable<boolean> {
+    updateProductOrVariant({
+        ctx: rawContext,
+        productId,
+        variantId,
+    }: {
+        ctx: any;
+        productId?: ID;
+        variantId?: ID;
+    }): Observable<boolean> {
         const ctx = RequestContext.fromObject(rawContext);
         let updatedProductVariants: ProductVariant[] = [];
         let removedProducts: Product[] = [];
@@ -97,22 +122,25 @@ export class ElasticsearchIndexerController {
                 await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
             }
             if (updatedVariants.length) {
-                const operations = updatedVariants.reduce((ops, variant) => {
-                    return [
-                        ...ops,
-                        { update: { _id: variant.id.toString() } },
-                        { doc: this.createVariantIndexItem(variant) },
-                    ];
-                }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+                const operations = updatedVariants.reduce(
+                    (ops, variant) => {
+                        return [
+                            ...ops,
+                            { update: { _id: variant.id.toString() } },
+                            { doc: this.createVariantIndexItem(variant) },
+                        ];
+                    },
+                    [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>,
+                );
                 await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
             }
             if (removedVariantIds.length) {
-                const operations = removedVariantIds.reduce((ops, id) => {
-                    return [
-                        ...ops,
-                        { delete: { _id: id.toString() } },
-                    ];
-                }, [] as BulkOperation[]);
+                const operations = removedVariantIds.reduce(
+                    (ops, id) => {
+                        return [...ops, { delete: { _id: id.toString() } }];
+                    },
+                    [] as BulkOperation[],
+                );
                 await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
             }
 
@@ -121,7 +149,13 @@ export class ElasticsearchIndexerController {
     }
 
     @MessagePattern(Message.UpdateVariantsById)
-    updateVariantsById({ ctx: rawContext, ids }: { ctx: any, ids: ID[] }): Observable<ReindexMessageResponse> {
+    updateVariantsById({
+        ctx: rawContext,
+        ids,
+    }: {
+        ctx: any;
+        ids: ID[];
+    }): Observable<ReindexMessageResponse> {
         const ctx = RequestContext.fromObject(rawContext);
         const { batchSize } = this.options;
 
@@ -160,8 +194,16 @@ export class ElasticsearchIndexerController {
                                 variantsInProduct = [];
                             }
                         }
-                        await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
-                        await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
+                        await this.executeBulkOperations(
+                            VARIANT_INDEX_NAME,
+                            VARIANT_INDEX_TYPE,
+                            variantsToIndex,
+                        );
+                        await this.executeBulkOperations(
+                            PRODUCT_INDEX_NAME,
+                            PRODUCT_INDEX_TYPE,
+                            productsToIndex,
+                        );
                         observer.next({
                             total: ids.length,
                             completed: Math.min((i + 1) * batchSize, ids.length),
@@ -186,63 +228,64 @@ export class ElasticsearchIndexerController {
         const { batchSize } = this.options;
 
         return new Observable(observer => {
-                (async () => {
-                    const timeStart = Date.now();
-                    const qb = this.getSearchIndexQueryBuilder();
-                    const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
-                    Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
-
-                    const batches = Math.ceil(count / batchSize);
-                    let variantsInProduct: ProductVariant[] = [];
-
-                    for (let i = 0; i < batches; i++) {
-                        Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
-
-                        const variants = await this.getBatch(ctx, qb, i);
-                        Logger.verbose(`variants count: ${variants.length}`);
-
-                        const variantsToIndex: Array<BulkOperation | VariantIndexItem> = [];
-                        const productsToIndex: Array<BulkOperation | ProductIndexItem> = [];
-
-                        // tslint:disable-next-line:prefer-for-of
-                        for (let j = 0; j < variants.length; j++) {
-                            const variant = variants[j];
-                            variantsInProduct.push(variant);
-                            variantsToIndex.push({index: {_id: variant.id.toString()}});
-                            variantsToIndex.push(this.createVariantIndexItem(variant));
-
-                            const nextVariant = variants[j + 1];
-                            if (nextVariant && nextVariant.productId !== variant.productId) {
-                                productsToIndex.push({index: {_id: variant.productId.toString()}});
-                                productsToIndex.push(this.createProductIndexItem(variantsInProduct) as any);
-                                variantsInProduct = [];
-                            }
+            (async () => {
+                const timeStart = Date.now();
+                const qb = this.getSearchIndexQueryBuilder();
+                const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
+                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
+
+                const batches = Math.ceil(count / batchSize);
+                let variantsInProduct: ProductVariant[] = [];
+
+                for (let i = 0; i < batches; i++) {
+                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+
+                    const variants = await this.getBatch(ctx, qb, i);
+                    Logger.verbose(`variants count: ${variants.length}`);
+
+                    const variantsToIndex: Array<BulkOperation | VariantIndexItem> = [];
+                    const productsToIndex: Array<BulkOperation | ProductIndexItem> = [];
+
+                    // tslint:disable-next-line:prefer-for-of
+                    for (let j = 0; j < variants.length; j++) {
+                        const variant = variants[j];
+                        variantsInProduct.push(variant);
+                        variantsToIndex.push({ index: { _id: variant.id.toString() } });
+                        variantsToIndex.push(this.createVariantIndexItem(variant));
+
+                        const nextVariant = variants[j + 1];
+                        if (nextVariant && nextVariant.productId !== variant.productId) {
+                            productsToIndex.push({ index: { _id: variant.productId.toString() } });
+                            productsToIndex.push(this.createProductIndexItem(variantsInProduct) as any);
+                            variantsInProduct = [];
                         }
-                        await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
-                        await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
-                        observer.next({
-                            total: count,
-                            completed: Math.min((i + 1) * batchSize, count),
-                            duration: +new Date() - timeStart,
-                        });
                     }
-                    Logger.verbose(`Completed reindexing!`);
+                    await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
+                    await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
                     observer.next({
                         total: count,
-                        completed: count,
+                        completed: Math.min((i + 1) * batchSize, count),
                         duration: +new Date() - timeStart,
                     });
-                    observer.complete();
-                })();
-            },
-        );
+                }
+                Logger.verbose(`Completed reindexing!`);
+                observer.next({
+                    total: count,
+                    completed: count,
+                    duration: +new Date() - timeStart,
+                });
+                observer.complete();
+            })();
+        });
     }
 
-    private async executeBulkOperations(indexName: string,
-                                        indexType: string,
-                                        operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>) {
+    private async executeBulkOperations(
+        indexName: string,
+        indexType: string,
+        operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>,
+    ) {
         try {
-            const {body}: { body: BulkResponseBody; } = await this.client.bulk({
+            const { body }: { body: BulkResponseBody } = await this.client.bulk({
                 refresh: 'true',
                 index: this.options.indexPrefix + indexName,
                 type: indexType,
@@ -250,7 +293,10 @@ export class ElasticsearchIndexerController {
             });
 
             if (body.errors) {
-                Logger.error(`Some errors occurred running bulk operations on ${indexType}! Set logger to "debug" to print all errors.`, loggerCtx);
+                Logger.error(
+                    `Some errors occurred running bulk operations on ${indexType}! Set logger to "debug" to print all errors.`,
+                    loggerCtx,
+                );
                 body.items.forEach(item => {
                     if (item.index) {
                         Logger.debug(JSON.stringify(item.index.error, null, 2), loggerCtx);
@@ -284,7 +330,11 @@ export class ElasticsearchIndexerController {
         return qb;
     }
 
-    private async getBatch(ctx: RequestContext, qb: SelectQueryBuilder<ProductVariant>, batchNumber: string | number): Promise<ProductVariant[]> {
+    private async getBatch(
+        ctx: RequestContext,
+        qb: SelectQueryBuilder<ProductVariant>,
+        batchNumber: string | number,
+    ): Promise<ProductVariant[]> {
         const { batchSize } = this.options;
         const i = Number.parseInt(batchNumber.toString(), 10);
         const variants = await qb
@@ -353,21 +403,27 @@ export class ElasticsearchIndexerController {
             description: first.product.description,
             facetIds: this.getFacetIds(variants),
             facetValueIds: this.getFacetValueIds(variants),
-            collectionIds: variants.reduce((ids, v) => [ ...ids, ...v.collections.map(c => c.id)], [] as ID[]),
+            collectionIds: variants.reduce((ids, v) => [...ids, ...v.collections.map(c => c.id)], [] as ID[]),
             enabled: first.product.enabled,
         };
     }
 
     private getFacetIds(variants: ProductVariant[]): string[] {
         const facetIds = (fv: FacetValue) => fv.facet.id.toString();
-        const variantFacetIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetIds)], [] as string[]);
+        const variantFacetIds = variants.reduce(
+            (ids, v) => [...ids, ...v.facetValues.map(facetIds)],
+            [] as string[],
+        );
         const productFacetIds = variants[0].product.facetValues.map(facetIds);
         return unique([...variantFacetIds, ...productFacetIds]);
     }
 
     private getFacetValueIds(variants: ProductVariant[]): string[] {
         const facetValueIds = (fv: FacetValue) => fv.id.toString();
-        const variantFacetValueIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetValueIds)], [] as string[]);
+        const variantFacetValueIds = variants.reduce(
+            (ids, v) => [...ids, ...v.facetValues.map(facetValueIds)],
+            [] as string[],
+        );
         const productFacetValueIds = variants[0].product.facetValues.map(facetValueIds);
         return unique([...variantFacetValueIds, ...productFacetValueIds]);
     }

+ 141 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -0,0 +1,141 @@
+import { DeepRequired } from '@vendure/core';
+import deepmerge from 'deepmerge';
+
+/**
+ * @description
+ * Configuration options for the {@link ElasticsearchPlugin}.
+ *
+ * @docsCategory ElasticsearchPlugin
+ * @docsPage ElasticsearchOptions
+ */
+export interface ElasticsearchOptions {
+    /**
+     * @description
+     * The host of the Elasticsearch server.
+     */
+    host: string;
+    /**
+     * @description
+     * The port of the Elasticsearch server.
+     */
+    port: number;
+    /**
+     * @description
+     * Prefix for the indices created by the plugin.
+     *
+     * @default
+     * 'vendure-'
+     */
+    indexPrefix?: string;
+    /**
+     * @description
+     * Batch size for bulk operations (e.g. when rebuilding the indices).
+     *
+     * @default
+     * 2000
+     */
+    batchSize?: number;
+    /**
+     * @description
+     * Configuration of the internal Elasticseach query.
+     */
+    searchConfig?: SearchConfig;
+}
+
+/**
+ * @description
+ * Configuration options for the internal Elasticsearch query which is generated when performing a search.
+ *
+ * @docsCategory ElasticsearchPlugin
+ * @docsPage ElasticsearchOptions
+ */
+export interface SearchConfig {
+    /**
+     * @description
+     * The maximum number of FacetValues to return from the search query. Internally, this
+     * value sets the "size" property of an Elasticsearch aggregation.
+     *
+     * @default
+     * 50
+     */
+    facetValueMaxSize?: number;
+
+    // prettier-ignore
+    /**
+     * @description
+     * Defines the
+     * [multi match type](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types)
+     * used when matching against a search term.
+     *
+     * @default
+     * 'best_fields'
+     */
+    multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
+    /**
+     * @description
+     * Set custom boost values for particular fields when matching against a search term.
+     */
+    boostFields?: BoostFieldsConfig;
+}
+
+/**
+ * @description
+ * Configuration for [boosting](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#field-boost)
+ * the scores of given fields when performing a search against a term.
+ *
+ * Boosting a field acts as a score multiplier for matches against that field.
+ *
+ * @docsCategory ElasticsearchPlugin
+ * @docsPage ElasticsearchOptions
+ */
+export interface BoostFieldsConfig {
+    /**
+     * @description
+     * Defines the boost factor for the productName field.
+     *
+     * @default 1
+     */
+    productName?: number;
+    /**
+     * @description
+     * Defines the boost factor for the productVariantName field.
+     *
+     * @default 1
+     */
+    productVariantName?: number;
+    /**
+     * @description
+     * Defines the boost factor for the description field.
+     *
+     * @default 1
+     */
+    description?: number;
+    /**
+     * @description
+     * Defines the boost factor for the sku field.
+     *
+     * @default 1
+     */
+    sku?: number;
+}
+
+export const defaultOptions: DeepRequired<ElasticsearchOptions> = {
+    host: 'http://localhost',
+    port: 9200,
+    indexPrefix: 'vendure-',
+    batchSize: 2000,
+    searchConfig: {
+        facetValueMaxSize: 50,
+        multiMatchType: 'best_fields',
+        boostFields: {
+            productName: 1,
+            productVariantName: 1,
+            description: 1,
+            sku: 1,
+        },
+    },
+};
+
+export function mergeWithDefaults(userOptions: ElasticsearchOptions): DeepRequired<ElasticsearchOptions> {
+    return deepmerge(defaultOptions, userOptions) as DeepRequired<ElasticsearchOptions>;
+}

+ 4 - 47
packages/elasticsearch-plugin/src/plugin.ts

@@ -2,6 +2,7 @@ import { Client } from '@elastic/elasticsearch';
 import {
     CatalogModificationEvent,
     CollectionModificationEvent,
+    DeepRequired,
     EventBus,
     idsAreEqual,
     Logger,
@@ -10,7 +11,6 @@ import {
     PluginCommonModule,
     Product,
     ProductVariant,
-    SearchService,
     TaxRateModificationEvent,
     Type,
     VendurePlugin,
@@ -21,50 +21,7 @@ import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { AdminElasticSearchResolver, ShopElasticSearchResolver } from './elasticsearch-resolver';
 import { ElasticsearchService } from './elasticsearch.service';
 import { ElasticsearchIndexerController } from './indexer.controller';
-
-/**
- * @description
- * Configuration options for the {@link ElasticsearchPlugin}.
- *
- * @docsCategory ElasticsearchPlugin
- */
-export interface ElasticsearchOptions {
-    /**
-     * @description
-     * The host of the Elasticsearch server.
-     */
-    host: string;
-    /**
-     * @description
-     * The port of the Elasticsearch server.
-     */
-    port: number;
-    /**
-     * @description
-     * Prefix for the indices created by the plugin.
-     *
-     * @default
-     * 'vendure-'
-     */
-    indexPrefix?: string;
-    /**
-     * @description
-     * Batch size for bulk operations (e.g. when rebuilding the indices)
-     *
-     * @default
-     * 2000
-     */
-    batchSize?: number;
-    /**
-     * @description
-     * The maximum number of FacetValues to return from the search query. Internally, this
-     * value sets the "size" property of an Elasticsearch aggregation.
-     *
-     * @default
-     * 50
-     */
-    facetValueMaxSize?: number;
-}
+import { ElasticsearchOptions, mergeWithDefaults } from './options';
 
 /**
  * @description
@@ -109,7 +66,7 @@ export interface ElasticsearchOptions {
     workers: [ElasticsearchIndexerController],
 })
 export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {
-    private static options: Required<ElasticsearchOptions>;
+    private static options: DeepRequired<ElasticsearchOptions>;
     private static client: Client;
 
     /** @internal */
@@ -124,7 +81,7 @@ export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {
      */
     static init(options: ElasticsearchOptions): Type<ElasticsearchPlugin> {
         const { host, port } = options;
-        this.options = { indexPrefix: 'vendure-', batchSize: 2000, facetValueMaxSize: 50, ...options };
+        this.options = mergeWithDefaults(options);
         this.client = new Client({
             node: `${host}:${port}`,
         });

+ 5 - 0
yarn.lock

@@ -3978,6 +3978,11 @@ deepmerge@^2.2.1:
   resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
   integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==
 
+deepmerge@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
+  integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
+
 default-compare@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz#cb61131844ad84d84788fb68fd01681ca7781a2f"