← All posts
·11 min read

Claude Code with Elasticsearch: Search Done Right

Claude CodeElasticsearchSearchBackend
Claude Code building an Elasticsearch integration with mappings, queries, and bulk indexing

Why Elasticsearch needs explicit CLAUDE.md constraints

Elasticsearch is a distributed search and analytics engine. You index documents, it builds inverted indices, and you query them with a rich JSON query DSL that supports full-text relevance, exact filters, aggregations, and more. The official clients (@elastic/elasticsearch for Node, elasticsearch for Python) wrap the REST API in idiomatic methods.

The problem when you ask Claude Code to "add Elasticsearch search" is twofold. First, the client API has changed shape across major versions, and Claude's training data is full of the old form where every call took a body object. The current Node client takes request parameters at the top level, so code Claude writes from memory often nests everything under body and either errors or triggers deprecation paths. Second, and more damaging, Claude tends to skip the mapping. It indexes documents straight into a fresh index and lets Elasticsearch infer types, which produces text fields where you needed keyword, breaks your sorting and aggregations, and analyses fields you wanted matched exactly. By the time you notice, you have a corrupt index that must be reindexed.

These are not obscure edge cases. They are the default output of a competent Claude Code session with no guardrails for which client surface to use and no instruction to define mappings first. The CLAUDE.md file is what fixes both.

This guide covers the CLAUDE.md configuration, explicit mappings, the query DSL (match versus term versus filter), bulk indexing, search with pagination, and the six most common mistakes Claude makes by default. Elasticsearch usually sits behind an API, so Claude Code with PostgreSQL covers the primary datastore it complements, and Claude Code with Docker covers running an Elasticsearch container for local development.

The Elasticsearch CLAUDE.md template

Place this at your project root. Every Claude Code session reads it before generating any code.

# Elasticsearch rules

## Stack
- @elastic/elasticsearch (current major), TypeScript 5.x strict, Node 20.x
- Elasticsearch 8.x+ cluster (cloud or self-hosted)
- One client, configured from environment, never hardcoded credentials

## Client initialisation
- ALWAYS use a singleton: import { es } from '@/lib/es'
- src/lib/es.ts:
  import { Client } from '@elastic/elasticsearch';
  export const es = new Client({
    node: process.env.ELASTIC_NODE!,
    auth: { apiKey: process.env.ELASTIC_API_KEY! },
  });
- NEVER hardcode node URL, API key, or password
- Prefer apiKey auth over username/password for services

## Request shape (MANDATORY, current client)
- Parameters are TOP-LEVEL, not nested under a `body` object
- Search: es.search({ index, query, sort, from, size, aggs })
- Index:  es.index({ index, id, document })
- Get:    es.get({ index, id })
- NEVER wrap request params in { body: { ... } }; that is the deprecated form

## Mappings (MANDATORY before first index)
- ALWAYS create the index with an explicit mapping before indexing documents
- Use `keyword` for exact-match / sort / aggregate fields (ids, status, tags)
- Use `text` ONLY for full-text searchable fields (titles, descriptions, body)
- For a field that is both, use text with a keyword sub-field: title + title.keyword
- Set `date` type for timestamps, `integer`/`float`/`boolean` explicitly
- NEVER let Elasticsearch auto-infer mappings for a production index

## Query semantics (get these right)
- match  -> full-text, analysed, relevance-scored (use on text fields)
- term   -> exact, NOT analysed (use on keyword fields, never on text)
- Use bool with must/should/filter; put non-scoring conditions in `filter`
- `filter` clauses are cached and do not affect score; prefer them for facets
- NEVER use `term` on a `text` field; it will not match the analysed tokens

## Bulk operations
- ALWAYS use the bulk helper for >1 document: es.helpers.bulk({ datasource, onDocument })
- NEVER loop es.index() for batches; it is slow and not atomic per batch
- Check the bulk response for per-item errors; bulk does not throw on partial failure

## Hard rules
- ALWAYS define mappings before indexing; reindex is the only fix for a wrong mapping
- ALWAYS paginate with from/size (or search_after for deep pagination)
- NEVER request size: 10000+ without search_after; deep from/size is expensive
- ALWAYS handle the 404 from es.get() on a missing document (it throws)
- Refresh is NOT immediate; after index, a doc is searchable after the refresh interval

Three rules prevent the majority of production breakage.

The explicit mapping rule is the most important line in the file and the one Claude violates most. If you index into a non-existent index, Elasticsearch creates it with inferred mappings, and inference is frequently wrong for your purposes. A status field becomes text and gets analysed, so term filters silently return nothing and aggregations fail. The only remedy for a wrong mapping is to create a new index with the right one and reindex every document. Declaring "define mappings before indexing" with concrete keyword versus text guidance stops the entire class of problem at the source.

