Implementing Fast Search with Elasticsearch and NestJS
Learn how to implement fast and scalable search functionality using Elasticsearch with NestJS, including best practices for indexing, searching, and maintaining data synchronization.
Elasticsearch | NestJS | TypeScript | Search | Performance | Database
Elasticsearch is a powerful search engine that can significantly improve search performance in your applications. In this guide, we'll explore how to integrate Elasticsearch with NestJS to create fast, scalable, and feature-rich search functionality.
Setting Up Elasticsearch with NestJS
First, let's set up our NestJS project with Elasticsearch. We'll use the official Elasticsearch client and create a dedicated module for search functionality.
npm install @nestjs/elasticsearch @elastic/elasticsearch
Creating the Elasticsearch Module
// elasticsearch.module.ts
import { Module } from '@nestjs/common';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ElasticsearchModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
node: configService.get('ELASTICSEARCH_NODE'),
auth: {
username: configService.get('ELASTICSEARCH_USERNAME'),
password: configService.get('ELASTICSEARCH_PASSWORD'),
},
tls: {
rejectUnauthorized: false
}
}),
inject: [ConfigService],
}),
],
exports: [ElasticsearchModule],
})
export class SearchModule {}
Defining Search Interfaces
// search.interface.ts
export interface SearchableProduct {
id: number;
name: string;
description: string;
price: number;
category: string;
tags: string[];
createdAt: Date;
}
export interface SearchResponse<T> {
hits: {
total: {
value: number;
};
hits: Array<{
_source: T;
_score: number;
}>;
};
}
Implementing the Search Service
// search.service.ts
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
import { SearchableProduct, SearchResponse } from './search.interface';
@Injectable()
export class SearchService {
private readonly index = 'products';
constructor(private readonly elasticsearchService: ElasticsearchService) {}
async indexProduct(product: SearchableProduct) {
return this.elasticsearchService.index<SearchableProduct>({
index: this.index,
document: {
id: product.id,
name: product.name,
description: product.description,
price: product.price,
category: product.category,
tags: product.tags,
createdAt: product.createdAt
},
});
}
async search(text: string) {
const { hits } = await this.elasticsearchService.search<SearchResponse<SearchableProduct>>({
index: this.index,
query: {
multi_match: {
query: text,
fields: ['name^3', 'description^2', 'category', 'tags'],
fuzziness: 'AUTO'
}
},
sort: [
{ _score: { order: 'desc' } },
{ createdAt: { order: 'desc' } }
],
highlight: {
fields: {
name: {},
description: {}
}
}
});
return hits.hits.map(hit => ({
...hit._source,
score: hit._score,
highlights: hit.highlight
}));
}
async searchWithFilters(params: {
text?: string;
category?: string;
minPrice?: number;
maxPrice?: number;
tags?: string[];
page?: number;
limit?: number;
}) {
const { text, category, minPrice, maxPrice, tags, page = 1, limit = 10 } = params;
const must: any[] = [];
const filter: any[] = [];
if (text) {
must.push({
multi_match: {
query: text,
fields: ['name^3', 'description^2', 'category', 'tags'],
fuzziness: 'AUTO'
}
});
}
if (category) {
filter.push({ term: { category } });
}
if (minPrice !== undefined || maxPrice !== undefined) {
filter.push({
range: {
price: {
...(minPrice !== undefined && { gte: minPrice }),
...(maxPrice !== undefined && { lte: maxPrice })
}
}
});
}
if (tags?.length) {
filter.push({ terms: { tags } });
}
const { hits } = await this.elasticsearchService.search<SearchResponse<SearchableProduct>>({
index: this.index,
query: {
bool: {
must,
filter
}
},
sort: [
{ _score: { order: 'desc' } },
{ createdAt: { order: 'desc' } }
],
from: (page - 1) * limit,
size: limit,
highlight: {
fields: {
name: {},
description: {}
}
}
});
return {
items: hits.hits.map(hit => ({
...hit._source,
score: hit._score,
highlights: hit.highlight
})),
total: hits.total.value,
page,
limit,
pages: Math.ceil(hits.total.value / limit)
};
}
}
领英推荐
Implementing Data Synchronization
To keep Elasticsearch in sync with your primary database, you can use event listeners or observers. Here's an example using TypeORM subscribers:
// product.subscriber.ts
import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { SearchService } from './search.service';
import { Product } from './product.entity';
@Injectable()
@EventSubscriber()
export class ProductSubscriber implements EntitySubscriberInterface<Product> {
constructor(
connection: Connection,
private readonly searchService: SearchService
) {
connection.subscribers.push(this);
}
listenTo() {
return Product;
}
async afterInsert(event: InsertEvent<Product>) {
await this.searchService.indexProduct(this.toSearchableProduct(event.entity));
}
async afterUpdate(event: UpdateEvent<Product>) {
if (event.entity) {
await this.searchService.indexProduct(this.toSearchableProduct(event.entity));
}
}
async beforeRemove(event: RemoveEvent<Product>) {
if (event.entityId) {
await this.searchService.remove(event.entityId);
}
}
private toSearchableProduct(product: Product): SearchableProduct {
return {
id: product.id,
name: product.name,
description: product.description,
price: product.price,
category: product.category,
tags: product.tags,
createdAt: product.createdAt
};
}
}
Using the Search Service in Controllers
// search.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service';
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get()
async search(
@Query('q') query: string,
@Query('category') category?: string,
@Query('minPrice') minPrice?: number,
@Query('maxPrice') maxPrice?: number,
@Query('tags') tags?: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.searchService.searchWithFilters({
text: query,
category,
minPrice,
maxPrice,
tags: tags?.split(','),
page,
limit
});
}
}
Best Practices and Optimization
Implementing Bulk Operations
// search.service.ts
async bulkIndex(products: SearchableProduct[]) {
const operations = products.flatMap(product => [
{ index: { _index: this.index } },
{
id: product.id,
name: product.name,
description: product.description,
price: product.price,
category: product.category,
tags: product.tags,
createdAt: product.createdAt
}
]);
return this.elasticsearchService.bulk({ operations });
}
Index Mapping Configuration
// search.service.ts
async createIndex() {
const index = this.index;
const exists = await this.elasticsearchService.indices.exists({ index });
if (!exists) {
await this.elasticsearchService.indices.create({
index,
mappings: {
properties: {
id: { type: 'integer' },
name: {
type: 'text',
analyzer: 'standard',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256
}
}
},
description: { type: 'text' },
price: { type: 'float' },
category: { type: 'keyword' },
tags: { type: 'keyword' },
createdAt: { type: 'date' }
}
},
settings: {
analysis: {
analyzer: {
standard: {
type: 'standard',
stopwords: '_english_'
}
}
}
}
});
}
}
By following these patterns and implementing proper optimization strategies, you can create a powerful search functionality that scales well with your application's growth. Remember to monitor your Elasticsearch cluster's performance and adjust settings based on your specific use case.