Back to Blog
SearchElasticsearchFull-text SearchBackend

Search Implementation with Elasticsearch

Build powerful search features. From basic text search to faceted search to autocomplete and relevance tuning.

B
Bootspring Team
Engineering
October 20, 2023
6 min read

Users expect fast, relevant search. Elasticsearch provides powerful full-text search capabilities. Here's how to implement search features that users love.

Elasticsearch Setup#

1import { Client } from '@elastic/elasticsearch'; 2 3const elastic = new Client({ 4 node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200', 5 auth: { 6 username: process.env.ELASTICSEARCH_USER, 7 password: process.env.ELASTICSEARCH_PASSWORD, 8 }, 9}); 10 11// Create index with mappings 12async function createProductIndex() { 13 await elastic.indices.create({ 14 index: 'products', 15 body: { 16 settings: { 17 number_of_shards: 1, 18 number_of_replicas: 1, 19 analysis: { 20 analyzer: { 21 autocomplete: { 22 type: 'custom', 23 tokenizer: 'standard', 24 filter: ['lowercase', 'autocomplete_filter'], 25 }, 26 }, 27 filter: { 28 autocomplete_filter: { 29 type: 'edge_ngram', 30 min_gram: 2, 31 max_gram: 20, 32 }, 33 }, 34 }, 35 }, 36 mappings: { 37 properties: { 38 name: { 39 type: 'text', 40 analyzer: 'standard', 41 fields: { 42 autocomplete: { 43 type: 'text', 44 analyzer: 'autocomplete', 45 }, 46 keyword: { 47 type: 'keyword', 48 }, 49 }, 50 }, 51 description: { 52 type: 'text', 53 }, 54 category: { 55 type: 'keyword', 56 }, 57 brand: { 58 type: 'keyword', 59 }, 60 price: { 61 type: 'float', 62 }, 63 rating: { 64 type: 'float', 65 }, 66 inStock: { 67 type: 'boolean', 68 }, 69 tags: { 70 type: 'keyword', 71 }, 72 createdAt: { 73 type: 'date', 74 }, 75 }, 76 }, 77 }, 78 }); 79}

Indexing Documents#

1// Index single document 2async function indexProduct(product: Product) { 3 await elastic.index({ 4 index: 'products', 5 id: product.id, 6 body: { 7 name: product.name, 8 description: product.description, 9 category: product.category, 10 brand: product.brand, 11 price: product.price, 12 rating: product.rating, 13 inStock: product.inStock, 14 tags: product.tags, 15 createdAt: product.createdAt, 16 }, 17 }); 18} 19 20// Bulk indexing 21async function bulkIndexProducts(products: Product[]) { 22 const operations = products.flatMap((product) => [ 23 { index: { _index: 'products', _id: product.id } }, 24 { 25 name: product.name, 26 description: product.description, 27 category: product.category, 28 brand: product.brand, 29 price: product.price, 30 rating: product.rating, 31 inStock: product.inStock, 32 tags: product.tags, 33 createdAt: product.createdAt, 34 }, 35 ]); 36 37 const result = await elastic.bulk({ operations }); 38 39 if (result.errors) { 40 const errors = result.items.filter((item) => item.index?.error); 41 logger.error('Bulk indexing errors', { errors }); 42 } 43 44 return result; 45} 46 47// Keep index in sync 48async function syncProductToElastic(productId: string) { 49 const product = await prisma.product.findUnique({ 50 where: { id: productId }, 51 }); 52 53 if (product) { 54 await indexProduct(product); 55 } else { 56 await elastic.delete({ 57 index: 'products', 58 id: productId, 59 }); 60 } 61}
1interface SearchParams { 2 query: string; 3 page?: number; 4 limit?: number; 5 sort?: string; 6 filters?: { 7 category?: string; 8 brand?: string[]; 9 minPrice?: number; 10 maxPrice?: number; 11 inStock?: boolean; 12 }; 13} 14 15async function searchProducts(params: SearchParams) { 16 const { 17 query, 18 page = 1, 19 limit = 20, 20 sort = 'relevance', 21 filters = {}, 22 } = params; 23 24 const must: any[] = []; 25 const filter: any[] = []; 26 27 // Full-text search 28 if (query) { 29 must.push({ 30 multi_match: { 31 query, 32 fields: ['name^3', 'description', 'brand^2', 'tags'], 33 type: 'best_fields', 34 fuzziness: 'AUTO', 35 }, 36 }); 37 } 38 39 // Filters 40 if (filters.category) { 41 filter.push({ term: { category: filters.category } }); 42 } 43 44 if (filters.brand?.length) { 45 filter.push({ terms: { brand: filters.brand } }); 46 } 47 48 if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { 49 filter.push({ 50 range: { 51 price: { 52 gte: filters.minPrice, 53 lte: filters.maxPrice, 54 }, 55 }, 56 }); 57 } 58 59 if (filters.inStock !== undefined) { 60 filter.push({ term: { inStock: filters.inStock } }); 61 } 62 63 // Sort options 64 const sortOptions: Record<string, any> = { 65 relevance: [{ _score: 'desc' }], 66 price_asc: [{ price: 'asc' }], 67 price_desc: [{ price: 'desc' }], 68 rating: [{ rating: 'desc' }], 69 newest: [{ createdAt: 'desc' }], 70 }; 71 72 const result = await elastic.search({ 73 index: 'products', 74 body: { 75 from: (page - 1) * limit, 76 size: limit, 77 query: { 78 bool: { 79 must: must.length ? must : [{ match_all: {} }], 80 filter, 81 }, 82 }, 83 sort: sortOptions[sort] || sortOptions.relevance, 84 highlight: { 85 fields: { 86 name: {}, 87 description: {}, 88 }, 89 pre_tags: ['<mark>'], 90 post_tags: ['</mark>'], 91 }, 92 }, 93 }); 94 95 return { 96 hits: result.hits.hits.map((hit) => ({ 97 id: hit._id, 98 score: hit._score, 99 ...hit._source, 100 highlights: hit.highlight, 101 })), 102 total: (result.hits.total as any).value, 103 page, 104 limit, 105 pages: Math.ceil((result.hits.total as any).value / limit), 106 }; 107}
1async function searchWithFacets(params: SearchParams) { 2 const result = await elastic.search({ 3 index: 'products', 4 body: { 5 // ... query from above ... 6 aggs: { 7 categories: { 8 terms: { 9 field: 'category', 10 size: 20, 11 }, 12 }, 13 brands: { 14 terms: { 15 field: 'brand', 16 size: 50, 17 }, 18 }, 19 price_ranges: { 20 range: { 21 field: 'price', 22 ranges: [ 23 { key: 'under_25', to: 25 }, 24 { key: '25_50', from: 25, to: 50 }, 25 { key: '50_100', from: 50, to: 100 }, 26 { key: '100_200', from: 100, to: 200 }, 27 { key: 'over_200', from: 200 }, 28 ], 29 }, 30 }, 31 avg_rating: { 32 avg: { 33 field: 'rating', 34 }, 35 }, 36 rating_distribution: { 37 histogram: { 38 field: 'rating', 39 interval: 1, 40 }, 41 }, 42 }, 43 }, 44 }); 45 46 return { 47 // ... hits ... 48 facets: { 49 categories: result.aggregations?.categories.buckets, 50 brands: result.aggregations?.brands.buckets, 51 priceRanges: result.aggregations?.price_ranges.buckets, 52 avgRating: result.aggregations?.avg_rating.value, 53 ratingDistribution: result.aggregations?.rating_distribution.buckets, 54 }, 55 }; 56}

Autocomplete#

1async function autocomplete(query: string, limit = 10) { 2 const result = await elastic.search({ 3 index: 'products', 4 body: { 5 size: limit, 6 query: { 7 bool: { 8 should: [ 9 { 10 match: { 11 'name.autocomplete': { 12 query, 13 operator: 'and', 14 }, 15 }, 16 }, 17 { 18 match_phrase_prefix: { 19 name: { 20 query, 21 boost: 2, 22 }, 23 }, 24 }, 25 ], 26 }, 27 }, 28 _source: ['name', 'category', 'brand'], 29 }, 30 }); 31 32 return result.hits.hits.map((hit) => ({ 33 id: hit._id, 34 name: (hit._source as any).name, 35 category: (hit._source as any).category, 36 brand: (hit._source as any).brand, 37 })); 38} 39 40// API endpoint 41app.get('/api/search/autocomplete', async (req, res) => { 42 const { q } = req.query; 43 44 if (!q || typeof q !== 'string' || q.length < 2) { 45 return res.json([]); 46 } 47 48 const suggestions = await autocomplete(q); 49 res.json(suggestions); 50});

Search Analytics#

1// Log searches for analytics 2async function logSearch(params: { 3 query: string; 4 userId?: string; 5 results: number; 6 responseTime: number; 7}) { 8 await elastic.index({ 9 index: 'search_analytics', 10 body: { 11 ...params, 12 timestamp: new Date(), 13 }, 14 }); 15} 16 17// Popular searches 18async function getPopularSearches(days = 7, limit = 10) { 19 const result = await elastic.search({ 20 index: 'search_analytics', 21 body: { 22 size: 0, 23 query: { 24 range: { 25 timestamp: { 26 gte: `now-${days}d`, 27 }, 28 }, 29 }, 30 aggs: { 31 popular: { 32 terms: { 33 field: 'query.keyword', 34 size: limit, 35 }, 36 }, 37 }, 38 }, 39 }); 40 41 return result.aggregations?.popular.buckets; 42}

Best Practices#

Indexing: ✓ Use bulk operations for many documents ✓ Refresh indices strategically ✓ Keep mappings consistent ✓ Use aliases for zero-downtime reindexing Searching: ✓ Implement proper pagination ✓ Use filters for exact matches ✓ Tune relevance with boosting ✓ Add fuzziness for typo tolerance Performance: ✓ Cache common queries ✓ Use appropriate shard counts ✓ Monitor query performance ✓ Limit result sizes

Conclusion#

Elasticsearch enables powerful search experiences. Start with basic full-text search, add facets for filtering, and implement autocomplete for instant feedback.

Monitor search analytics to understand what users are looking for and tune relevance accordingly.

Share this article

Help spread the word about Bootspring