The request shape rule kills a version-mismatch bug. The current Node client takes parameters at the top level: es.search({ index, query }). Older versions, which dominate Claude's training data, took es.search({ index, body: { query } }). Claude defaults to the body-wrapped form, which on the current client either errors or routes through compatibility handling you do not want. Pinning the top-level shape with examples redirects every generated call.

The match-versus-term rule prevents silent zero-result bugs. match is for analysed full-text fields and scores by relevance. term is for exact, un-analysed keyword fields. Use term on a text field and it tries to match the literal string against analysed tokens, which almost never matches, so your query returns nothing and no error. Claude mixes these constantly. The rule plus the mapping discipline keeps them aligned.

Install and connection

npm i @elastic/elasticsearch

For local development, run Elasticsearch in Docker:

docker run -d --name es \
  -p 9200:9200 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  docker.elastic.co/elasticsearch/elasticsearch:8.13.0

Set the connection details in the environment:

# .env
ELASTIC_NODE=https://your-cluster.es.cloud:9243
ELASTIC_API_KEY=base64-encoded-api-key

Create the singleton client:

// src/lib/es.ts
import { Client } from '@elastic/elasticsearch';

if (!process.env.ELASTIC_NODE) {
  throw new Error('ELASTIC_NODE is not defined');
}

export const es = new Client({
  node: process.env.ELASTIC_NODE,
  auth: { apiKey: process.env.ELASTIC_API_KEY ?? '' },
});

The startup check surfaces a missing node URL at boot rather than at the first query deep in a request handler. For running the local container as part of a broader stack, Claude Code with Docker covers the compose setup.

Defining the mapping first

Before indexing a single document, create the index with an explicit mapping. This is the step Claude skips and the step that determines whether search works.

// scripts/create-index.ts
import { es } from '../src/lib/es';

const INDEX = 'products';

async function createIndex() {
  const exists = await es.indices.exists({ index: INDEX });
  if (exists) {
    console.log(`Index ${INDEX} already exists`);
    return;
  }

  await es.indices.create({
    index: INDEX,
    mappings: {
      properties: {
        name:        { type: 'text', fields: { keyword: { type: 'keyword' } } },
        description: { type: 'text' },
        category:    { type: 'keyword' },          // exact filter + aggregate
        tags:        { type: 'keyword' },           // exact, multi-value
        price:       { type: 'float' },
        in_stock:    { type: 'boolean' },
        created_at:  { type: 'date' },
      },
    },
  });

  console.log(`Created index ${INDEX}`);
}

createIndex().catch(console.error);

The choices here are the whole game. name is text so it is searchable, with a keyword sub-field (name.keyword) so you can also sort and aggregate on it exactly. category and tags are keyword because you filter and facet on them, not full-text search them. price is float, in_stock is boolean, created_at is date. Get these right once and every query behaves; get them wrong and you reindex.

Indexing documents

For a single document, es.index():

// src/lib/products.ts
import { es } from '@/lib/es';

export interface Product {
  name: string;
  description: string;
  category: string;
  tags: string[];
  price: number;
  in_stock: boolean;
  created_at: string;
}

export async function indexProduct(id: string, product: Product) {
  await es.index({
    index: 'products',
    id,
    document: product,    // top-level `document`, not body
  });
}

For many documents, never loop es.index(). Use the bulk helper, which batches, retries, and reports per-item errors:

export async function bulkIndexProducts(
  products: Array<{ id: string; doc: Product }>
) {
  const result = await es.helpers.bulk({
    datasource: products,
    onDocument(item) {
      return { index: { _index: 'products', _id: item.id } };
    },
  });

  // The helper resolves with stats; check failed count
  if (result.failed > 0) {
    console.error(`Bulk index had ${result.failed} failures`);
  }
  return result;
}

The bulk helper is dramatically faster than individual indexing and handles back-pressure and retries for you. The one thing to remember: bulk does not throw on a partial failure, so you must inspect the result for failed items rather than assuming success.

Querying: match, term, and filter

The query DSL is where the text versus keyword distinction pays off. Full-text search uses match on a text field:

export async function searchProducts(searchTerm: string) {
  const result = await es.search<Product>({
    index: 'products',
    query: {
      match: { name: searchTerm },    // analysed, relevance-scored
    },
    size: 20,
  });

  return result.hits.hits.map(hit => ({
    id: hit._id,
    score: hit._score,
    ...hit._source!,
  }));
}

Exact filtering uses term on a keyword field, and non-scoring conditions belong in a filter clause so they are cached and do not pollute relevance:

