Claude Code for Search Implementation: Full-Text, Vector, and Faceted Search — Claude Skills 360 Blog
Blog / Development / Claude Code for Search Implementation: Full-Text, Vector, and Faceted Search
Development

Claude Code for Search Implementation: Full-Text, Vector, and Faceted Search

Published: September 1, 2026
Read time: 9 min read
By: Claude Skills 360

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.

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.

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