Claude Code for Search: Elasticsearch, Typesense, and Full-Text Search — Claude Skills 360 Blog
Blog / Development / Claude Code for Search: Elasticsearch, Typesense, and Full-Text Search
Development

Claude Code for Search: Elasticsearch, Typesense, and Full-Text Search

Published: July 11, 2026
Read time: 9 min read
By: Claude Skills 360

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.

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;
}
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 }>>),
  };
}
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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free