export async function searchInCategory(searchTerm: string, category: string) {
  const result = await es.search<Product>({
    index: 'products',
    query: {
      bool: {
        must:   [{ match: { name: searchTerm } }],        // scores relevance
        filter: [
          { term: { category } },                          // exact, cached, no score
          { term: { in_stock: true } },
        ],
      },
    },
    sort: [{ price: 'asc' }],
    size: 20,
  });

  return result.hits.hits.map(hit => ({
    id: hit._id,
    ...hit._source!,
  }));
}

The structure to internalise: must for things that should affect relevance score, filter for yes-or-no conditions that should not. Filters are cached by Elasticsearch and are faster, so anything that is a hard constraint (a category, a stock flag, a date range) goes in filter, not must.

Pagination and aggregations

For shallow pagination, from and size are fine. For deep pagination (thousands of results in), from/size becomes expensive because Elasticsearch must rank everything up to the offset on every shard. Use search_after instead.

// Shallow pagination
export async function listProducts(page: number, pageSize = 20) {
  const result = await es.search<Product>({
    index: 'products',
    query: { match_all: {} },
    from: page * pageSize,
    size: pageSize,
    sort: [{ created_at: 'desc' }],
  });
  return result.hits.hits.map(h => ({ id: h._id, ...h._source! }));
}

Aggregations compute facets and stats over matching documents. This is why keyword mappings matter: you can only aggregate cleanly on un-analysed fields.

export async function categoryFacets(searchTerm: string) {
  const result = await es.search<Product>({
    index: 'products',
    query: { match: { name: searchTerm } },
    size: 0,                                  // we only want the aggregation
    aggs: {
      by_category: {
        terms: { field: 'category', size: 10 },
      },
    },
  });

  // aggregations are alongside hits in the response
  const buckets = (result.aggregations?.by_category as any)?.buckets ?? [];
  return buckets.map((b: any) => ({ category: b.key, count: b.doc_count }));
}

Setting size: 0 means you get the aggregation without paying for document hits you do not need, the right pattern for a pure facet query.

Common Claude Code mistakes with Elasticsearch

Six patterns Claude generates without CLAUDE.md constraints, with the correct replacement for each.

1. Skipping the mapping entirely

Claude generates: an es.index() call into a fresh index with no indices.create() first, letting Elasticsearch infer types.

Correct: create the index with an explicit mapping (keyword for exact fields, text for searchable ones) before indexing anything. Inferred mappings break sorting, filtering, and aggregations, and the only fix is a reindex.

2. Wrapping request params in body

Claude generates: es.search({ index, body: { query: { match_all: {} } } }).

Correct: es.search({ index, query: { match_all: {} } }). The current client takes parameters at the top level; the body wrapper is the deprecated form.

3. Using term on a text field

Claude generates: { term: { name: 'wireless headphones' } } against a text field.

Correct: use match for text fields ({ match: { name: 'wireless headphones' } }), and reserve term for keyword fields. term does not analyse, so it will not match the tokenised text index and silently returns nothing.

4. Looping es.index() for batches

Claude generates: a for loop calling es.index() per document.

Correct: es.helpers.bulk({ datasource, onDocument }). The bulk helper is far faster, handles back-pressure, and reports per-item errors instead of failing slowly one call at a time.

5. Putting hard filters in must

Claude generates: a bool.must array containing both the relevance match and exact constraints like category and stock.

Correct: keep the relevance match in must and move exact constraints into filter. Filter clauses are cached and do not affect the score, which is both faster and semantically correct.

6. Deep pagination with from/size

Claude generates: from: 50000, size: 20 for deep paging.

Correct: use search_after with a sort key for deep pagination. Large from values force Elasticsearch to rank everything up to the offset on every shard, which degrades badly at scale.

Add all six pairs to CLAUDE.md as a common-mistakes section. Claude benefits from the before-and-after contrast because it can match the pattern it would otherwise generate to the correct form.

Building search that holds up

The Elasticsearch CLAUDE.md in this guide produces integrations where mappings are defined explicitly before any indexing, request parameters use the current top-level shape, match and term are applied to the correct field types, bulk indexing replaces per-document loops, hard constraints live in cached filter clauses, and deep pagination uses search_after instead of large offsets.

The underlying principle is the same for any infrastructure integration with Claude Code: the framework has a current, correct surface, and Claude's training data contains older shapes plus convenient shortcuts (like skipping the mapping) that compile but corrupt the result. The CLAUDE.md template fills that gap, so the search code Claude generates is production-shaped from the first index. For the relational store that usually sits beside it, Claude Code with PostgreSQL covers the primary datastore, and Claude Code with Docker covers running the cluster locally.

Get Claudify. A complete Claude Code operating system with project memory and infrastructure patterns configured so search integrations work on the first index.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame