Search is one of the most user-visible features in any product — and one of the hardest to get right. The difference between a search that feels fast and accurate versus one that frustrates users comes down to index design, query construction, and relevance tuning. Claude Code generates search implementations with proper tokenization, field boosting, and fuzzy matching — not just WHERE content LIKE '%query%'.
This guide covers search with Claude Code: Elasticsearch for production search, Typesense for simpler use cases, and PostgreSQL full-text search for teams that don’t want an extra service.
PostgreSQL Full-Text Search
We have a 50k row products table.
Add search without adding Elasticsearch.
PostgreSQL’s full-text search handles millions of rows and supports ranking, stemming, and exact phrase matching — sufficient for many applications.
-- Add a tsvector column for efficient searching
ALTER TABLE products ADD COLUMN search_vector tsvector;
-- Populate it (weighted: A=title, B=description, C=tags)
UPDATE products SET search_vector =
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C');
-- GIN index for fast full-text search
CREATE INDEX idx_products_search ON products USING GIN (search_vector);
-- Keep search_vector in sync with updates
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(NEW.tags, ' '), '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_search_update
BEFORE INSERT OR UPDATE OF name, description, tags
ON products
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
-- Search query with ranking
SELECT
id, name, description, price,
ts_rank(search_vector, query) as rank,
ts_headline('english', description, query, 'MaxWords=20, MinWords=10') as snippet
FROM products,
to_tsquery('english', 'wireless & headphones') query
WHERE search_vector @@ query
AND status = 'active'
ORDER BY rank DESC
LIMIT 20;
// TypeScript wrapper
async function searchProducts(query: string, filters: ProductFilters) {
// Convert user query to tsquery format (handle special characters)
const sanitized = query
.replace(/[&|!():*]/g, ' ') // Remove tsquery operators
.trim()
.split(/\s+/)
.filter(Boolean)
.join(' & '); // AND all terms
const results = await db.query(`
SELECT id, name, description, price, category,
ts_rank(search_vector, to_tsquery('english', $1)) as rank,
ts_headline('english', description, to_tsquery('english', $1)) as snippet
FROM products
WHERE search_vector @@ to_tsquery('english', $1)
AND ($2::text IS NULL OR category = $2)
AND ($3::numeric IS NULL OR price <= $3)
ORDER BY rank DESC
LIMIT $4 OFFSET $5
`, [sanitized, filters.category, filters.maxPrice, filters.limit ?? 20, filters.offset ?? 0]);
return results.rows;
}
Typesense (Easy Out-of-the-Box Search)
We want faceted search — filter by category, price range, rating.
Typesense looks simpler than Elasticsearch. Set it up.
// src/lib/typesense.ts
import Typesense from 'typesense';
export const typesense = new Typesense.Client({
nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: 'https' }],
apiKey: process.env.TYPESENSE_API_KEY!,
connectionTimeoutSeconds: 2,
});
// Schema — define before indexing
export async function createProductsCollection() {
return typesense.collections().create({
name: 'products',
fields: [
{ name: 'id', type: 'string' },
{ name: 'name', type: 'string' }, // Default search field
{ name: 'description', type: 'string' },
{ name: 'category', type: 'string', facet: true }, // Facetable
{ name: 'brand', type: 'string', facet: true },
{ name: 'tags', type: 'string[]', facet: true },
{ name: 'price', type: 'float', facet: true },
{ name: 'rating', type: 'float', facet: true },
{ name: 'in_stock', type: 'bool', facet: true },
],
default_sorting_field: 'rating',
});
}
// Faceted search query
async function searchProducts(req: SearchRequest) {
const { query, filters, page = 1, perPage = 20 } = req;
// Build Typesense filter string
const filterClauses = [];
if (filters.category) filterClauses.push(`category:=${filters.category}`);
if (filters.maxPrice) filterClauses.push(`price:<=${filters.maxPrice}`);
if (filters.minRating) filterClauses.push(`rating:>=${filters.minRating}`);
if (filters.inStock) filterClauses.push(`in_stock:=true`);
const searchParams = {
q: query || '*',
query_by: 'name,description,tags',
query_by_weights: '3,1,2', // name is 3x more important than description
filter_by: filterClauses.join(' && ') || undefined,
facet_by: 'category,brand,tags,price(,10,50,100,500),rating(0,3,4,4.5)',
per_page: perPage,
page,
prefix: true, // Match partial words (type "head" → "headphones")
typo_tokens_threshold: 1, // Allow 1 typo for tokens > 4 chars
highlight_full_fields: 'name',
highlight_affix_num_tokens: 4,
};
const results = await typesense.collections('products').documents().search(searchParams);
return {
hits: results.hits?.map(h => ({
...h.document,
highlight: h.highlight,
})) ?? [],
total: results.found,
facets: results.facet_counts?.reduce((acc, f) => {
acc[f.field_name] = f.counts;
return acc;
}, {} as Record<string, Array<{ value: string; count: number }>>),
};
}
Elasticsearch for Advanced Search
We need fuzzy matching, synonym support, and custom relevance scoring.
Set up Elasticsearch with our product catalog.
// src/lib/elasticsearch.ts
import { Client } from '@elastic/elasticsearch';
const client = new Client({
node: process.env.ELASTICSEARCH_URL ?? 'http://localhost:9200',
});
// Index mapping — define field types and analysis
await client.indices.create({
index: 'products',
body: {
settings: {
analysis: {
filter: {
english_stemmer: { type: 'stemmer', language: 'english' },
product_synonyms: {
type: 'synonym',
synonyms: [
'tv, television',
'laptop, notebook',
'phone, smartphone, mobile',
],
},
},
analyzer: {
product_search_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: ['lowercase', 'english_stemmer', 'product_synonyms'],
},
},
},
},
mappings: {
properties: {
name: {
type: 'text',
analyzer: 'product_search_analyzer',
fields: {
keyword: { type: 'keyword' }, // For exact sorting
suggest: { type: 'completion' }, // Autocomplete
},
},
description: { type: 'text', analyzer: 'product_search_analyzer' },
category: { type: 'keyword' }, // Exact match only
price: { type: 'float' },
rating: { type: 'float' },
tags: { type: 'keyword' },
},
},
},
});
// Multi-match query with fuzzy matching and boosting
async function searchProducts(query: string, filters: ProductFilters) {
const response = await client.search({
index: 'products',
body: {
query: {
bool: {
must: [{
multi_match: {
query,
fields: ['name^3', 'description^1', 'tags^2'], // ^N = boost
type: 'best_fields',
fuzziness: 'AUTO', // Auto-scales: 0 for 1-2 chars, 1 for 3-5, 2 for 6+
prefix_length: 1, // First char must match exactly (performance)
},
}],
filter: [
...(filters.category ? [{ term: { category: filters.category } }] : []),
...(filters.maxPrice ? [{ range: { price: { lte: filters.maxPrice } } }] : []),
...(filters.inStock ? [{ term: { in_stock: true } }] : []),
],
},
},
aggs: {
categories: { terms: { field: 'category', size: 20 } },
price_ranges: {
range: {
field: 'price',
ranges: [
{ to: 50 }, { from: 50, to: 200 }, { from: 200, to: 1000 }, { from: 1000 },
],
},
},
avg_rating: { avg: { field: 'rating' } },
},
highlight: {
fields: {
name: { pre_tags: ['<mark>'], post_tags: ['</mark>'] },
description: { fragment_size: 150, number_of_fragments: 1 },
},
},
},
});
return {
hits: response.hits.hits.map(hit => ({
...hit._source,
score: hit._score,
highlight: hit.highlight,
})),
total: (response.hits.total as { value: number }).value,
facets: {
categories: response.aggregations?.categories?.buckets,
priceRanges: response.aggregations?.price_ranges?.buckets,
},
};
}
For search in a real-time context where results update as users type, the WebSocket scaling guide covers the pub/sub patterns. For database-layer search optimization with full-text indexes in PostgreSQL, see the SQL optimization guide. The Claude Skills 360 bundle includes search skill sets for Elasticsearch and Typesense. Start with the free tier to implement search for your application.