Search implementation ranges from simple ILIKE queries (fine for < 10K rows) to vector similarity search for semantic matching. Claude Code helps choose the right approach and implements it correctly: proper tsvector indexes in PostgreSQL, Elasticsearch query DSL, and pgvector for semantic search.
This guide covers search with Claude Code: PostgreSQL full-text, Elasticsearch, vector similarity, autocomplete, and faceted filtering.
Choosing Your Search Backend
We have a product catalog with 500K items. Users search by name,
description, and want filters by category, price range, and rating.
What search technology should we use?
For 500K products with structured filters:
- PostgreSQL full-text works, but struggle with fuzzy matching and ranking by multiple factors
- Elasticsearch/OpenSearch is the standard choice: relevance scoring, facets, autocomplete all built-in
- Typesense/Meilisearch is simpler to operate than Elasticsearch for this scale
- pgvector needed only if you want semantic similarity (e.g., “cozy sweaters” finding “warm knitwear”)
For this scale, PostgreSQL full-text + some Elasticsearch for the search UI.
PostgreSQL Full-Text Search
Add full-text search to the products table.
Products have name, description, category, and tags.
Boost results where the name matches over description.
-- Add generated tsvector column (auto-updated, indexed)
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(category, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'C') ||
setweight(to_tsvector('english', COALESCE(array_to_string(tags, ' '), '')), 'B')
) STORED;
-- GIN index for fast full-text lookups
CREATE INDEX CONCURRENTLY idx_products_search ON products USING GIN (search_vector);
-- Query with ranking
SELECT
id,
name,
category,
price_cents,
-- ts_rank_cd accounts for proximity between search terms
ts_rank_cd(search_vector, query) AS rank,
ts_headline('english', description, query,
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2'
) AS highlighted_description
FROM products
, to_tsquery('english', $1) AS query
WHERE search_vector @@ query
AND ($2::text IS NULL OR category = $2)
AND ($3::int IS NULL OR price_cents >= $3)
AND ($4::int IS NULL OR price_cents <= $4)
ORDER BY rank DESC, name ASC
LIMIT 20 OFFSET $5;
// src/search/product-search.ts
function buildTsQuery(input: string): string {
// Convert user input to tsquery: "running shoes" → "running & shoes"
// Handle phrases: "red shirt" → "red <-> shirt" (adjacent)
const words = input.trim().split(/\s+/).filter(w => w.length > 1);
return words.map(w => `${w}:*`).join(' & '); // Prefix matching with :*
}
export async function searchProducts(db: Pool, params: {
query: string;
category?: string;
minPrice?: number;
maxPrice?: number;
page?: number;
}) {
const tsQuery = buildTsQuery(params.query);
const { rows } = await db.query(
`SELECT id, name, category, price_cents, ts_rank_cd(search_vector, $1) AS rank
FROM products, to_tsquery('english', $1) AS query_parsed
WHERE search_vector @@ query_parsed
AND ($2::text IS NULL OR category = $2)
ORDER BY rank DESC LIMIT 20 OFFSET $3`,
[tsQuery, params.category ?? null, ((params.page ?? 1) - 1) * 20],
);
return rows;
}
Elasticsearch Integration
Index products to Elasticsearch for production search.
Include proper mappings, relevance tuning, and the query structure.
// src/search/elasticsearch.ts
import { Client } from '@elastic/elasticsearch';
const client = new Client({ node: process.env.ELASTICSEARCH_URL });
// Index mapping — defined once at index creation
export async function createProductsIndex() {
await client.indices.create({
index: 'products',
body: {
settings: {
analysis: {
analyzer: {
// Custom analyzer for product names (removes stopwords, stems)
product_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: ['lowercase', 'stop', 'porter_stem'],
},
},
},
},
mappings: {
properties: {
id: { type: 'keyword' },
name: {
type: 'text',
analyzer: 'product_analyzer',
fields: {
keyword: { type: 'keyword' }, // For sorting/aggregations
suggest: { type: 'completion' }, // For autocomplete
},
},
description: { type: 'text', analyzer: 'product_analyzer' },
category: { type: 'keyword' },
tags: { type: 'keyword' },
price_cents: { type: 'integer' },
rating: { type: 'float' },
in_stock: { type: 'boolean' },
created_at: { type: 'date' },
},
},
},
});
}
// Search with multi-field boost, filters, and facets
export async function searchProducts(params: {
query: string;
category?: string;
minPrice?: number;
maxPrice?: number;
page?: number;
}) {
const { hits, aggregations } = await client.search({
index: 'products',
body: {
query: {
bool: {
must: [
{
multi_match: {
query: params.query,
fields: [
'name^3', // Name match worth 3x
'category^2', // Category worth 2x
'tags^2',
'description',
],
type: 'most_fields',
fuzziness: 'AUTO', // Handles typos
},
},
],
filter: [
params.category && { term: { category: params.category } },
params.minPrice && { range: { price_cents: { gte: params.minPrice } } },
params.maxPrice && { range: { price_cents: { lte: params.maxPrice } } },
{ term: { in_stock: true } },
].filter(Boolean),
should: [
// Boost recently added products slightly
{
range: {
created_at: {
gte: 'now-30d',
boost: 1.5,
},
},
},
],
},
},
// Facets for filtering UI
aggs: {
categories: {
terms: { field: 'category', size: 20 },
},
price_ranges: {
range: {
field: 'price_cents',
ranges: [
{ to: 2500, key: 'under_25' },
{ from: 2500, to: 7500, key: '25_75' },
{ from: 7500, key: 'over_75' },
],
},
},
avg_rating: { avg: { field: 'rating' } },
},
highlight: {
fields: { name: {}, description: { fragment_size: 150 } },
},
from: ((params.page ?? 1) - 1) * 20,
size: 20,
},
});
return {
results: hits.hits.map(hit => ({
...hit._source,
score: hit._score,
highlights: hit.highlight,
})),
total: (hits.total as any).value,
facets: aggregations,
};
}
Vector Search with pgvector
Users searching "comfortable office chair" should also find
"ergonomic desk seating". Add semantic search using embeddings.
// src/search/vector-search.ts
import OpenAI from 'openai';
import { Pool } from 'pg';
const openai = new OpenAI();
// Store embeddings alongside products
// ALTER TABLE products ADD COLUMN embedding vector(1536);
// CREATE INDEX ON products USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
// Hybrid search: combine keyword and semantic
export async function hybridSearch(db: Pool, query: string, opts: {
keywordWeight?: number;
semanticWeight?: number;
} = {}) {
const { keywordWeight = 0.5, semanticWeight = 0.5 } = opts;
const queryEmbedding = await generateEmbedding(query);
const { rows } = await db.query(
`WITH keyword_results AS (
SELECT id, ts_rank_cd(search_vector, to_tsquery('english', $1)) AS keyword_score
FROM products
WHERE search_vector @@ to_tsquery('english', $1)
),
semantic_results AS (
SELECT id, 1 - (embedding <=> $2::vector) AS semantic_score
FROM products
ORDER BY embedding <=> $2::vector
LIMIT 50
)
SELECT
p.id, p.name, p.price_cents, p.category,
COALESCE(k.keyword_score, 0) * $3 + COALESCE(s.semantic_score, 0) * $4 AS combined_score
FROM products p
FULL OUTER JOIN keyword_results k ON k.id = p.id
FULL OUTER JOIN semantic_results s ON s.id = p.id
WHERE k.id IS NOT NULL OR s.id IS NOT NULL
ORDER BY combined_score DESC
LIMIT 20`,
[`${query.split(' ').join(' & ')}:*`, JSON.stringify(queryEmbedding), keywordWeight, semanticWeight],
);
return rows;
}
For integrating this search with Elasticsearch into the existing search infrastructure, see the search and Elasticsearch guide. For machine learning embeddings and vector databases for AI-powered features, see the machine learning guide. The Claude Skills 360 bundle includes search implementation skill sets for full-text, vector, and hybrid search patterns. Start with the free tier to try search code generation.