<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title><![CDATA[Integrations - Elasticsearch Labs]]></title>
    <description><![CDATA[Articles and tutorials from the Search team at Elastic]]></description>
    <copyright><![CDATA[© 2026. Elasticsearch B.V. All Rights Reserved]]></copyright>
    <image>
      <title><![CDATA[Integrations - Elasticsearch Labs]]></title>
      <url>https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1121c0bf0e8a6e65/6a88da6340a1841030ef456f/search-labs-thumbnail.png</url>
      <link>https://www.elastic.co/search-labs/blog/category/integrations</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/integrations</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/integrations.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Thu, 24 Sep 2026 21:09:27 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Let the big model think, let the small model work: Splitting LLM costs in Elastic Workflows]]></title>
    <description><![CDATA[Build an Elastic workflow that sends a data sample to a large model to propose classification labels. A human signs off, then a smaller model applies them across the full corpus.]]></description>
    <content:encoded><![CDATA[<p>Split the expensive part of large language model (LLM) classification from the cheap part. This article builds an <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic workflow</a> where Claude Sonnet reads a stratified sample of NASA pilot incident reports and proposes classification labels based on what it finds. A human reviews the schema and signs off, and then <a href="https://mistral.ai/news/mistral-small-3-1/">Mistral Small 3.1</a> applies the labels across the full corpus. The routing is YAML, the results land in Elasticsearch as structured data, and the pattern works wherever you have free text that needs labeling.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0ceb64be1f3ad4e5/6a87ef8373f743fa6848688d/image2.png" alt="Example NASA ASRS pilot incident report showing a free-text narrative describing a near-miss at an uncontrolled airfield, the type of document classified by the LLM pipeline" /><p><a href="https://asrs.arc.nasa.gov/">NASA Aviation Safety Reporting System (ASRS)</a> reports describe unusual events during flights, such as missed altitudes, confusing clearances, runway issues, or mechanical problems. Each report already has an official category, like altitude deviation, course deviation, or ground encounter. In this article, we ask a different question: <em>What does this report reveal about the pilot who wrote it?</em> The idea is to ask a model to infer a schema grounded on the data to classify the report based on criteria that help us figure out information about the report writers. Then ask a second model to apply the labels.</p><p><em><strong>You can find the full workflow definitions and helper scripts </strong></em><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/larger-llms-task-planning-smaller-llms-execution"><em><strong>here</strong></em></a><em><strong>.</strong></em></p><h2>What you need to run this LLM pipeline</h2><ul><li><p>Elastic Stack 9.4+ or Elastic Cloud Serverless. Elastic Workflows has been generally available (GA) since 9.4.</p></li><li><p>Elastic Agent Builder enabled in your deployment.</p></li><li><p>A <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector">Kibana generative AI (GenAI) connector</a> pointing at Claude Sonnet (or an equivalent reasoning model). This is the planner.</p></li><li><p>A <a href="https://console.mistral.ai/api-keys">Mistral API key</a>. We’ll use it to register an Elasticsearch inference endpoint.</p></li><li><p>Python 3.10+ with <code>elasticsearch&gt;=9.0</code> and <code>pandas</code>. Used by the dataset loader.</p></li></ul><h2>How two-tier LLM orchestration works</h2><p>The workflow has two jobs: Decide what labels should exist, and then apply those labels to every report.</p><p><strong>The first job is open-ended.</strong> A large model reads a varied sample of reports and proposes a small schema of categorical fields. A field is one way to describe the writer, such as <code>attribution_style</code> or <code>procedure_orientation</code>. Each field has a few allowed values, such as <code>self_critical</code>, <code>system_attributing</code>, or <code>balanced</code>.</p><p><strong>The second job is repeatable.</strong> After a human approves the schema, a smaller model reads each report and chooses one value for each field.</p><p>We use Elastic Workflows because the steps are known ahead of time: sample reports, propose labels, wait for approval, classify every document, and store the results. Writing those steps in YAML makes the process reproducible, observable, and cheaper to rerun.</p><h3><strong>Why split LLM work across two model tiers?</strong></h3><p>A small model could handle classification, but schema discovery is a different shape of problem. It requires reading a diverse sample, spotting latent patterns, and proposing complex structures. In practice, smaller models over-anchor on surface keywords and produce redundant or nonexclusive fields.</p><p>Classification is simpler, the schema exists, the values are enumerated, and the task is to pick one per field. A smaller model handles this reliably and at a fraction of the cost, since it runs once per document across the entire corpus.</p><p><em>Large</em> and <em>small</em> here mean reasoning capability. In this article, Claude Sonnet plays the planner and Mistral Small 3.1 plays the executor.</p><h2>Classifying NASA pilot reports with a two-tier LLM pipeline</h2><p>We’ll use the NASA ASRS database, which collects voluntary, anonymous incident reports from pilots, controllers, and mechanics. The dataset is public, and the reports are written as free-text narratives.</p><p>What we want to ask is:</p><p><em>What does this report reveal about the pilot who wrote it?</em></p><p>The planner reads a varied sample of reports and decides which distinctions are meaningful based on how the reports are actually written.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b7f0b9d35ef59e3/6a87efa5b6895193cca26e8e/image4.png" alt="Elastic Workflow pipeline diagram showing schema discovery by a large language model, human approval via waitForInput, and classification by a small language model storing results in Elasticsearch" /><p><strong>Step</strong></p><p><strong>Role</strong></p><p><strong>Model tier</strong></p><p><code>sample</code></p><p>Pull a diverse subset of reports from the corpus.</p><p>(no LLM)</p><p><code>discover</code></p><p>Read the question and the sample, propose a schema of fields with enum values.</p><p><strong>Large</strong></p><p><code>approve</code></p><p>Human reviews the proposed schema and approves or edits it.</p><p>(Human via <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/wait-for-input"><code>waitForInput</code></a>)</p><p><code>apply</code></p><p>Iterate over the corpus, assign one value per field to each report.</p><p><strong>Small</strong></p><p><code>store</code></p><p>Write the schema and the per-document field values to Elasticsearch.</p><p>(No LLM)</p><h2>Registering Mistral and Claude as Elasticsearch inference endpoints</h2><p>The small model will be registered as an Elasticsearch <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/infer-service-mistral.html">inference endpoint</a> using the native <code>mistral</code> service integration. </p>INFERENCE_ID = "mistral-small-extractor"

es.inference.put(
    task_type="chat_completion",
    inference_id=INFERENCE_ID,
    inference_config={
        "service": "mistral",
        "service_settings": {
            "api_key": MISTRAL_API_KEY,
            "model": "mistral-small-latest",
            # 6 RPM is conservative for the Mistral free tier to avoid 429s.
            "rate_limit": {"requests_per_minute": 6},
        },
    },
)<p>The alias <code>mistral-small-latest</code> resolves to <a href="https://mistral.ai/news/mistral-small-3-1">Mistral Small 3.1</a>. It has a 128k context window and supports JSON-mode output.</p><p>The large model will be an <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector">AI connector</a> pointing at Claude Sonnet. The Agent Builder UI walks you through creating the connector. Take a note of the connector ID since we’ll reference it from the workflow.</p><h2>Indexing NASA ASRS incident reports into Elasticsearch</h2><p>The ASRS dataset is indexed with keyword mappings for aggregation fields and text mappings for the narratives the models will read.</p><p>Download the ASRS CSV (the database publishes quarterly extracts at the <a href="https://asrs.arc.nasa.gov/search/database.html">ASRS Database Online</a> page), and index it. The mappings are:</p>{
  "properties": {
    "acn":          { "type": "keyword" },
    "flight_phase": { "type": "keyword" },
    "anomaly":      { "type": "keyword" },
    "synopsis":     { "type": "text" },
    "narrative":    { "type": "text" }
  }
}<p>The mapping types follow how each field is used. <code>flight_phase</code> and <code>anomaly</code> are mapped as <code>keyword</code> because we’ll run terms aggregations on them to build the sample, and aggregations need exact, non-analyzed values. <code>narrative</code> and <code>synopsis</code> are mapped as <code>text</code> because they hold free-form prose that the models will read. The companion notebook has the full loader script that reads the CSV and bulk-indexes the documents.</p><h2>Building a stratified sample for the planning LLM</h2><p>The YAML snippets in this and the following sections are steps of the workflow definition that the notebook registers via the Workflows API. The first two steps generate a representative sample: They aggregate by flight phase and by anomaly and pull a few documents per bucket with <code>top_hits</code>.</p>- name: by_phase
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 0
      aggs:
        per_phase:
          terms:
            field: flight_phase
            size: 8
          aggs:
            sampled_docs:
              top_hits:
                size: 5
                _source: ["acn", "synopsis", "narrative"]

- name: by_anomaly
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 0
      aggs:
        per_anomaly:
          terms:
            field: anomaly
            size: 8
          aggs:
            sampled_docs:
              top_hits:
                size: 3
                _source: ["acn", "synopsis", "narrative"]<h2>How the large LLM discovers a classification schema from the data</h2><p>The prompt needs both the question and the sample. A question alone may produce generic labels disconnected from the corpus, and a sample alone produces descriptive clusters that ignore the angle of the question. </p><p>When both are present and the output is structured, the model produces labels that are grounded in the data and oriented to the task: a schema of categorical fields, each with two to four mutually exclusive value options backed by evidence from the sample.</p><p>Here’s the planner step from the workflow:</p>- name: discover
  type: ai.prompt
  connector-id: "claude-sonnet"
  with:
    systemPrompt: |
      You design categorical schemas for use by downstream classifiers. A
      schema is a small set of fields, each with a few mutually exclusive
      values. Every value you propose must be grounded in evidence from the
      provided sample and must serve the stated question. You do not invent
      values that are not supported by at least two documents in the sample.
      You do not propose fields that a reasonable analyst could have written
      without reading the documents.
    prompt: |
      Question:
      ${{ inputs.goal }}

      Sample documents stratified by flight phase:
      ${{ steps.by_phase.output.aggregations.per_phase.buckets | json }}

      Sample documents stratified by anomaly type:
      ${{ steps.by_anomaly.output.aggregations.per_anomaly.buckets | json }}

      Propose between 2 and 4 categorical fields that:
      - serve the question (you can explain how)
      - depend on patterns visible in the sample (you can cite document IDs)
      - would not be obvious to someone who has not read the sample

      For each field, return: name (snake_case), definition, why_useful,
      and values (2 to 4 mutually exclusive options).

      For each value, return: value (snake_case) and definition.
    schema:
      type: object
      properties:
        fields:
          type: array
          minItems: 2
          maxItems: 4
          items:
            type: object
            required: [name, definition, why_useful, values]
            properties:
              name: { type: string }
              definition: { type: string }
              why_useful: { type: string }
              values:
                type: array
                minItems: 2
                maxItems: 4
                items:
                  type: object
                  required: [value, definition]
                  properties:
                    value: { type: string }
                    definition: { type: string }
    temperature: 0.3<p>The structured output schema enforces the shape of the response:</p><p> </p><ul><li><p><code>name</code>: Identifier for the categorical field.</p></li><li><p><code>definition</code>: What this field measures, in one sentence.</p></li><li><p><code>why_useful</code>: How this field serves the question; this also helps the downstream classifier understand the intent.</p></li><li><p><code>values</code>: Two to four mutually exclusive options. Each has a <code>value</code> and a <code>definition</code>.</p></li></ul><p>Here’s an example of the produced schema. We can see how the writer is being classified and the reasons why the model decided to create the category. <code>definition</code>and <code>why_useful</code> fields are used by the second model to classify the documents.</p>{
  "fields": [
    {
      "name": "attribution_style",
      "definition": "How the reporter frames responsibility for what happened.",
      "why_useful": "Surfaces reporting culture independent of the technical event. Useful for training and safety-management programmes that want to distinguish reporter style from incident type.",
      "values": [
        {
          "value": "self_critical",
          "definition": "Assigns the cause primarily to their own action, even when external factors clearly contributed."
        },
        {
          "value": "system_attributing",
          "definition": "Frames the cause as external: ATC, equipment, weather, or organisational factors."
        },
        {
          "value": "balanced",
          "definition": "Distributes responsibility across self and system without emphasising either."
        }
       ]
    },
    {
      "name": "procedure_orientation",
      "definition": "How the reporter relates to written procedure.",
      "why_useful": "Distinguishes pilots who frame events through SOPs from those who frame them through personal judgment.",
      "values": [
        // procedure_first, experience_first (same structure as above)
      ]
    }
  ]
}<h2>Human-in-the-loop schema approval with waitForInput</h2><p>The proposed schema is now passed to a person for approval. Elastic Workflows has a <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/wait-for-input"><code>waitForInput</code></a> step that pauses the workflow with a schema, exposes a form, and resumes when the input is submitted.</p><p><code>waitForInput</code> has no timeout of its own, so if nobody responds, the execution <a href="https://www.elastic.co/docs/explore-analyze/workflows/authoring-techniques/human-in-the-loop#what-happens-while-the-workflow-is-paused">waits indefinitely</a>. To put a limit on that, set a workflow-level <code>settings.timeout</code>; if it elapses before the reviewer submits the form, the execution is canceled.</p>- name: human_gate
  type: waitForInput
  with:
    message: "Review and edit the proposed schema. The approved fields will be applied across the full corpus."
    schema:
      type: object
      required: [approved_fields]
      properties:
        approved_fields:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              definition: { type: string }
              values:
                type: array
                items:
                  type: object
                  properties:
                    value: { type: string }
        notes:
          type: string<p>When the workflow reaches this step, the execution pauses and the Kibana UI shows an "Action is required" badge. Clicking <strong>Provide action</strong> opens a form where the reviewer can paste or edit the schema JSON. Since <code>waitForInput</code> cannot be prepopulated from a previous step, the code polls the <em>discover</em> step output and prints a paste-ready JSON block that can be copied directly into this form.</p>discover = step_output(execution_id, "discover")  # polls until the step completes

# Strip <code>why_useful</code> (not part of the human_gate form) and wrap in the shape
# expected by the waitForInput form so this is paste-ready.
approved_fields = [
    {
        "name": field["name"],
        "definition": field["definition"],
        "values": [
            {"value": v["value"], "definition": v["definition"]}
            for v in field["values"]
        ],
    }
    for field in discover["content"]["fields"]
]

print(json.dumps({"approved_fields": approved_fields, "notes": ""}, indent=2))<p>The <code>step_output</code> helper (in the notebook) polls the execution via <code>GET /api/workflows/executions/{id}</code> until the <em>discover</em> step completes and then returns its output.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf3771a69b51089e7/6a87efeca8b3236eb5cc01f5/image1.png" alt="Kibana execution view showing an Elastic Workflow paused at the waitForInput step with the Provide action button highlighted for human-in-the-loop schema approval" /><p>Code JSON output pasted on Kibana:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt85d91c93043ad0d1/6a87f00d6ea6da5cfe0083e0/image5.png" alt="Kibana Provide action modal displaying the approved classification schema JSON with pilot experience level fields, where a reviewer edits the schema before the workflow resumes" /><p>The reviewer can keep useful fields, rewrite unclear ones, merge overlapping values, and add notes. After approval, the workflow resumes and sends the final schema to the executor step.</p><p>For a new corpus, keep this human gate in place. Once the schema is stable, you can auto-approve and only fall back to review when it’s worth it: Route just the low-confidence extractions to a person, or compare a new discovery run against the schema stored in the <code>schemas</code> index and trigger review only when fields or values change beyond a threshold.</p><h2>Classifying the full corpus with a smaller LLM</h2><p>By the time the workflow reaches this step, the open-ended part of the job is over. From here, the small model takes over and classifies each report against the approved schema.</p>- name: fetch_corpus
  type: elasticsearch.request
  with:
    method: POST
    path: "/incident_reports/_search"
    body:
      size: 100
      _source: ["acn", "narrative"]
      query:
        match_all: {}

- name: classify_all
  type: foreach
  foreach: "${{ steps.fetch_corpus.output.hits.hits }}"
  iteration-on-failure:
    retry:
      max-attempts: 5
      delay: "3s"
    fallback:
      - name: notify_failure
        type: slack_api.postMessage
        connector-id: "team-alerts"
        with:
          channelNames:
            - "#pipeline-alerts"
          text: "Classification failed for ACN ${{ foreach.item._source.acn }} after all retries."
    continue: true
  steps:
    - name: classify
      type: ai.agent
      inference-id: "mistral-small-extractor"
      timeout: "120s"
      with:
        message: |
          You will classify the following report against a fixed schema.
          For each field in the schema, assign exactly one of its value
          options, or null if none of the values clearly applies. Include
          the short quote that supports the assignment and a confidence
          score between 0 and 1. Set review_required to true if any field
          returned null or any confidence is below 0.5.

          Schema:
          ${{ steps.human_gate.output.approved_fields | json }}

          Report:
          ${{ foreach.item._source.narrative }}
        schema:
          type: object
          properties:
            field_values:
              type: object
              additionalProperties: true
            review_required: { type: boolean }
    - name: write_extraction
      type: elasticsearch.index
      with:
        index: extractions
        document:
          acn: "${{ foreach.item._source.acn }}"
          field_values: "${{ steps.classify.output.structured_output.field_values }}"
          review_required: "${{ steps.classify.output.structured_output.review_required }}"<p><em>Note: The classification step uses </em><em><code>ai.agent</code></em><em> instead of </em><em><code>ai.prompt</code></em><em> because </em><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/ai-steps#step-types"><em><code>ai.agent</code></em><em> accepts an </em><em><code>inference-id</code></em></a><em>, which lets it call the Elasticsearch </em><em><code>_inference</code></em><em> endpoint directly, while </em><em><code>ai.prompt</code></em><em> only accepts a </em><em><code>connector-id</code></em><em>.</em></p><p>The <code>fetch_corpus</code> step is the third <code>elasticsearch.request</code> in the workflow, so it’s worth saying why we read from the index again. The first two (<code>by_phase</code> and <code>by_anomaly</code>) only pulled a small stratified sample for the planner to reason over, not the data to label. Now that the schema is approved, <code>fetch_corpus</code> pulls the documents we actually want to classify. We cap it at 100 with <code>match_all</code> to keep the demo fast; this is where you would page through the full corpus.</p><p>For every field, it returns a value (or null), a confidence, and a short quote. Setting <code>additionalProperties: true</code> in the JSON schema lets the step return one entry per field without the workflow having to know the field names ahead of time. A stored extraction looks like this:</p>{
  "acn": "2238341",
  "field_values": {
    "attribution_style": {
      "value": "self_critical",
      "confidence": 0.82,
      "quote": "I should have caught the altitude bust earlier"
    },
    "procedure_orientation": {
      "value": "procedure_first",
      "confidence": 0.44,
      "quote": "we ran the QRH before doing anything else"
    }
  },
  "review_required": true
}<p>Here, <code>review_required</code> is <code>true</code> because <code>procedure_orientation</code> came back at <code>0.44</code> confidence, below our <code>0.5</code> threshold, which is the signal a confidence-based quality gate would act on.</p><p>The <code>fetch_corpus</code> step pulls the documents to classify. The <a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/foreach"><code>foreach</code></a>step iterates over them sequentially, and <code>iteration-on-failure</code> handles the errors: <code>retry</code> covers transient API errors from the inference endpoint, and, if all attempts fail, the <code>fallback</code> step posts to <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/slack-action-type#slack-workflow-examples">Slack</a> so the failure doesn’t pass silently. (An <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/email-action-type">email</a> connector works the same way.) <code>continue: true</code> then lets the loop move on to the next document instead of failing the whole run. </p><p><em>For production-scale corpora, consider using </em><a href="https://www.elastic.co/docs/explore-analyze/workflows/steps/composition#workflow-executeasync"><em><code>executeAsync</code></em></a><em>, which is the fan-out version of execute.</em></p><h2>Writing schemas and extractions back to Elasticsearch</h2><p>The workflow produces two things: the approved schema and the per-document field values. The <code>store_schema</code> step runs right after the human gate, before the classification step fans out:</p>- name: store_schema
  type: elasticsearch.index
  with:
    index: schemas
    document:
      question: "${{ inputs.goal }}"
      approved_fields: "${{ steps.human_gate.output.approved_fields }}"
      reviewer_notes: "${{ steps.human_gate.output.notes }}"<p>Each extraction is written inside the <code>foreach</code> loop, so results are persisted as they’re produced rather than batched at the end.</p><p>The <code>schemas</code> index holds one document per discovery run (question, approved fields, reviewer notes). The <code>extractions</code> index holds one document per report per schema version. </p><h2>What this two-tier LLM orchestration pattern gives you</h2><p>We built one Elastic workflow that pulls a stratified sample from an incident report index, sends it with a question to a large reasoning model to generate a classification schema based on the data and a user-defined angle, pauses for human approval, and then iterates over the full corpus with a small Mistral model that assigns one value per field. </p><p>The approved schema and per-document field values are written back to Elasticsearch as structured data.</p><p>The point of the exercise is that two different shapes of work, schema discovery, and schema application can use two different model tiers and that a workflow lets you write the routing decision down.</p><h2>Next steps for your own LLM pipeline</h2><ul><li><p>Try it on a corpus of your own. The pattern doesn’t care whether the input is incident reports, customer feedback, weekly status updates, or property listings.</p></li><li><p>Promote the <code>foreach</code> step to <code>workflow.executeAsync</code> once you’re comfortable for parallel fan-out at scale.</p></li><li><p>Schedule the rediscovery workflow on a cron trigger so you can discover different schema variations based on the data that comes in.</p></li><li><p>Read the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows documentation</a> for the full step catalog.</p></li></ul><h3><strong>Related reading</strong></h3><ul><li><p><a href="https://www.elastic.co/search-labs/blog/build-ai-agents-elastic-inference-service">Build AI agents with Elastic Inference Service</a> (EIS) covers the broader multi-model wiring pattern via EIS, complementary to the Workflows-orchestrated split shown here.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">How to build AI agentic workflows with Elasticsearch</a> is a higher-level survey of how Agent Builder and Workflows fit together.</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/langextract-elasticsearch-tutorial-usage-example">LangExtract and Elasticsearch tutorial</a> explores a different extraction pattern using a hand-authored schema; useful for contrast with the discover-then-apply approach above.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/llm-orchestration-elastic-workflows</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-orchestration-elastic-workflows</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdf42ed268fb6f953/6a87ef5c386ac3fab0adf4e2/image3.png" length="0" type="image/png"/>
    <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Skip the stateful OTel Collector: Elasticsearch 9.5 natively stores both metric temporalities]]></title>
    <description><![CDATA[Ingest cumulative and delta OpenTelemetry metrics under the same metric name while ES|QL and PromQL queries auto-detect temporality per series, with no new syntax or conversion pipelines required.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch 9.5 natively stores both cumulative and delta OpenTelemetry (OTel) counters and histograms, even when mixed for the same metric name. You ingest via OpenTelemetry Protocol (OTLP) and Elasticsearch preserves the temporality metadata automatically.<a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/ts"> ES|QL TS</a> and<a href="https://www.elastic.co/docs/reference/query-languages/promql"> PromQL</a> queries detect the temporality per series and interpret the data correctly, without new syntax, configuration changes to your OTel SDKs or stateful OTel Collector conversion. Existing queries and downsampled data continue to work as expected.</p><h2>What is metric temporality in OpenTelemetry?</h2><p>Metrics stores usually receive client-side, pre-aggregated metrics. For example, if an application records request response times, it won’t send each individual response time as a single data point to your metrics back end. Instead, the application (or rather the OTel SDK) pre-aggregates those raw response times into counters or histograms. These pre-aggregated values are then exported at a periodic interval, dramatically reducing the number of data points. <em>Temporality</em> is about how this pre-aggregation works. There are two temporality models: <em>cumulative</em> and <em>delta</em>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1829137fe767c17d/6a7edfc40da673bd3357cae8/image6.png" alt="Diagram showing how delta and cumulative temporality represent the same OTel counter metric data points differently" /><h3>Cumulative temporality in OTel metrics</h3><p>With <em>cumulative temporality</em>, each data point represents the totalamount of change in the metric value since the process started. Values monotonically increase, with occasional reset to 0 (for example, when the process restarts).</p><p>Take a counter tracking the total CPU time consumed by a Java Virtual Machine (JVM):</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>12.4s</p><p>12.4s total CPU time since start</p><p>10:02</p><p>13.1s</p><p>13.1s total CPU time since start</p><p>10:03</p><p>13.9s</p><p>13.9s total CPU time since start</p><p>To compute the rate of change between 10:01 and 10:02, we subtract: <code>13.1 - 12.4 = 0.7s</code> of CPU time was consumed in that interval. Dividing by the time range of the interval gives us the <code>rate</code>. This is the default temporality for counters in both Prometheus and OTel.</p><h3>Delta temporality in OTel metrics</h3><p>With <em>delta temporality</em>, each data point represents the change since the last measurement. Values are independent of each other. In other words, after each export, the OTel SDK resets all values for all series.</p><p>The same raw observations from the cumulative example above would look as follows with delta temporality.</p><p><strong>Timestamp</strong></p><p><strong>Value</strong></p><p><strong>Meaning</strong></p><p>10:01</p><p>0.5s</p><p>0.5s of CPU time in this interval</p><p>10:02</p><p>0.7s</p><p>0.7s of CPU time in this interval</p><p>10:03</p><p>0.8s</p><p>0.8s of CPU time in this interval</p><p>To compute the rate or increase, we can use the value directly, without any subtraction.</p><h3>Trade-offs between cumulative and delta OpenTelemetry metrics</h3><p>Both temporalities have practical trade-offs:</p><ul><li><p><strong>Resilience to data loss: </strong>Cumulative counters are self-describing: If you miss an export, the next data point still gives you the correct total. Delta values are incremental, so a lost data point means that the corresponding increase is lost.</p></li><li><p><strong>Metric producer memory footprint: </strong>For cumulative temporality, the OTel SDKs need to keep a state for every series in memory. For delta temporality, the footprint is much lower. There, the SDKs only need to keep track of counters or histograms which changed since the last export. If there are a lot of counters or histograms and many of them don’t increase each period, this difference can be quite substantial.</p></li><li><p><strong>Aggregation across restarts: </strong>Cumulative counters require reset detection logic, which in edge cases can fail: If the metric value decreases, it’s detected as a reset. We assume that the application was restarted and the counter started from 0 again. This can be missed if the first reported counter value after the restart is higher than before the restart. A concrete example:</p></li><ul><li><p>The service consumes 1 second CPU time and restarts.</p></li><li><p>After the restart, the service performs a CPU-intensive task and consumes 2 seconds of CPU time before the metric is exported again.</p></li><li><p>The metric back end just sees 1 followed by 2 as the metric value. It never observes a decrease and therefore misses the reset.</p></li></ul></ul><p>Delta values don't have this problem since each value is independent.</p><p>If you’re using histograms, the trade-offs have an even bigger effect:</p><p><strong>Trade-off</strong></p><p><strong>Cumulative</strong></p><p><strong>Delta</strong></p><p>Histogram size</p><p>Buckets accumulate across exports, consuming more storage</p><p>Buckets reset each export, producing smaller histograms</p><p>Min/max accuracy</p><p>Approximated from buckets for custom time ranges (tracked values represent extremes since process start)</p><p>Exact per-export minimum and maximum values</p><p>Query performance</p><p>Faster: only the first and last value in a time range plus resets are needed</p><p>Slower: all histograms in the queried range must be combined</p><p>OpenTelemetry supports both models and lets you choose per SDK via the <code>OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE</code> environment variable.</p><h2>Why native temporality support eliminates OTel Collector workarounds</h2><p><a href="https://prometheus.io/docs/concepts/metric_types/#counter">Prometheus</a> and most other metrics back ends pick a side: All metrics have to be either cumulative or delta. Elasticsearch previously followed that pattern, too, with native storage of cumulative counters and delta histograms, and workarounds for everything else. Delta counters were stored as gauges, functional but without native counter semantics for rate queries. And cumulative histograms were unsupported.</p><p>One workaround for unsupported temporalities is to configure your metric producers (for example, <a href="https://opentelemetry.io/docs/languages/">OTel SDKs</a>) to produce data with the temporality that your back end supports. In large-scale deployments, this can be a very challenging task. And sometimes this isn’t even possible (for example, if you consume OTLP metrics from third-party services).</p><p>Another workaround is to convert the temporality prior to ingestion. In the OTel Collector, you would typically use the <a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/cumulativetodeltaprocessor/README.md">cumulative-to-delta processor</a>, which comes with a big warning sign about <em>statefulness</em>. The conversion is inherently stateful, requiring ordered delivery of metric series to the same collector and persisted state across restarts. In practice, it works, but at scale, it comes with a lot of deployment headaches.</p><p>With Elasticsearch 9.5, you can skip the conversion pipeline entirely. Elasticsearch natively stores and queries metric data with both temporalities. It doesn’t require any stateful conversion required or explicit configuration of your OTel SDKs.</p><h2>Demo: ingesting cumulative and delta OTel metrics side by side</h2><p>To demonstrate the temporality support, we'll reuse a demo setup from our <a href="https://www.elastic.co/search-labs/blog/otel-histogram-metrics-esql">OTel histogram metrics ES|QL blog post</a>: a Java <a href="https://github.com/renaissance-benchmarks/renaissance">Renaissance</a> benchmark instrumented with the <a href="https://opentelemetry.io/docs/zero-code/java/agent/">OTel Java agent</a>. The twist this time: We run two instances of the benchmark, each configured with a different temporality:</p><ul><li><p><strong><code>renaissance-delta</code></strong><strong>: </strong>Exports metrics with delta temporality.</p></li><li><p><strong><code>renaissance-cumulative</code></strong><strong>: </strong>Exports metrics with cumulative temporality.</p></li></ul><p>Both instances report the same metrics under the same service name <code>renaissance</code>, but with different <code>service.instance.id</code> values. Here’s the relevant section of the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-temporality-demo/docker-compose.yml">docker-compose.yml</a> that can be found in <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/elasticsearch-temporality-demo">the companion code</a>:</p>renaissance-delta:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=delta-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: delta
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM

renaissance-cumulative:
  environment:
    OTEL_SERVICE_NAME: renaissance
    OTEL_RESOURCE_ATTRIBUTES: "service.instance.id=cumulative-instance"
    OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: cumulative
    OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION: BASE2_EXPONENTIAL_BUCKET_HISTOGRAM<p>To run the demo yourself, you'll also have to fill out the <a href="https://www.elastic.co/docs/reference/opentelemetry/managed-inputs/managed-otlp-endpoint">managed OTLP endpoint URL</a> and the corresponding API key:</p>OTEL_EXPORTER_OTLP_ENDPOINT: https://&lt;cluster-endpoint&gt;
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=ApiKey &lt;base64 api key&gt;"<p>After starting the demo with <code>docker compose up --build</code>, both instances will start reporting metrics to Elasticsearch.</p><h3>Querying OTel counter metrics with ES|QL and PromQL</h3><p>Let's query the first few raw data points of <code>jvm.cpu.time</code> for both instances to see the different temporalities in action:</p><p>This gives us the first five data points for each service instance:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte236c69ee14c534f/6a7ee18f2888399d7307f09f/image2.png" alt="ES|QL query results showing raw cumulative and delta OTel metrics for jvm.cpu.time from two service instances" /><p>The benchmark consumes CPU at a nearly constant rate. This is directly visible based on the delta temporality data: The values are nearly constant between exports. In contrast, the cumulative temporality values grow over time, as they represent the total CPU usage of the benchmark instance.</p><p>Now let's have a look at how to properly query this metric using PromQL:</p>PROMQL sum by (service.instance.id) (rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf7ed76cee486794e/6a7ee0250e035cf4d8b865a2/image4.png" alt="PromQL rate query showing CPU time per service instance with cumulative and delta OTel metrics overlaid" /><p>The screenshot shows that both benchmark instances consume a nearly constant of 1 to 1.2 number of CPU cores with some variance. This query works because we made our <code>rate</code> implementation respect the temporality: Every time series (so every service instance in our case) stores the temporality as a metric dimension. The <code>rate</code> implementation looks at this dimension and interprets the data accordingly: For delta temporality, values are summed up; for cumulative temporality, a difference computation is done. This all happens automatically in the background, without requiring any changes to your queries.</p><p>We’ve adapted <code>rate</code>, <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#increase"><code>increase</code></a>, and <a href="https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/time-series-aggregation-functions#irate"><code>irate</code></a> to work this way. The same applies when using those functions in ES|QL TS queries:</p><p>Because Elasticsearch tracks the temporality as a dimension, you can have multiple series with different temporalities for the same metric, just like in the demo use case. Aggregating across series also works as expected, because at that point <code>rate</code>, <code>increase</code>, or <code>irate</code> already took care of normalizing the data:</p>PROMQL sum(rate(jvm.cpu.time))<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6b8eb2e5ee6199bc/6a7ee06a28883935c907f097/image3.png" alt="PromQL chart showing total CPU time aggregated across both cumulative and delta OTel metrics instances" /><h3>Querying OTel histogram metrics across temporalities</h3><p>Metric temporality applies to histograms in the same way it applies to counters: histogram buckets are effectively a set of counters, each tracking values in a specific range.As in our histogram demo, we use exponential histograms, where bucket boundaries adapt automatically to minimize relative error.</p><p>Due to this similarity, histograms can also be cumulative or delta. Either the counter per bucket is reset after each metric export or the cumulative count carries over between exports.</p><p>Let's query the median major garbage collection (GC) duration for our benchmark instances, which is a histogram metric:</p>PROMQL histogram_quantile(0.5,  sum by (service.instance.id) (increase(jvm.gc.duration{jvm.gc.action=~".*major.*"})))<p>Or the equivalent ES|QL query:</p><p></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb02046819944c5ff/6a7ee0f6dcb4372a2b2d1232/image5.png" alt="Median major GC duration queried across cumulative and delta OpenTelemetry histogram metrics per instance" /><p>Again, both queries will automatically load the temporality per series and interpret the histograms accordingly. In PromQL, this is handled by the <code>increase</code> function. Note that in ES|QL, you don't explicitly call <code>increase</code> on histograms. The <code>TS</code> command automatically handles the temporality-aware merging of histograms when you use aggregation functions, like <code>PERCENTILE</code>, <code>MEDIAN</code>, or <code>AVG</code>.</p><h2>How Elasticsearch stores metric temporality in TSDB</h2><p>Elasticsearch's time series database (TSDB) stores metric temporality in a dedicated dimension field on each document. The <a href="https://www.elastic.co/docs/reference/elasticsearch/index-settings/time-series#index-time-series-temporality-field"><code>index.time_series.temporality_field</code></a> index setting lets you specify which field carries the temporality information. The field must be a <code>keyword</code> field with <code>time_series_dimension: true</code> and the permissible values <code>"delta"</code> or <code>"cumulative"</code>.</p><p>As soon as this setting is present on a time series index, ES|QL and PromQL will load the corresponding field when performing temporality-dependent aggregations. If the field isn’t present or has no value on a document, we fall back to defaults based on the type of the corresponding metric: counters default to cumulative, and histograms default to delta. This matches the historical behavior and ensures existing queries and existing data continue to work without changes.</p><p>When you ingest metrics via the OTLP endpoint, Elasticsearch automatically adds a <code>temporality</code> dimension field to each document, populated from the <a href="https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality">OTLP AggregationTemporality</a> metadata. For custom (neither OTLP nor <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/tsds-ingest-prometheus-remote-write">Prometheus remote write</a>) ingestion, you’ll have to manually set up the <code>index.time_series.temporality_field</code> setting and populate your temporality dimension.</p><p>The temporality is also respected during downsampling: As it’s a dimension, it’s preserved automatically and used to compute the aggregate values.</p><h2>Getting started with mixed-temporality OTel metrics in Elasticsearch</h2><p>With Elasticsearch 9.5, cumulative versus delta is no longer a decision you have to get correct at the start. Ingest both temporalities side by side, even for the same metric name, and let ES|QL and PromQL handle the rest. You can switch between both without having to touch your queries. For more details, see the <a href="https://www.elastic.co/docs/manage-data/data-store/data-streams/metric-temporality">metric temporality documentation</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/otel-metrics-cumulative-delta-elasticsearch</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jonas Kunz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltfebe6e53bb1ad4e4/6a7eded6b591027803eeca82/image1.png" length="0" type="image/png"/>
    <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Kibana Dashboards API: A stable contract for every panel type, tested by 50+ teams before GA]]></title>
    <description><![CDATA[Manage Kibana dashboards as code: Commit to Git, promote across environments, and automate deployments with the Kibana API and Terraform.]]></description>
    <content:encoded><![CDATA[<p>The<a href="https://dashboardsapispec.kibana.dev/dashboards#tag/Dashboards"> Kibana Dashboards and Visualizations APIs</a> are production-ready in Elastic 9.5, available across all subscription tiers, with full backward compatibility. Define your dashboards as JSON, commit them to Git, and then deploy across environments using continuous integration and continuous deployment (CI/CD) pipelines,<a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard"> Terraform</a>, or whatever tooling you already have. Over 50 teams tested the API during<a href="https://www.elastic.co/search-labs/blog/kibana-dashboards-as-code-terraform-api"> technical preview in 9.4</a>, some already running it in production. Version 9.5 also adds new endpoints (in technical preview) for<a href="https://dashboardsapispec.kibana.dev/tags.html"> Tags</a>, with<a href="https://dashboardsapispec.kibana.dev/markdowns.html"> Markdown</a> and<a href="https://dashboardsapispec.kibana.dev/links.html#tag/Links"> Links</a> panel endpoints available now in Elastic Cloud Serverless and landing in 9.6.</p><h2>What backward compatibility means for the Kibana Dashboards API</h2><p>During technical preview, the API shape could change between releases.[1] That's no longer the case. General availability (GA) means:</p><ul><li><p><strong>Complete backward compatibility.</strong> New fields and panel types will be added over time, but existing fields and behavior remain unchanged. Any future breaking changes would be very carefully considered and would only be introduced in a new major stack version.</p></li><li><p><strong>Production-ready with full support.</strong> The API carries Elastic's full support guarantees. You can safely use it in production environments for automated deployments, environment promotion, and programmatic dashboard management.</p></li></ul><h2>New Kibana API endpoints for Tags, Markdown, and Links panels</h2><p>Elastic 9.5 also introduces a new  standalone endpoint for <a href="https://dashboardsapispec.kibana.dev/tags.html"><strong>Tags</strong></a>, which let you categorize and filter dashboards. Now you can manage them programmatically through dedicated CRUD endpoints, making it easier to organize dashboards at scale across environments.	</p><p>New <a href="https://dashboardsapispec.kibana.dev/markdowns.html"><strong>Markdown</strong></a> and <a href="https://dashboardsapispec.kibana.dev/links.html#tag/Links"><strong>Links</strong></a> panel endpoints are available now in Serverless and will land in the next stack release (9.6).</p><h2>What panel types does the Kibana Dashboards API support?</h2><p>The Dashboards API supports all <em>by-value</em> panels in 9.5 (those defined directly in a dashboard, as opposed to library panels saved for reuse). Every supported panel type has a typed, validated schema.</p><p><strong>Panel type</strong></p><p><strong>Status</strong></p><p>XY charts</p><p>Supported</p><p>Metrics</p><p>Supported</p><p>Pie</p><p>Supported</p><p>Gauge</p><p>Supported</p><p>Heatmap</p><p>Supported</p><p>Data tables</p><p>Supported</p><p>Treemap</p><p>Supported</p><p>Discover sessions</p><p>Supported</p><p>Controls</p><p>Supported</p><p>Markdown</p><p>Supported</p><p>Links</p><p>Supported</p><p>ML panels</p><p>Supported</p><p>Observability panels</p><p>Supported</p><p>Maps</p><p>Coming soon</p><p>Vega</p><p>Coming soon</p><h2>How to manage Kibana dashboards as code</h2><p>The Dashboards API enables a full dashboards-as-code workflow: Export a dashboard as clean, diffable JSON, commit it to Git as the source of truth, review changes in pull requests, and deploy the same definition across development, staging, and production. Once a dashboard is managed as code, treat Git as the single source of truth: Changes made directly in the UI are overwritten the next time you deploy.</p><p>The main challenge when moving a dashboard between spaces, clusters, or stages is that dashboards reference objects like data views and library visualizations by ID. Because these IDs are auto-generated and differ across environments, a dashboard exported from one environment can point at objects that don't exist in another. There are three ways to handle this, listed here from most to least automated:</p><ul><li><p><strong>Use Terraform.</strong> The <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard">Elastic Stack Terraform provider</a> tracks each resource and maps IDs per environment automatically, so references stay consistent as you promote a dashboard from development to production.</p></li><li><p><strong>Define by-value </strong><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql"><strong>Elasticsearch Query Language (ES|QL) panels</strong></a><strong>.</strong> The most portable way to build a panel is to define its visualization with ES|QL directly in the dashboard. An <a href="https://www.elastic.co/docs/explore-analyze/query-filter/languages/esql-kibana">ES|QL</a> query reads from the indices you name in it, so the panel carries no external references to data views or library objects. The result is a fully self-contained, portable dashboard.</p></li><li><p><strong>Assign matching IDs.</strong> If you reference saved objects, like data views or library visualizations, create them with a chosen ID using <code>PUT</code> (upsert) rather than <code>POST</code> (which auto-generates an ID). Use human-readable IDs, like <code>logs-prod</code>, so they're easy to reuse and recognize across environments.</p></li></ul><p>For a detailed walkthrough of these portability patterns and the full dashboards-as-code workflow, see the <a href="https://www.elastic.co/docs/explore-analyze/dashboards/manage-dashboards-as-code#dashboards-as-code-portability">Manage dashboards as code</a> documentation.</p><h3>Create a Kibana dashboard with the Dashboards API using PUT</h3><p>Here's a quick example creating a dashboard with a metric panel using <code>PUT</code> instead of <code>POST</code> to assign a custom ID using the dashboard name (<code>service-health-overview</code>). The same logic works for creating standalone visualizations saved in the library.</p>PUT kbn:/api/dashboards/service-health-overview
{
  "title": "Service health overview",
  "description": "Key service metrics — managed via API",
  "tags": [
    "production",
    "sre-team"
  ],
  "panels": [
    {
      "type": "vis",
      "grid": {
        "x": 0,
        "y": 0,
        "w": 12,
        "h": 8
      },
      "config": {
        "title": "Error rate (5xx)",
        "type": "metric",
        "data_source": {
          "type": "esql",
          "query": "FROM logs-* | WHERE http.response.status_code &gt;= 500 | STATS error_rate=count(*) BY host.name"
        },
        "metrics": [
          {
            "type": "primary",
            "column": "count"
          }
        ]
      }
    }
  ]
}<h2>Kibana Dashboards API roadmap: Maps, Vega, and standalone endpoints</h2><p>We're actively expanding the API surface. Maps and Vega panel support is next, adding typed schemas for them. We're also building standalone CRUD endpoints for Discover sessions (beyond their existing support as dashboard panels), Vega, Maps, and Annotations, decoupled from the dashboard lifecycle.</p><p>For the full schema definitions, visit the <a href="https://dashboardsapispec.kibana.dev/dashboards#tag/Dashboards">Dashboards API documentation</a>. For Terraform users, the <a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/kibana_dashboard">Elastic Stack Terraform provider</a> supports the GA Dashboards API.</p><h2>Note</h2><ol><li><p>The core endpoints are unchanged from the technical preview. If you built integrations against 9.4, they work in 9.5. The only breaking changes are two minor ones affecting dashboard listing and duration unit formats, documented <a href="https://www.elastic.co/docs/release-notes/kibana/breaking-changes">here</a>.</p></li></ol>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/dashboards-as-code-kibana-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/dashboards-as-code-kibana-api</guid>
    <category><![CDATA[Kibana]]></category>
    <category><![CDATA[Developer Experience]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Teresa Alvarez Soler]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8ed7e33de291f255/6a730619c8b7ac02b251f9d3/image1.png" length="0" type="image/png"/>
    <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[One prompt, a complete workflow: Elastic's AI agent writes your automation for you]]></title>
    <description><![CDATA[Elastic Workflows takes a plain-text prompt and generates YAML you can inspect, version and run against your Elasticsearch data. Now GA, with human-in-the-loop workflows in Slack, parallel execution, and 10 new connectors.]]></description>
    <content:encoded><![CDATA[<h2>One prompt, a complete workflow: Elastic's AI agent writes your automation for you</h2><p>Elastic Workflows now writes its own YAML. You type what you want automated in plain language, the Elastic AI Agent generates a complete workflow against a typed schema, and nothing runs until you've read it. YAML is why this works: it gives the model a constrained, well-typed target, so what comes back is actual building blocks you can edit and run.</p><p><strong>What is new in Elastic Workflows 9.5:</strong></p><ul><li><p><strong>Natural language authoring</strong> is GA and on by default: describe an automation, and the Elastic AI Agent writes the workflow, you review and run it.</p></li><li><p><strong>Versioning</strong> with diff and one-click rollback is GA.</p></li><li><p>Three new experimental previews (behind an advanced setting): a visual mode that renders a workflow as a graph, human-in-the-loop steps that reach people in Slack for input or approval, and parallel execution.</p></li><li><p>More to build on: new connectors, event triggers that react to Cases activity, token metering for AI steps, and a queue strategy for concurrency.</p></li></ul><p>Workflows is the automation engine built into the Elastic platform. It reached general availability in 9.4, enabled by default and running against your Elasticsearch data with the connectors and access controls you already have. This post walks through what 9.5 adds.</p><h2>Why YAML makes AI workflow automation work</h2><p>YAML is the authoring language for Elastic Workflows because it's declarative, version-controllable, diffable, and portable across environments. It reads the same in a pull request as it does in the editor.</p><p>It was also a bet. Large language models (LLMs) are very good at generating structured, well-typed content, and a workflow language is close to an ideal target for that. Ask for prose and a model can wander. Ask for a workflow against a typed schema, with named step types and validated inputs, and there is a right shape for the answer.</p><p>In 9.5 that bet pays off, and it is GA. Inside the workflow editor, you write what you want in plain language:</p><p>When a detection alert fires for a host, pull the last 24 hours of related logs, ask the AI step to summarize what happened, and post the summary to the on-call Slack channel.</p><p>The Elastic AI Agent generates the workflow: the trigger, the Elasticsearch query, the AI summarize step, the Slack step, wired together with the right inputs and outputs. You get inspectable, editable YAML back. Nothing runs until you read it, adjust it, and decide to run it. You can also point it at a workflow you already have and describe the change you want, and it edits in place.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt024bee0a140cf88c/6a6a33d32e4eb74ad5d64368/7cd5a718081ce86a246df7fda74d17a2e8b7613f-1999x1124.png" alt="AI workflow builder generating YAML from a natural language prompt in the Elastic Workflows editor with AI Agent panel" /><p>The output is worth reading because there is a rich, well-typed language underneath it. A short prompt expands into real building blocks:</p><ul><li><p><code>foreach</code> and <code>while</code> loops, with guardrails that stop runaway execution.</p></li><li><p><code>switch</code> for clean multi-way branching.</p></li><li><p>data steps like <code>data.filter</code> and <code>data.aggregate</code> for in-flight transforms.</p></li><li><p><code>on-failure</code> handling on every step, so you can retry, continue, or abort.</p></li><li><p><code>workflow.execute</code>, so one workflow can call another and you assemble new automation from pieces you have already tested.</p></li></ul><p>Natural language gets you the first draft fast; the language underneath is what makes that draft real.</p><h2>Workflow versioning with diff and one-click rollback</h2><p>Versioning is GA in 9.5. Every workflow now has version history: every change is tracked and diffable, and you can roll back to any prior version in one click. You see who changed what and when, compare any two versions side by side, and undo a bad edit without reconstructing it by hand. This is the change-control foundation teams asked for before they would run automation against production systems.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9901bbd63fbe380f/6a6a33dc99442c082ddf1d88/63eecf4cdb28702d03752e0dacd99c7ff9b8f285-1920x1080.gif" alt="Toggling between the Elastic Workflows YAML editor and the visual graph view of a workflow's steps and branches" /><p>Versioning pairs with the production controls already in place: granular role-based access control (RBAC) over who creates, edits, runs, and views workflows, every management action written to the security audit log, and import/export that moves workflows between environments with their connector references intact.</p><p>Versioning in the product is the near end of a longer arc. A workflow is a declarative YAML definition, plain text with a well-defined schema, which means it already fits the tooling built for code: it can be diffed, reviewed, and version-controlled. Where we are headed is full, bidirectional integration with the version control systems you already use, so that a workflow could live in your repository, move through review, and deploy the same way the rest of your software does. That is coming, and the same bet that made natural language authoring work, a declarative and well-typed language, is what will let you manage workflows as code.</p><h2>Visual mode, human-in-the-loop workflows, and parallel execution</h2><p>Three of the newest 9.5 additions ship in Experimental. To try them, turn on <strong>Elastic Workflows: Experimental Features</strong> in <strong>Stack Management → Advanced Settings</strong> (it requires a page reload). Here is what each one does.</p><h3>Visual workflow editor: see the logic as a graph</h3><p>You can now switch a workflow between the YAML editor and a visual mode that renders the workflow as a graph. The graph lays out your steps, branches, and flow control, so you can see the logic and the paths a run can take at a glance, alongside the YAML. It is read-only in 9.5: you still author in YAML, and the graph stays in sync as you edit. It is the first step toward a full drag-and-drop builder, which is coming next.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c3f9054539e5440/6a6a33e3c40efb720bd3094c/4f225a895d29df4d5af136167024f1403adfbc27-1920x1080.gif" alt=" Elastic Workflows YAML editor showing a security workflow with foreach loops, conditional logic and on-failure handling" /><h3>Human-in-the-loop workflows with approval steps in Slack</h3><p>Workflows could already pause for a person in 9.4 with <code>waitForInput</code>, which presents a schema-defined form and lets the response drive what happens next. 9.5 adds <code>waitForApproval</code> for the most common case of all, a binary approve or reject with labels you choose, and it extends both steps to their first external surface: Slack. Both pause the run until someone answers, with a timeout so a workflow never hangs forever. Not every decision should be fully automated, and this is how you put the human on exactly the steps that need one.</p><p><code>waitForInput</code> is the flexible one: define a schema for the input you want back, and the response comes through typed. Reach for it when the choice is more than yes or no. Here, an Observability workflow has caught a service-level objective (SLO) burn-rate alert on <code>payment-service</code> and asks the on-call engineer which mitigation to run:</p>- name: ask_sre
  type: waitForInput
  with:
    message: "payment-service is burning its error budget. Which mitigation should we run?"
    schema:
      type: object
      properties:
        mitigation:
          type: string
          enum: [restart, scale_up, monitor]
        reason:
          type: string
      required: [mitigation]
    channels:
      slack_api:
        connector-id: my-slack-connector
        channels: ["sre-oncall"]<p><code>waitForApproval</code> is the new one in 9.5, for a straight approve or reject. Here a security workflow has decided a host should be contained, but gates that destructive action on a human before it runs:</p>- name: request_containment_approval
  type: waitForApproval
  timeout: 24h
  with:
    message: &gt;
      Isolate {{ event.alerts[0].host.name }}? This will cut the host off from
      the network until it is manually released.
    approveLabel: Isolate host
    rejectLabel: Leave connected
    channels:
      slack_api:
        connector-id: my-slack-connector
        channels: ["soc-response"]
- name: act_on_decision
  type: switch
  expression: "{{ steps.request_containment_approval.output.response.approved }}"
  cases:
    - match: "true"
      steps:
        - name: isolate_host
          # ... run the containment action
    - match: "false"
      steps:
        - name: keep_monitoring
          # ... skip containment, keep watching<p>The new piece is the <code>channels</code> block. A wait step can now deliver to where people already are, and in 9.5 that means Slack: the workflow posts the request to a Slack channel, the person responds from Slack, and the workflow resumes with their answer. No one has to be sitting in Kibana for the automation to move. Slack is the first external surface, and richer delivery experiences across more channels are on the way.</p><h3>Parallel execution: run independent workflow steps at once</h3><p>By default a workflow runs one step after another, which is what you want when each step depends on the last. But plenty of work does not: enriching an alert from three sources, checking a file against several reputation services, investigating a handful of leads. Run those sequentially and the workflow is only as fast as the sum of its parts, when it could be as fast as the slowest one. The new <code>parallel</code> step lets you run independent work at the same time. It works two ways.</p><p>The first is when you know the work ahead of time. You define a fixed set of tasks, and they run at the same time, so you gather all the results in one step instead of waiting for each in turn. Enriching a security alert from two sources at once is the classic case:</p>- name: enrich
  type: parallel
  branches:
    - name: virustotal
      steps:
        - name: scan_hash
          type: virustotal.scanFileHash
          # ... pass the alert's file hash
    - name: ip_reputation
      steps:
        - name: check_ip
          type: abuseipdb.checkIp
          # ... pass the alert's source IP<p>The second is when you do not know the work ahead of time. You give the step a list, and it runs the same work once per item, all at the same time, up to a concurrency limit you set. Root cause analysis is a good example. An earlier AI step generates a set of hypotheses for why a service is degrading, and you do not know in advance how many there will be or what they are. Rather than investigate them one after another, you pass the list into a parallel step, and it investigates every hypothesis at once:</p>- name: investigate_hypotheses
  type: parallel
  foreach: "{{ steps.generate_hypotheses.output.hypotheses }}"
  concurrency:
    max: 5
  steps:
    - name: investigate_hypothesis
      type: ai.agent
      # runs once per hypothesis, up to 5 at a time
      # the agent gathers evidence for {{ foreach.item }} and scores it<p>You control how many run at once with <strong>concurrency</strong>, and the engine caps both the concurrency and the total number of parallel tasks so a workflow cannot spawn unbounded work. All the results are available to the next step, so the workflow runs the parallel work, then continues once every task finishes.</p><h2>More in Elastic Workflows: connectors, triggers, token metering and concurrency</h2><p>Beyond the headline features, 9.5 widens what a workflow can reach and react to.</p><h3>New connectors: BigQuery, Snowflake, HubSpot, Cortex XSOAR and more</h3><p>The connector catalog keeps growing, with native connectors added in 9.5 for:</p><ul><li><p>BigQuery</p></li><li><p>Snowflake</p></li><li><p>Box</p></li><li><p>Dropbox</p></li><li><p>OneDrive</p></li><li><p>Outlook</p></li><li><p>Azure Blob</p></li><li><p>Google Cloud Functions</p></li><li><p>HubSpot</p></li><li><p>Cortex XSOAR connector for security automation</p></li></ul><p>More are on the way, and when there is not a dedicated connector for the system you need, the <code>http</code> step is the escape hatch: it can securely call any API endpoint, with credentials supplied by a connector rather than written into the YAML.</p><h3>Event-driven triggers for Elastic Cases</h3><p>A workflow starts from a trigger, and 9.5 widens what a workflow can respond to. Cases now emit events a workflow can subscribe to:</p><ul><li><p>A case is created.</p></li><li><p>A case is updated.</p></li><li><p>Its status changes.</p></li><li><p>A comment is added.</p></li><li><p>An attachment is added.</p></li></ul><p>So a workflow can run the moment a case opens, to enrich it, tag it, or notify the right channel, or when its status flips to a state you care about, rather than polling for changes. Alert-triggered workflows also receive richer rule context now, including the rule's tags, type, and parameters, so the workflow has more to work with before it acts.</p><h3>Token usage and cost tracking for AI workflow steps</h3><p>Workflows can call AI steps: <code>ai.prompt</code> for a freeform prompt, <code>ai.classify</code> to sort something into categories, <code>ai.agent</code> to hand a task to an Agent Builder agent. In an automation that runs thousands of times a day, those calls add up. 9.5 now reports token usage for every AI step, input, output, cached, and total, both per step and for the whole run. You can see exactly what the AI in a workflow consumes, track it over time, and tune a prompt or a model choice with the numbers in front of you.</p><h3>Workflow concurrency: cancel, drop or queue</h3><p>A workflow's concurrency setting decides what happens when a new execution starts before the last one finishes. 9.5 adds a third strategy, so you can pick the behavior that fits the workflow:</p><p>Strategy</p><p>Use it when</p><p>Cancel-in-progress</p><p>Only the latest execution matters, like recomputing a current state</p><p>Drop</p><p>An execution already in flight covers the situation and extras are redundant</p><p>Queue (new in 9.5)</p><p>Every execution matters and order does, so they line up and run one after another, with a queue size and time-to-live you control</p><p>Queue is what you reach for when executions touch the same resource or should not overlap. Audit logging also covers more of the lifecycle in 9.5, including restoring a workflow from its version history.</p><h2>Get started with Elastic Workflows</h2><p>The fastest way to see this is to describe something you want automated. Open the workflow editor in 9.5, type it in plain language, and read the YAML that comes back. Natural language authoring is on by default. To try the visual mode, human-in-the-loop steps, and parallel execution, turn on <strong>Elastic Workflows: Experimental Features</strong> in <strong>Stack Management → Advanced Settings</strong>.</p><p>The theme across 9.5 is a shorter path from idea to running automation. You describe what you want and AI drafts it, you see it as a graph and version it as you go, you pause it for a person in Slack when a step needs judgment, and you run independent work in parallel. For the full details, see the <a href="https://www.elastic.co/docs/explore-analyze/workflows">Workflows documentation</a>.</p><p><em>The release and timing of any features or functionality described in this post remain at Elastic's sole discretion. Any features or functionality not currently available may not be delivered on time or at all.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-workflow-automation-natural-language</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-workflow-automation-natural-language</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Tinsae Erkailo,Shahar Glazner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda47d75430c4fa7c/6a17f5cae3179149242d5963/d5d04bbcfc3925f48f3487ea4c7e0dd2205316d0-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[On-prem in under 5 minutes: Jina embedding models now available for on-prem deployment]]></title>
    <description><![CDATA[All 28 Jina AI models, including rerankers, as ready-to-deploy Docker containers, with zero telemetry and no license server. Drop-in compatible with OpenAI, Cohere, Voyage AI and Elastic Inference Service APIs.]]></description>
    <content:encoded><![CDATA[<p>All 28 Jina AI embedding and reranking models now ship as fully offline Docker containers for on-prem deployment, including <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"><code>jina-embeddings-v5-omni</code></a><a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"> </a>and <a href="https://www.elastic.co/search-labs/tutorials/jina-tutorial/jina-reranker-v3"><code>jina-reranker-v3</code></a>. Download one, transfer it to an on-premises air-gapped or firewalled system, and local inference is running in under five minutes. The containers are completely self-contained and make no external connections. There’s no call to Hugging Face or any model registry. There’s also no license server or telemetry or logging endpoints. For regulated industries, data sovereignty requirements or environments where internet access is unreliable or simply unavailable, this removes the dependency on third-party AI services. Jina On-Prem supports Elastic Inference Service (EIS), OpenAI, Cohere, Voyage AI, and Gemini API schemas, so existing applications work without code changes.</p><p>The most powerful AI models run on remote cloud installations with access via a web API, meaning that you have to trust your AI service provider for security, service availability, and stable prices. You can’t easily align reasonable demands for reliability, privacy, manageable costs, and good data governance with increasingly powerful, sophisticated, and resource-intensive AI usage.</p><p>Government regulation, court rulings, and business considerations made in someone else’s interest have all recently resulted in restricting access to specific services. And even if you can switch to other services, AI models aren’t components that can just be swapped out whenever you want. Applications that use semantic embeddings depend on having access to the same models at query time as at data ingestion time. To lose access to your embedding model means your search system comes to a halt.</p><p>AI pricing models compound that risk. Recent financial disclosures from major AI vendors give customers good reason to be concerned about potential price hikes. Reliance on products with unpredictable costs adds more risk to capital-intensive AI investments that may not produce clear returns.</p><p>Jina On-Prem is Elastic’s answer to these challenges.</p><h2>Who needs on-premises AI?</h2><p>Local hosting and direct control over your AI models support a variety of technical demands, industry requirements, and business interests.</p><p>Local installation reduces what you pay your AI service providers, but it puts the cost of hardware and reliable access on your organization. Depending on your volume of use, it may simply be cheaper. But there are additional pressing reasons to consider running your own AI. If any of the issues described below concern your enterprise, consider a local AI solution like Jina On-Prem. This list is not exhaustive.</p><p>Use case</p><p>Why on-prem</p><p>Example</p><p>Air-gapped / high-security</p><p>No outbound data transmission; complete network isolation</p><p>Defence, intelligence, classified research</p><p>Regulatory compliance</p><p>Data sovereignty; no cross-border transmission or third-party exposure</p><p>Healthcare (Health Insurance Portability and Accountability Act [HIPAA]), finance, EU enterprises (General Data Protection Regulation [GDPR])</p><p>Latency-critical</p><p>Zero network dependency; no tolerance for connection failures</p><p>Robotics, edge computing, vehicles, ships</p><p>Cost predictability</p><p>Fixed infrastructure cost vs. per-token pricing with uncertain future rates</p><p>High-volume continuous inference workloads</p><p>Liability reduction</p><p>No third-party data exposure; maintains legal privilege and duty of care</p><p>Law firms, government agencies</p><h3>Why air-gapped and firewalled systems need on-prem AI</h3><p>Air-gapped and firewalled systems cannot use external AI APIs. Jina On-Prem runs entirely within your infrastructure with no outbound connections.</p><p>For organizations managing especially sensitive data, security and privacy considerations are paramount. It does little good to invest in protecting your sensitive data if you promptly turn it over to some remote third party that may have insufficient security in place or might be subject to the demands of a foreign government.</p><p>Employees in organizations that handle sensitive data often receive some training in secure data handling, but this isn’t very effective when they all have web browsers that may be open to any page on the internet while they handle that data. Isolation is the most effective security measure available, either through air-gapping or very restrictive firewalls, but that makes it difficult to use external services of any kind.</p><h3>On-prem AI for latency-sensitive and high-availability systems</h3><p>Software as a service and cloud computing represent a compromise between the cost of offering highly accessible, reliable services on your own computers and outsourcing the problem to someone else. But they come with variable latency, outages, and a complete loss of control when things go wrong. AI services aren’t the exception. If your search system goes offline when you can’t access your embedding model, it may no longer look like a good compromise.</p><p>Furthermore, relying on external AI will always involve risks that you can’t easily foresee or manage. Internet access and network latency can degrade without notice, as a result of political events, bad weather, or ships dragging their anchors over underwater fiber-optic cables. Governments can, and recently have, used export bans to suddenly block access to AI models. AI service providers sometimes withdraw models to induce you to switch to newer ones. The flexibility and managed costs of external services have to be balanced against the risks of dependency.</p><h3>On-prem AI for GDPR, HIPAA, and data sovereignty compliance</h3><p>Organizations that collect personal data are subject to increasingly stringent regulations which often differ between jurisdictions and may have contradictory requirements. Notably, <a href="https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index.html">HIPAA rules</a> place very strict data protections on American healthcare providers, and strong general data protection laws in <a href="https://laws-lois.justice.gc.ca/eng/acts/p-8.6/">Canada</a>, the <a href="https://gdpr-info.eu/">European Union</a>, and <a href="https://www.japaneselawtranslation.go.jp/en/laws/view/4241">many Asian jurisdictions</a> require all enterprises that handle personal information to do so securely and to limit the transmission of that data to other parties or other jurisdictions. These rules can even impose obligations on foreign entities if they have any customers in those jurisdictions. Financial institutions are frequently subject to even stricter rules and bear the same direct liability for information security that they have to protect against other forms of criminal activity.</p><p>Regulatory compliance can be incompatible with third-party AI services, especially if using them involves cross-border data transmission.</p><p>Furthermore, recent events show that rules restricting the physical location of data stores may not be a reliable source of protection when international cloud operators are subject to pressure from foreign governments. Local laws may conflict between jurisdictions, requiring local data storage and processing and making third-party services impossible to use. In some cases, the only solution is to take all the parts of your processes in house, including your AI systems.</p><h3>AI liability risks from third-party data transmission</h3><p>Data protection laws and recognized duties of care toward sensitive data routinely have liability implications, sometimes very severe ones. You can be liable for third-party service providers’ handling of your data. While courts and legal procedures might provide some retrospective protections from insecure service providers, those remedies are not available nor generally effective against national security actors, law enforcement, or criminal hackers.</p><p>For governments, there have already been instances of cross-border cloud service providers releasing sensitive state information to foreign actors.</p><p>But even if you don’t worry about foreign governments or hackers, and if your external AI service providers are themselves secure, just the fact that they’re external can create liabilities.</p><p>For example, in most jurisdictions, lawyers’ communications with their clients enjoy special legal protections, and law offices have strict liabilities when recording or storing this information. In the United States, this “attorney-client privilege” is so famous, it’s central to movie and TV plots. But one of the ways that privilege can be lost is by communicating information with someone who is not privileged, and recent developments suggest that external AI service providers might qualify.</p><p>It’s possible, at least in the United States, that just using third-party AI services over an internet API, like embedding models that provide indexing services, might violate critical confidentiality rules. A law firm might be sued, disciplined, or disbarred just for using externally hosted software, even if no security breach occurs.</p><h3>On-prem AI for offline, edge, and physically isolated systems</h3><p>Computer systems aren’t just isolated for security reasons. For example, moving vehicles cannot rely on internet access for any essential functions. Ships and aircraft have very extensive onboard computer systems that have to function without internet connections and therefore cannot use external AI services. Offshore platforms, remote facilities in wilderness areas, computer services in the Arctic, Antarctic and on small islands without adequate physical connections to global networks are all examples of installations that benefit from locally hosting all the services they need. As AI’s role in enterprise computing grows, these limitations become more important to address.</p><p>Emerging applications of AI to physical systems (robotics and other spatially confined or external-world–focused use cases, like logistics management systems or even supermarket checkouts) may be connected to the global internet, but they have no tolerance for connection failures or spikes in latency. If they rely on an AI system to operate, that AI system needs to be as local and reliable as possible.</p><h2>Who doesn’t need on-premises AI?</h2><p>Remote software services and off-site AI do have benefits. Running AI models can require expensive, power-hungry processors with notoriously short lifespans. Access to high-quality hardware is particularly difficult right now due to market factors and external economic shocks. Under the circumstances, it may make sense to pay by the token to use an external API instead of supporting the steep capital costs of local AI.</p><p>External APIs make the most sense for intermittent users. If you use AI models primarily to batch process data for analysis, rather than running a search system that has to be online all the time, it makes little sense to invest in capital-intensive hardware and local installations.</p><p>Furthermore, when your data processing is already cloud-based, for example, an ecommerce website hosted in the cloud for reliability and accessibility reasons, using AI services located in the same cloud infrastructure may provide a better value for money than introducing your own licensed AI model deployment. You’re already dependent on your cloud service provider, so being dependent on its AI services doesn’t add much risk.</p><p>If your use case sounds like it fits that description, Jina AI models are available on <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>, <a href="https://aws.amazon.com/marketplace/seller-profile?id=seller-stch2ludm6vgy">AWS Marketplace</a>, and the <a href="https://console.cloud.google.com/marketplace/browse?q=jina">Google Cloud Platform</a> specifically to meet your needs.</p><p>The table below summarizes the key factors. Your answer depends on your data, infrastructure and usage pattern.</p><p>Factor</p><p>On-prem favored</p><p>Cloud API favored</p><p>Usage pattern</p><p>Continuous or high-volume inference</p><p>Intermittent or batch processing</p><p>Data sensitivity</p><p>Regulated, sovereign, or classified</p><p>No cross-border or third-party restrictions</p><p>Network environment</p><p>Air-gapped, firewalled, or unreliable</p><p>Stable, always-on internet</p><p>Existing infrastructure</p><p>Own or can procure GPU hardware</p><p>Already cloud-hosted with colocated AI</p><p>Cost model</p><p>Fixed hardware + license; predictable at scale</p><p>Per-token; lower up-front, variable long-term</p><p>Latency tolerance</p><p>None (robotics, edge, real-time)</p><p>Network variability is acceptable</p><p>Operational responsibility</p><p>Your team manages hardware and availability</p><p>Provider manages hardware and updates; you manage integration</p><p>You have to consider the costs and benefits in light of your particular circumstances and use cases, taking into account the issues highlighted in the previous section that apply to you. The cost-benefit analysis will doubtless change over time. We can’t predict the future of the AI industry or hardware prices even in the short term.</p><h2>Introducing Jina On-Prem</h2><p>For users who can benefit from local AI services, we’re introducing <a href="https://github.com/jina-ai/jina-on-prem/wiki/">Jina On-Prem</a>, a fully self-contained installation suite for Jina AI’s high-performance models.</p><p>Jina AI’s models match the accuracy of embedding models <a href="https://mteb-leaderboard.hf.space/benchmark/MTEB(Multilingual%2C%20v2)">many times their size</a>, reducing compute costs, memory footprints, and hardware requirements. This makes them an ideal choice for users who want or need to keep their AI on-premises. Commercial licenses are available with scalable, proportionately priced solutions for use cases of all sizes.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt190865fb3ebde472/6a6a33d0065b162508701ff9/02559ceca556a26c53eb703ae87d421452b27251-1374x1400.png" alt="MMTEB Multilingual v2 leaderboard showing Jina AI embedding model rankings: jina-embeddings-v5-omni-small and jina-embeddings-v5-text-small ranked 13th, jina-embeddings-v5-omni-nano and jina-embeddings-v5-text-nano ranked 19th, competing against models from Microsoft, Google, Tencent, NVIDIA and Qwen" /><h3>What API schemas does Jina On-Prem support?</h3><ul><li><p>Available as a complete collection of dependencies for local installation or as a <a href="https://www.docker.com/">Docker container</a> that you can install and run in minutes.</p></li><li><p>Jina On-Prem installations <em>do not</em> call out to outside systems.</p><ul><li><p>No call to Hugging Face Hub or any model registry (<code>HF_HUB_OFFLINE=1</code> and <code>TRANSFORMERS_OFFLINE=1</code> are baked in).</p></li><li><p>There’s no license server.</p></li><li><p>There are no telemetry or logging endpoints.</p></li></ul></li><li><p>Supports both CPU and GPU hardware, with GPU autodetection.</p></li><li><p>All 28 Jina AI models available, including the latest <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-omni-all-media-one-index"><code>jina-embeddings-v5-omni</code></a> multimodal embedding models and <a href="https://www.elastic.co/search-labs/tutorials/jina-tutorial/jina-reranker-v3"><code>jina-reranker-v3</code></a>.</p></li><li><p>Access via standard AI API schemas: <a href="https://jina.ai/api-dashboard">Jina API</a>, OpenAI, Cohere, Voyage AI, and Gemini. Jina On-Prem is a drop-in solution for applications built on those schemas.</p></li><li><p>Drop-in replacement for models served by the <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a>. Jina On-Prem integrates directly with <a href="https://www.elastic.co/blog/deploy-elastic-air-gapped-disconnected-environments">air-gapped Elastic deployments</a>.</p></li></ul><h2>Hardware requirements for Jina AI on-prem models</h2><p>The hardware requirements vary for different Jina models. The table below shows the recommendations for the most recent models using GPU settings. You don’t need anything more powerful than an NVIDIA L4 GPU, although an A100 is recommended for the v5 embedding models. Our latest embedding model currently requires a minimum of 8 GB of VRAM.</p><p>Model</p><p>Minimum VRAM</p><p>Recommended GPU</p><p>jina-embeddings-v5-text-nano</p><p>2 GB</p><p>T4 / L4</p><p>jina-embeddings-v5-text-small</p><p>3 GB</p><p>L4 / A10G</p><p>jina-embeddings-v5-omni-small</p><p>8 GB</p><p>L4 / A10G / A100</p><p>jina-reranker-v3</p><p>3 GB</p><p>L4</p><p>jina-clip-v2</p><p>4 GB</p><p>L4</p><p>jina-code-embeddings-1.5b</p><p>4 GB</p><p>L4</p><p>ReaderLM-v2</p><p>4 GB</p><p>L4</p><p>If you use more than one model at a time, the VRAM requirements will increase. Please see the <a href="https://github.com/jina-ai/jina-on-prem/wiki/Sizing-And-Hardware">Sizing and Hardware page</a> for more information.</p><h2>How to install Jina On-Prem with Docker</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20265d09e2d8d0f4/6a6a33d1065b162105701ffd/ada9881af407168298b1940f8537ad71a5411c89-1999x1200.png" alt="" /><p>The quickest way to get started is to <a href="https://www.docker.com/get-started/">install Docker</a> (if you haven’t already) and follow the instructions on the <a href="https://github.com/jina-ai/jina-on-prem/wiki/QuickStart">Jina On-Prem Quick Start</a> page.</p><p>There are pre-composed Docker containers for all 28 Jina models. Download one and transfer it to your installation target, and you can have Jina AI models running in under five minutes.</p><p>For multimodal or custom builds, or to download the complete dependency set for installation outside of a container, follow the steps outlined in the <a href="https://github.com/jina-ai/jina-on-prem/wiki/Bundling-Guide">bundling guide</a>.</p><p>Your Jina On-Prem installation supports all Jina API and EIS functionality and embedding generation via OpenAI, Cohere, Voyage AI, and Gemini APIs, so it can integrate into preexisting applications using standard interfaces. See the <a href="https://github.com/jina-ai/jina-on-prem/wiki/API-Reference">API documentation</a> for more information.</p><p>Jina models, including models installed with Jina On-Prem, are available on various licensing terms, with the latest models free for noncommercial use under a <a href="https://creativecommons.org/licenses/by-nc/4.0/deed.en">CC BY-NC 4.0</a> license. To license Jina On-Prem for commercial use, please contact <a href="https://www.elastic.co/contact">Elastic Sales</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/on-prem-ai-jina-embedding-models</guid>
    <category><![CDATA[Jina AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Scott Martens]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17731ab0c6ec66f6/6a6a33d140a4941014ca5c9a/09bc6dac4e6a86c7877f8ed78d68f5d581aeffa9-1999x1200.png" length="0" type="image/png"/>
    <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[4 NVIDIA AI tasks, 1 Elasticsearch API: Embeddings, chat, completion, and rerank]]></title>
    <description><![CDATA[Set up NVIDIA hosted models in Elasticsearch with one API key and a model ID. No custom integration code needed.]]></description>
    <content:encoded><![CDATA[<p></p><p>Elasticsearch's <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">inference API</a> now connects directly to NVIDIA-hosted models. You get text embedding, completion, chat completion, and reranking, plus access to NVIDIA's catalog of NVIDIA Inference Microservices–optimized (NIM-optimized) retrieval and generative models, without writing any custom integration code.</p><p>In practice, that's vector search and retrieval augmented generation (RAG) applications built on NVIDIA-hosted embeddings. It's also multi-turn conversations through the chat completion API and reranking with NVIDIA's cross-encoder models to push relevance past keyword matching. All four task types run natively through the inference API, with support for both streaming and non-streaming responses. How do I get an NVIDIA API key?</p><p>NVIDIA offers a broad catalog of models designed for a wide range of use cases, all of which can be explored on the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. Throughout this article, we provide specific examples of high-performance models optimized for each inference task type. After identifying the model that best aligns with your application requirements, choose the deployment approach that fits your infrastructure and operational needs. This could mean running it on-premises for greater control or using a serverless option for faster experimentation and simplified scaling.</p><p>To get started quickly, you’ll first need access to NVIDIA’s model catalog and APIs. Create an account or log in at <a href="https://www.build.nvidia.com/">https://www.build.nvidia.com/</a> to explore available models, evaluate their capabilities, and compare which ones best fit your use case before proceeding toward full-scale deployment. This site provides a web-based interface for testing models, which is useful during evaluation and experimentation. For production-level requirements, you can use NVIDIA NIM to deploy endpoints on your own infrastructure.</p><p>To access NVIDIA models, you need to generate an API key. This key will serve as the authorization mechanism when making calls to NVIDIA's endpoints. You can create, access, and manage your API keys at <a href="https://build.nvidia.com/settings/api-keys">API keys</a>. To create a new key, click the <strong>Generate API Key</strong> link in the top right, and then specify a name and expiration period for the key. After generating the API key, select the appropriate model for your task and set up the corresponding Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt238bdc3001941d8c/6a6119f21c28938e356e5cf7/ca6c4a6322433697ad76d28f68e78c03b59095a9-2048x1104.png" alt="" /><h2>Setting up Elasticsearch inference endpoints</h2><p>Once you have set up your NVIDIA account and obtained the necessary API keys, you can create an Elasticsearch inference endpoint.</p><p>Endpoint setup can be done directly in Kibana using the console, which allows you to input the required steps into Elasticsearch even without using an API. The following sections provide examples and details on how to create and use endpoints for <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-text-embedding">text embeddings</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-completion">completion</a>, <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-chat-completion-unified">chat completion</a>, and <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-rerank">reranking</a>.For more examples and detailed information, please consult the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-nvidia">Elasticsearch API reference documentation</a>.</p><h3>Creating and using a text embeddings inference endpoint</h3><p>To create a text embedding inference endpoint, you first select an appropriate model that can perform embedding operations. NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can select the <strong>Text-to-Embedding</strong> or <strong>Retrieval Augmented Generation</strong> use case on the left to filter the appropriate models. You can also find NVIDIA’s text embedding models in the <a href="https://docs.api.nvidia.com/nim/reference/retrieval-apis">NVIDIA documentation</a>. NVIDIA’s retrieval APIs include <strong>text embedding</strong> and <strong>reranking</strong> models. When choosing a model, make sure it explicitly supports text embedding inference. Text embedding models typically include an API description, such as "Creates an embedding vector from the input text."</p><p>A good example of an embedding model is the <a href="https://build.nvidia.com/nvidia/nemotron-3-embed-1b">nvidia/nemotron-3-embed-1b</a> model. You can access <a href="https://build.nvidia.com/nvidia/nemotron-3-embed-1b/deploy">the deployment page for this model</a>, which allows you to deploy this model on-premises.</p><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to create an Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf15fb19a2bcf18a0/6a6119f38b1c7a7183893d18/207e483b7ac40b2acee268fc13b80819a9ecf9ed-1259x869.png" alt="NVIDIA embedding API reference showing the POST endpoint and model parameter for llama-nemotron-embed-1b-v2" /><p>Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for embedding inference. This parameter is named <code>model</code> on the NVIDIA side.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used to send requests to the NVIDIA model (either deployed on-premises or in a serverless environment). It must be accessible from your Elasticsearch instance.</p></li></ul><p>For most text embedding models, the URL is static and NVIDIA differentiates models solely via the <code>model</code> parameter. If the <code>url</code> parameter isn’t provided during endpoint creation, the default text embedding task specific value <a href="https://integrate.api.nvidia.com/v1/embeddings">https://integrate.api.nvidia.com/v1/embeddings</a> will be used.</p><p>To generate text embeddings, set up an endpoint configured with the required NVIDIA model values in the service settings map:</p>PUT _inference/text_embedding/nvidia-text-embedding
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/embeddings", // optional
        "api_key": "&lt;api_key&gt;",
	 "model_id": "nvidia/nemotron-3-embed-1b"
    }
}<p>Upon sending this request, you should receive a successful <strong>200 OK</strong> response. This response confirms that the endpoint is functioning correctly and the settings are specified accurately, and it will detail your newly created Elasticsearch endpoint for the text embedding task type.</p>{
    "inference_id": "nvidia-text-embedding",
    "task_type": "text_embedding",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-embed-1b",
        "url": "https://integrate.api.nvidia.com/v1/embeddings",
        "rate_limit": {
            "requests_per_minute": 3000
        },
        "dimensions": 2048,
        "similarity": "dot_product"
    },
    "chunking_settings": {
        "strategy": "sentence",
        "max_chunk_size": 250,
        "sentence_overlap": 1
    }
}<p>You can now use the newly created endpoint to generate embeddings. The request for this operation will be similar to the example shown below:</p>POST _inference/nvidia-text-embedding
{
    "input": [
        "First input.",
        "Second input."
    ]
}<p>The text embeddings will be returned, accompanied by a successful HTTP <strong>200 OK</strong> status.</p>{
    "text_embedding": [
        {
            "embedding": [
                -0.016174316,
                0.018432617,
                ...,
                -0.016723631
            ]
        },
        {
            "embedding": [
                -0.008995056,
                0.014381409,
                ...,
                -0.025314331
            ]
        }
    ]
}<p>This integration allows users to use the NVIDIA models directly within Elasticsearch, making advanced search and RAG applications easier to build. These production-ready models offer a reliable and robust foundation for enterprise-scale deployments.</p><h3>Creating and using a completion inference endpoint</h3><p>To create a completion inference endpoint, you first select an appropriate model.</p><p>NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can search for the model there, but you can also find NVIDIA’s completion models in the left-hand navigation of this <a href="https://docs.api.nvidia.com/nim/reference/llm-apis">large language model (LLM) API documentation</a>.Each entry in the list links to a general description of the model. From there, you can navigate to a nested link that opens the API reference specific to the selected model. A good example of a completion model is the <a href="https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b">nvidia/nemotron-3-super-120b-a12b</a> model. You can access <a href="https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b/deploy">the deployment page</a> for this model, which allows you to deploy this model on-premises.</p><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to successfully create an Elasticsearch inference endpoint.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt595e423887574160/6a6119f45144f7954cb98969/b3af7e17a0d310bf63c33339056e30f2960ea139-1495x779.png" alt="NVIDIA chat completions API reference showing the POST endpoint and default model nemotron-3-super-120b-a12b" /><p>Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for completion inference. This parameter is named <code>model</code> on the NVIDIA side.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used to send requests to the NVIDIA model (either deployed on-premises or in a serverless environment). It must be accessible from your Elasticsearch instance.</p></li></ul><p>For most completion models, the URL is static, and NVIDIA differentiates between models using only the <code>model</code> parameter. If the <code>url</code> parameter isn’t specified during endpoint creation, the default value of <a href="https://integrate.api.nvidia.com/v1/chat/completions">https://integrate.api.nvidia.com/v1/chat/completions</a> will be used.</p><p>To use a generative model for the Elasticsearch inference completion task, you configure an endpoint that supports completion operations. The service settings map must include the required configuration for the selected NVIDIA model.</p>PUT _inference/completion/nvidia-completion
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/chat/completions", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/nemotron-3-super-120b-a12b"
    }
}<p>Upon success, you’ll receive a 200 OK response. This response provides the details of your new Elasticsearch endpoint, which is configured for completion tasks.</p>{
    "inference_id": "nvidia-completion",
    "task_type": "completion",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-super-120b-a12b",
        "url": "https://integrate.api.nvidia.com/v1/chat/completions",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>The created endpoint allows you to generate both streaming and non-streaming completions. These refer to how the endpoint delivers its output. <em>Non-streaming completions</em> wait until the entire response is generated before sending it back in a single block, resulting in a single, slower response time. In contrast, <em>streaming completions</em> send the generated text back in small, continuous chunks as they’re produced, which allows you to start reading the response immediately. This continuous delivery creates the perception of faster interaction and is essential for real-time conversational interfaces.</p><h4>Generating non-streaming completions</h4><p>To generate non-streaming completions, you call the newly created endpoint with a request similar to the following:</p>POST _inference/completion/nvidia-completion
{
    "input": "The sky above the port was the color of television tuned to a dead channel."
}<p>You'll receive a successful 200 OK response, with the completion result:</p>{
    "completion": [
        {
            "result": "This line uses a simile to describe the sky over a seaport."
        }
    ]
}<h4>Generating streaming completions</h4><p>To use the streaming functionality for the completion task type, you need to send the identical request used for non-streaming completions, but with <code>_stream</code> included in the URL path:</p>POST _inference/completion/nvidia-completion/_stream
{
    "input": "The sky above the port was the color of television tuned to a dead channel."
}<p>This command will initiate a continuous flow of events, delivering a sequence of outputs similar to the example provided below:</p>event: message
data: {"completion":[{"delta":"First"},{"delta":" Second"}]}

﻿event: message
data: {"completion":[{"delta":" Third"},{"delta":" Fourth"}]}

﻿event: message
data: [DONE]<p>This capability empowers users to easily integrate NVIDIA generative models directly into their Elastic applications, supporting both single-response and engaging streaming experiences for dynamic content generation.</p><h3>Creating and using a chat completion inference endpoint</h3><p>To enable more dynamic and flexible interactions than those supported by the standard completion inference endpoint, you configure a chat completion inference endpoint, specifically designed to handle chat-based completion tasks.</p><p>To identify the parameters required to construct the service settings map, refer to the completion inference endpoint section of this blog post. The same configuration principles apply to the chat completion inference endpoint.</p><p>The service settings map must include the required configuration settings for the selected NVIDIA model.</p>PUT _inference/chat_completion/nvidia-chat-completion
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://integrate.api.nvidia.com/v1/chat/completions", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/nemotron-3-super-120b-a12b"
    }
}<p>Upon success, you’ll receive a 200 OK response, which includes the details of your new Elasticsearch endpoint specifically for the chat completion task type.</p>{
    "inference_id": "nvidia-chat-completion",
    "task_type": "chat_completion",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/nemotron-3-super-120b-a12b",
        "url": "https://integrate.api.nvidia.com/v1/chat/completions",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>You can now use the new endpoint to stream generated completions. Your request should resemble the following example:</p>POST _inference/chat_completion/nvidia-chat-completion/_stream
{
    "messages": [
        {
            "role": "user",
            "content": "What is deep learning?"
        }
    ]
}<p>The chat completion results will be delivered to you as a continuous stream of events, formatted as follows:</p>event: message
data: {
    "id": "cmpl-92346cfa1d004f65991eedf0765b622a",
    "choices": [
        {
            "delta": {
                "content": " first chunk"
            },
            "index": 0
        }
    ],
    "model": "nvidia/nemotron-3-super-120b-a12b",
    "object": "chat.completion.chunk"
}
﻿﻿event: message
data: {
    "id": "cmpl-92346cfa1d004f65991eedf0765b622a",
    "choices": [
        {
            "delta": {
                "content": " second chunk"
            },
            "finish_reason": "length",
            "index": 0
        }
    ],
    "model": "nvidia/nemotron-3-super-120b-a12b",
    "object": "chat.completion.chunk",
    "usage": {
        "completion_tokens": 10,
        "prompt_tokens": 8,
        "total_tokens": 18
    }
}

﻿event: message
data: [DONE]<p>The chat completion capability, distinct from the simpler completion API, allows users to build stateful, multi-turn conversational AI applications directly within the Elastic Stack, using the full flexibility of NVIDIA models for dynamic user interactions following Elasticsearch inference chat completion API.</p><h3>Creating and using a rerank inference endpoint</h3><p><em>Reranking</em> is the process of reordering the results from an initial search query to improve their relevance to your intent. Reranking is a second-stage relevance step that reorders the results returned by an initial retriever. In many cases, it uses a different model than the retriever itself, typically a cross-encoder model, which evaluates the query and each candidate document together to produce a more accurate relevance score. The output is a list of results ranked based on their relevancy, thereby drastically improving the quality and contextual accuracy of the search results.</p><p>To create a rerank inference endpoint, you first select an appropriate model that can perform reranking operations. NVIDIA lists its models in the <a href="https://build.nvidia.com/models">NVIDIA Build model catalog</a>. You can use the <code>reranking</code> label to select the appropriate models. You can also find NVIDIA’s reranking models in the left-hand navigation of this <a href="https://docs.api.nvidia.com/nim/reference/retrieval-apis">retrieval APIs documentation</a>. NVIDIA includes rerankingand text embedding models in the Retrieval APIs section in its API documentation. When selecting a model, ensure that it explicitly supports rerank inference requests. Rerank models typically include an API description, such as “Ranks passages by their relation to a query.” This wording indicates that the model supports the rerank task type.</p><p>A good example of a reranking model is the <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2">nvidia/llama-nemotron-rerank-vl-1b-v2</a>. You can access <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2/deploy">the deployment page for this model</a>, which allows you to deploy this model on-premises.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt818a68e2286f5b73/6a6119f4f81792e7ea07f5fd/64cf92d4db85b4af88f84e2048c589457c3fb1c1-1495x772.png" alt="NVIDIA rerank API reference showing the POST endpoint and model parameter for llama-nemotron-rerank-vl-1b-v2" /><p>Once you have selected a suitable model, open its API reference page, where you’ll find the parameters required to create an Elasticsearch inference endpoint. Two parameters are relevant:</p><ul><li><p><strong><code>model_id</code></strong>(required): Specifies the NVIDIA model to be used for rerank inference.</p></li><li><p><strong><code>url</code></strong>(optional): The endpoint URL used by the inference endpoint to send requests to the NVIDIA service.</p></li></ul><p>For most models, the URL is static and NVIDIA differentiates between models using only the <code>model</code> parameter. If the <code>url</code> parameter isn’t specified during endpoint creation, the default value</p><p><a href="https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking">https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking</a> will be used. The <a href="https://build.nvidia.com/nvidia/llama-nemotron-rerank-vl-1b-v2">nvidia/llama-nemotron-rerank-vl-1b-v2</a> model requires a custom URL to be specified, and it will be used in the example below.</p><p>To perform reranking tasks, you configure an inference endpoint that executes reranking operations. The service settings map must include the required configuration for the selected NVIDIA model.
</p>PUT _inference/rerank/nvidia-rerank
{
    "service": "nvidia",
    "service_settings": {
        "url": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-vl-1b-v2/reranking", // optional
        "api_key": "&lt;api_key&gt;",
        "model_id": "nvidia/llama-nemotron-rerank-vl-1b-v2"
    }
}<p>The successful creation of your new Elasticsearch endpoint for the rerank task type will be confirmed by a 200 OK response, which will also provide the specific details of the endpoint.</p>{
    "inference_id": "nvidia-rerank",
    "task_type": "rerank",
    "service": "nvidia",
    "service_settings": {
        "model_id": "nvidia/llama-nemotron-rerank-vl-1b-v2",
        "url": "https://ai.api.nvidia.com/v1/retrieval/nvidia/llama-nemotron-rerank-vl-1b-v2/reranking",
        "rate_limit": {
            "requests_per_minute": 3000
        }
    }
}<p>You can then start using the new endpoint to perform a ranking operation with a request like the one shown below:</p>POST _inference/rerank/nvidia-rerank
{
    "input": [
        "mercury",
        "venus",
        "earth",
        "mars",
        "jupiter",
        "saturn"
    ],
    "query": "which planet is third from the sun"
}<p>A successful HTTP 200 OK status will be returned, and the ranked entries will be included in the response. Since models are not deterministic, the results you receive may vary and may be ordered differently across calls, as the same outcome isn’t guaranteed each time.</p>{
    "rerank": [
        {
            "index": 2,
            "relevance_score": -8.5
        },
        {
            "index": 1,
            "relevance_score": -8.9453125
        },
        {
            "index": 4,
            "relevance_score": -8.984375
        },
        {
            "index": 3,
            "relevance_score": -9.0078125
        },
        {
            "index": 0,
            "relevance_score": -9.5546875
        },
        {
            "index": 5,
            "relevance_score": -10.53125
        }
    ]
}<p>Integrating the rerank capability with Elasticsearch and NVIDIA elevates search applications to deliver the most accurate, contextually relevant results. By using the NVIDIA reranking models within the search infrastructure of Elasticsearch, the system moves beyond simple keyword matching. This capability prioritizes the most relevant documents after the initial search, drastically improving the user experience and the utility of the data.</p><h2>NVIDIA and Elasticsearch: What's next</h2><p>The integration of Elasticsearch's inference API with NVIDIA marks a considerable step forward for users. By providing a standardized, simpler path to access NVIDIA's high-performance, optimized models, this integration significantly expands Elastic's capabilities. Users can now work with these models for key AI tasks, including generating text embeddings for vector search, generating and streaming content with completion models, building stateful conversational AI applications with chat completion, and drastically improving search result accuracy through reranking. This simplification streamlines the development of sophisticated AI-powered applications, from advanced RAG systems to dynamic conversational interfaces, making powerful AI more accessible for Elastic users.</p><p>Ready to get started?</p><ul><li><p>Explore the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-nvidia">Elasticsearch API reference documentation</a> to dive deeper into setup.</p></li><li><p>Browse the <a href="https://build.nvidia.com/models">NVIDIA model catalog</a> to see all available models.</p></li><li><p>Check out the <a href="https://docs.api.nvidia.com/">NVIDIA API Documentation hub</a> for integration guides and API references.</p></li><li><p>Start your journey by creating an <a href="https://build.nvidia.com/settings/api-keys">NVIDIA account and API key</a> to begin integrating the models today.</p></li></ul><h2>Frequently asked questions</h2><p><strong>How do I connect Elasticsearch to NVIDIA hosted models?</strong></p><p>Create an NVIDIA API key at <a href="http://build.nvidia.com">build.nvidia.com</a>, and then create an Elasticsearch inference endpoint using the <code>nvidia</code> service with your API key and a <code>model_id</code>. Elasticsearch's inference API supports four NVIDIA task types: text embedding, completion, chat completion, and reranking.</p><p><strong>What's the default endpoint URL for NVIDIA text embedding models in Elasticsearch?</strong></p><p>If no <code>url</code> is specified when creating the endpoint, Elasticsearch defaults to <code>https://integrate.api.nvidia.com/v1/embeddings</code> for text embedding tasks. Completion and chat completion tasks default to <code>https://integrate.api.nvidia.com/v1/chat/completions</code> instead.</p><p><strong>Can I use NVIDIA NIM models deployed on my own infrastructure with Elasticsearch?</strong></p><p>Yes. NVIDIA NIM supports on-premises deployment, and Elasticsearch's inference endpoint accepts a custom <code>url</code> parameter pointing to your self-hosted NIM endpoint instead of NVIDIA's serverless API.</p><p><strong>How do I stream chat completion responses from NVIDIA models in Elasticsearch?</strong></p><p>Append <code>_stream</code> to the chat completion endpoint's URL path (<code>POST _inference/chat_completion/{id}/_stream</code>). Elasticsearch returns results as a continuous event stream instead of a single blocking response, ending with a <code>[DONE]</code> event.</p><p><strong>What's the difference between the completion and rerank task types in Elasticsearch's NVIDIA integration?</strong></p><p>Completion and chat completion generate new text from a prompt. Reranking takes an existing list of documents and a query and then reorders them by relevance score using a cross-encoder model; it doesn't generate text, it rescores what you already retrieved.</p><p><strong>How do NVIDIA's reranking models improve Elasticsearch search results?</strong></p><p>NVIDIA's reranking models evaluate the query and each candidate document together, producing a relevance score used to reorder results beyond keyword matching. Elasticsearch's rerank endpoint returns each document's index and relevance score, so the highest-scoring passages surface first.</p><p><strong>Do I need a paid NVIDIA account to use hosted models with Elasticsearch?</strong></p><p>You need an NVIDIA account and an API key generated at <a href="http://build.nvidia.com">build.nvidia.com</a>; NVIDIA's build platform offers both free evaluation access and paid production tiers, depending on usage. Elasticsearch itself doesn't add separate licensing for the NVIDIA service beyond your existing NVIDIA account terms.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-nvidia-inference</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-nvidia-inference</guid>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI Tools ]]></category>
    <dc:creator><![CDATA[ Jan Kazlouski]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt88526af16bafdb7c/6a17d7807f6f15825dc0998d/d11e1ba058784ec92b8953fb8db62e1bad21c210-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Your FAQ bot doesn't need a PhD: LLM query routing with Elastic Workflows]]></title>
    <description><![CDATA[Route LLM queries by complexity using Elasticsearch search metadata: Mistral Small for FAQ questions, Claude Sonnet for multi-source synthesis.]]></description>
    <content:encoded><![CDATA[<p>Sending every customer support query to a large model means your simple FAQ answers are as slow and as expensive as your most complex ones. This post shows how to build a two-model routing system in <a href="https://www.elastic.co/docs/explore-analyze/workflows">Elastic Workflows</a>: <a href="https://mistral.ai/news/mistral-small-4/">Mistral Small</a> handles straightforward questions directly from a single FAQ article; <a href="https://www.anthropic.com/claude/sonnet">Claude Sonnet</a> synthesizes answers across multiple knowledge base sources when the query needs it. The routing decision is made from search metadata alone, keeping classification cheap and fast on every query.</p><h2>Prerequisites</h2><ul><li><p><a href="https://www.elastic.co/cloud">Elastic Cloud</a> deployment running Elasticsearch 9.3+ or <a href="https://cloud.elastic.co/registration">start a free trial</a></p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/workflows/get-started#workflows-prerequisites">Workflows enabled</a> (Advanced Settings)</p></li><li><p>Python 3.9+</p></li><li><p>A <a href="https://console.mistral.ai/">Mistral API key</a></p></li></ul><h2>How LLM query routing works in this system</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4665bd674708a1cb/6a3e41fab0216c28c26945d6/71973e55db974ef7eca637539c3c49859759d2a3-1280x720.png" alt="Flowchart diagram showing how customer queries are processed. It begins with “Customer Query” and then moves to “Search Knowledge Base (Elasticsearch)” and “Classify Query with ES context.” From there, two branches appear: one labeled “simple FAQ match” leading to “Direct answer from FAQ snippet (Mistral Small) Fast &amp; cheap,” and another labeled “complex (needs synthesis)” leading to “Synthesize from multiple articles (Claude Sonnet).” Both paths converge at “Final Response" /><p>We're going to build a two-stage system: a router that decides how to answer, and an answering model that produces the response.</p><p>The router looks at the query and the metadata of the top search hits, like scores, categories, and complexity labels. From that, it picks one of two strategies: Answer directly from the top FAQ article, or synthesize across multiple articles with citations. That decision can be made from structured signals alone, so a small, fast model handles it.</p><p>The answering step varies. A single-article answer is bounded work that a small model does well and returns quickly. A multisource synthesis with citations benefits from a more capable model, and the extra time is worth it. Matching each query to the model that fits keeps simple answers fast and complex answers good.</p><h3>Why use a small model for routing instead of the large model?</h3><p>Because the router runs on every query, including the simple ones. A slow router makes every answer slow, even the ones a small model could have produced in a fraction of the time.</p><p>The key design choice is that the routing step only sees metadata, not full documents. A query like "my OTG isn't heating evenly" only needs to know that the top hits are in the "Product Troubleshooting - Appliances" category with <code>issue_complexity: medium</code>, not the full conversation transcripts. This keeps the classification prompt tiny (a few hundred tokens) and cheap. The full article content is only loaded in the response step once.</p><h2>Set up AI connectors</h2><p>We use two AI connectors for the workflow:</p><p>Connector</p><p>Model</p><p>Type</p><p>Role</p><p>Mistral Small</p><p>mistral-small-latest</p><p>Custom (OpenAI-compatible)</p><p>Classify query complexity from metadata, answer simple FAQ-style questions</p><p>Anthropic Claude Sonnet 4.6</p><p>Claude Sonnet</p><p>Elastic Managed LLM</p><p>Synthesize complex answers from multiple articles, with citations</p><p>Both connectors are billed per million tokens, with the smaller model costing significantly less. Routing simple queries to it saves money on top of the latency win. To learn more about Elastic Managed LLM, see this <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/elastic-managed-llm">documentation</a>.</p><p>The Claude Sonnet connector is already available as an Elastic Managed large language model (LLM). We only need to create a custom connector for Mistral using the <code>.gen-ai</code> connector type, which supports any <a href="https://developers.openai.com/api/reference/overview">OpenAI-compatible API</a>. You can also <a href="https://www.elastic.co/docs/reference/kibana/connectors-kibana/ai-connector#set-up-an-ai-connector">create it through the Kibana UI</a>.</p><p>All the setup code in this article is available in the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/routing-queries-right-model-elasticsearch/notebook.ipynb">companion notebook</a>. You can run each section there as you follow along.</p>SMALL_LLM_CONNECTOR = "Mistral Small"

headers = {
    "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
    "kbn-xsrf": "true",
    "Content-Type": "application/json",
}

mistral_connector_payload = {
    "connector_type_id": ".gen-ai",
    "name": SMALL_LLM_CONNECTOR,
    "config": {
        "apiProvider": "Other",
        "apiUrl": "https://api.mistral.ai/v1/chat/completions",
        "defaultModel": "mistral-small-latest",
    },
    "secrets": {
        "apiKey": MISTRAL_API_KEY,
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/actions/connector",
    headers=headers,
    json=mistral_connector_payload,
)
result = response.json()
MISTRAL_CONNECTOR_ID = result.get("id")<p>The connector ID is auto-generated by Kibana. We let the platform handle this instead of trying to set it manually.</p><p>Once created, the connector appears in the Connectors UI:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt690e9245c0f19580/6a3e41fdb0216c2b556945dc/77179f792bb585dd060deac4ac9bc1c87ec1027a-1999x872.png" alt="Screenshot of a Connectors dashboard showing third‑party integrations for alerting data. The table lists AI connectors including Google Gemini 2.5 Pro, Google Gemini 3.0 Flash, Google Gemini 3.1 Pro (Preview), Mistral Small, and OpenAI GPT‑4.1. Each row displays type, compatibility, and authentication method. The focus is on the Mistral Small row." /><h2>Load and index the dataset</h2><p>We use the <a href="https://huggingface.co/datasets/rjac/e-commerce-customer-support-qa">e-commerce-customer-support-qa</a> dataset from Hugging Face. It contains 1,000 real customer support interactions from an ecommerce platform (BrownBox) with customer questions, agent solutions, issue categories, complexity levels, and customer sentiment.</p><p>The index mapping uses <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/semantic-text.html"><code>semantic_text</code></a> with the <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text"><code>.jina-embeddings-v5-text-small</code></a> model from <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a>. This field handles <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> end-to-end: <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">embedding generation</a>, <a href="https://www.elastic.co/search-labs/blog/chunking-strategies-elasticsearch">chunking</a>, and querying. We use <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> to aggregate the conversation and QA summary into a single searchable field:</p>es_client.indices.create(
    index="support-knowledge-base",
    mappings={
        "properties": {
            "conversation": {
                "type": "text",
                "copy_to": "semantic_content",
            },
            "qa": {
                "type": "text",
                "copy_to": "semantic_content",
            },
            "issue_area": {"type": "keyword"},
            "issue_category": {"type": "keyword"},
            "issue_complexity": {"type": "keyword"},
            "product_category": {"type": "keyword"},
            "semantic_content": {
                "type": "semantic_text",
                "inference_id": ".jina-embeddings-v5-text-small",
            },
        }
    },
)<h2>Defining the query routing workflow in Elastic Workflows YAML</h2><p>The routing workflow has four steps: semantic search, metadata-only classification, conditional branching, and a model-appropriate response step.</p><p>We use <a href="https://www.elastic.co/search-labs/blog/elastic-workflows-automation">Elastic Workflows</a> to encapsulate this routing logic. Workflows let us:</p><ol><li><p><strong>Expose the triaging as a tool</strong> in <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Elastic Agent Builder</a>, so a conversational agent can call it.</p></li><li><p><strong>Trigger it directly</strong> via manual execution, schedules, or alerts.</p></li></ol><p>This flexibility means the same logic serves both programmatic and conversational interfaces without duplicating code.</p><p>Workflows are defined in YAML and configured directly in the Workflow UI (<strong>Elasticsearch &gt; Workflows &gt; Create a New Workflow</strong>). Each step can query Elasticsearch, call Kibana APIs, or prompt an LLM.</p><p>Here’s the complete workflow definition:</p>name: support_query_router
description: &gt;
  Routes customer queries to the appropriate LLM based on complexity.
  Searches the KB, classifies using only result metadata (cheap),
  then routes to a small or large model depending on complexity.
enabled: true

inputs:
  - name: query
    type: string
    description: The customer support query
    required: true

consts:
  indexName: support-knowledge-base

triggers:
  - type: manual

steps:
  # Step 1: Search the knowledge base using semantic search
  - name: search_es
    type: elasticsearch.search
    with:
      index: "{{ consts.indexName }}"
      query:
        semantic:
          field: semantic_content
          query: "{{ inputs.query }}"
      size: 5

  # Step 2: Classify using only METADATA (Mistral Small - cheap)
  # We deliberately do NOT pass the full documents here. The routing
  # decision only needs to know the shape of the results: which
  # categories they hit, their complexity labels, and their scores.
  - name: classify_query
    type: ai.prompt
    with:
      connectorId: Mistral Small
      prompt: &gt;
        You are a support query classifier. Based on the customer query
        and the metadata of the top knowledge base hits below, decide
        how this query should be handled.

        Return ONLY a JSON object with:
        - "complexity": "simple" if the top hit clearly matches a single
          FAQ (high score, low-complexity category, single product area),
          or "complex" if the query spans multiple categories, the top
          hits have medium/high complexity labels, or the results are
          weakly matched.
        - "reasoning": one-line explanation.

        Customer query: {{ inputs.query }}

        Top 5 results (metadata only):
        1. score={{ steps.search_es.output.hits.hits[0]._score }}, category={{ steps.search_es.output.hits.hits[0]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[0]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[0]._source.product_category }}
        2. score={{ steps.search_es.output.hits.hits[1]._score }}, category={{ steps.search_es.output.hits.hits[1]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[1]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[1]._source.product_category }}
        3. score={{ steps.search_es.output.hits.hits[2]._score }}, category={{ steps.search_es.output.hits.hits[2]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[2]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[2]._source.product_category }}
        4. score={{ steps.search_es.output.hits.hits[3]._score }}, category={{ steps.search_es.output.hits.hits[3]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[3]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[3]._source.product_category }}
        5. score={{ steps.search_es.output.hits.hits[4]._score }}, category={{ steps.search_es.output.hits.hits[4]._source.issue_category_sub_category }}, complexity={{ steps.search_es.output.hits.hits[4]._source.issue_complexity }}, product={{ steps.search_es.output.hits.hits[4]._source.product_category }}

  # Step 3: Route based on complexity
  - name: route_by_complexity
    type: if
    condition: "${{ steps.classify_query.output.complexity == 'simple' }}"
    steps:
      # Simple: answer directly from FAQ snippet (Mistral Small)
      - name: answer_from_faq
        type: ai.prompt
        with:
          connectorId: Mistral Small
          prompt: &gt;
            You are a customer support agent. Answer the customer's question
            using ONLY the FAQ article below. Be concise, friendly, and
            include specific steps if applicable.

            Customer query: {{ inputs.query }}

            FAQ article:
            {{ steps.search_es.output.hits.hits[0]._source | json }}
    else:
      # Complex: synthesize from multiple articles (Claude Sonnet)
      - name: synthesize_answer
        type: ai.prompt
        with:
          connectorId: Anthropic Claude Sonnet 4.6
          prompt: &gt;
            You are a senior customer support specialist. The customer's query
            requires careful analysis across multiple knowledge base articles.

            Provide a detailed, empathetic response that:
            1. Addresses all aspects of the customer's question
            2. Cites specific articles from the knowledge base (reference them
               by their question/title)
            3. Provides clear resolution steps
            4. Notes if any part of the query isn't covered by the KB

            Customer query: {{ inputs.query }}

            Knowledge base articles:
            {{ steps.search_es.output.hits.hits | json }}<p>The workflow has four key parts:</p><p></p><ol><li><p><strong><code>search_es</code></strong> uses <code>elasticsearch.search</code> with a <a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-semantic-query">semantic query</a> to find the five most relevant articles.</p></li><li><p><strong><code>classify_query</code></strong>sends the customer query plus <strong>only metadata</strong> from the search results to Mistral Small. The prompt includes scores, categories, complexity labels, and product categories. This keeps the classification step cheap, preventing the use of large amounts of tokens.</p></li><li><p><strong><code>route_by_complexity</code></strong> uses an <code>if</code> step to branch based on the classifier's output.</p></li><li><p><strong>The response step</strong> depends on the route. For simple queries, Mistral Small gets the top FAQ article and rephrases it. For complex queries, Claude Sonnet gets all five articles and synthesizes a detailed response with citations. This is the only step where full document content is loaded.</p></li></ol><h2>Using the workflow as a tool in Agent Builder</h2><p>Beyond the default triggers (manual, schedule, alerts), workflows can also be exposed as <strong>tools in </strong><a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder"><strong>Agent Builder</strong></a>. This adds a conversational layer where users interact through a chat interface, and the agent decides when to call the workflow.</p><p>We use the <a href="https://www.elastic.co/docs/solutions/search/agent-builder/kibana-api">Agent Builder APIs</a> to create the tool and the agent. After creating the workflow in the Kibana UI, copy its ID and use it to register the workflow as a tool:</p>WORKFLOW_ID = "workflow-aaf77e41-37cf-48a8-973b-c853f71e4fae"

# Create the workflow tool
workflow_tool_payload = {
    "id": "run_support_query_router",
    "type": "workflow",
    "description": (
        "Routes a customer support query through the triage workflow. "
        "Searches the knowledge base, classifies query complexity, and "
        "generates a response using the appropriate model. Use this tool "
        "whenever a customer asks a support question."
    ),
    "tags": ["support", "triage", "workflow"],
    "configuration": {
        "workflow_id": WORKFLOW_ID,
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/tools",
    headers=headers,
    json=workflow_tool_payload,
)<p>Then create an agent that uses the tool:</p>agent_payload = {
    "id": "support-query-agent",
    "name": "Support Query Agent",
    "description": "Customer support agent that routes queries through a multi-model workflow.",
    "labels": ["support", "e-commerce"],
    "configuration": {
        "instructions": (
            "You are a customer support assistant for BrownBox, an e-commerce platform. "
            "When a customer asks a support question, use the `run_support_query_router` tool "
            "to process it. The tool will search the knowledge base, classify the query, "
            "and generate an appropriate response.\n\n"
            "Present the response to the customer in a friendly, professional tone."
        ),
        "tools": [{"tool_ids": ["run_support_query_router"]}],
    },
}

response = requests.post(
    f"{KIBANA_URL}/api/agent_builder/agents",
    headers=headers,
    json=agent_payload,
)<p>The agent is now available in the <a href="https://www.elastic.co/docs/solutions/search/elastic-agent-builder">Agent Builder</a> UI in Kibana. You can also create the agent and its tools directly through the <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-agents#custom-agents">Agent Builder UI</a>.</p><p>Once created, the agent appears in the Agent Builder UI with the workflow tool assigned:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec33d305e3d75727/6a3e4200517ae220fcf2fbfa/cc1b7c60969568bfe96d12ecc0c7174869144a75-1999x961.png" alt="" /><h2>Testing simple vs. complex query routing</h2><h3>Simple query</h3>"How do I track my order?"<p>The workflow searches the knowledge base, finds a direct match in the FAQ articles about order tracking, classifies it as <strong>simple</strong>, and routes to Mistral Small. The response is concise and drawn directly from the matched article: instructions for using the "My Orders" section or the tracking number from the confirmation email.</p><h3>Complex query</h3>"I ordered an OTG last week and it arrived damaged. I also noticed I was
charged twice on my credit card. I want a replacement for the OTG and a
refund for the duplicate charge. Also, my account shows the wrong delivery
address - can you update it?"<p>This query involves three distinct issues (damaged product, duplicate charge, address update) across different support categories. The workflow classifies it as <strong>complex</strong> and routes to Claude Sonnet, which synthesizes information from multiple knowledge base articles, addresses each issue separately, cites the relevant articles, and provides clear resolution steps for each.</p><h2>Conclusion</h2><p>Routing LLM queries by complexity in Elasticsearch reduces latency and cost for simple queries without sacrificing quality on complex ones. The small model answers FAQ-style queries in a fraction of the time the larger model would take, and the larger model is reserved for the queries that actually benefit from its capabilities. Cost savings come along for the ride: Simple queries routed to the smaller model are cheaper, too.</p><p>The pattern that makes this work is searching the knowledge base before routing. Without that context, the router is guessing based on surface-level cues. With it, the structure of the search results, like scores, categories, and complexity labels, tells the router whether the answer lives in a single article or needs synthesis across several. That's the actual signal for how to handle the query.</p><p>Elastic Workflows makes this possible without writing orchestration code. The entire routing logic lives in YAML inside Kibana, using native steps for search, LLM prompts, and conditional branching. Combined with Agent Builder, the same workflow serves programmatic triggers and conversational interfaces.</p><h2>Next steps</h2><ul><li><p>Try the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/routing-queries-right-model-elasticsearch/notebook.ipynb">notebook</a> with the complete implementation.</p></li><li><p>Add <a href="https://www.elastic.co/search-labs/blog/llm-monitoring-openrouter-agent-builder">LLM monitoring with OpenRouter</a> to track cost per routing tier.</p></li><li><p>Explore <a href="https://www.elastic.co/search-labs/blog/elastic-workflows-automation">Elastic Workflows</a> for other automation patterns.</p></li><li><p>Learn more about <a href="https://www.elastic.co/search-labs/blog/agent-builder-elastic-ga">Agent Builder</a> and how to expose workflows as conversational tools.</p></li><li><p>Read about <a href="https://www.elastic.co/search-labs/blog/ai-agentic-workflows-elastic-ai-agent-builder">building AI agentic workflows</a> with Elastic Agent Builder.</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/llm-query-routing-elastic-workflows</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/llm-query-routing-elastic-workflows</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt469309ae05518d1c/6a3e4203d473dd14db0cb146/5a9cb32bda53bcb51b45e0bcf8a64ac184d45588-1672x941.png" length="0" type="image/png"/>
    <pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[6 resources, 1 command: fully automated Elastic anomaly detection with Terraform]]></title>
    <description><![CDATA[Build and manage Elastic anomaly detection jobs entirely in Terraform (job config, datafeed, lifecycle state and environment promotion) with a modular, ready-to-clone example.]]></description>
    <content:encoded><![CDATA[<p>Anomaly detection jobs created by hand don't version, don't review and don't promote cleanly across environments. This post shows how to manage the full AD lifecycle (job, datafeed and operational state) as Terraform code. Six resources, one terraform apply, and your job is running. One variable change promotes it from dev to production. terraform destroy tears it all down in the correct order.</p><p>The complete, ready-to-clone code is available at <a href="https://github.com/elastic/terraform-ad-example/">github.com/elastic/terraform-ad-example</a>.</p><h2><strong>Prerequisites</strong></h2><ul><li><p>An Elastic Cloud account with an organization-level API key. See<a href="https://www.elastic.co/docs/reference/cloud/cloud-hosted/ec-regions-templates-instances"> Elastic Cloud regions, deployment templates, and instances</a> for available regions and templates.</p></li><li><p>Terraform installed (&gt;= 1.0.0). The Elastic Stack provider version 0.14.0 or later is required for anomaly detection (AD) job and datafeed resource support. See <a href="https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli#install-terraform">Install Terraform</a>.</p></li><li><p>A terminal opened in a directory containing the clone of the git repo <a href="https://github.com/elastic/terraform-ad-example/"><strong>github.com/elastic/terraform-ad-example</strong></a>.</p></li><li><p>A valid index suitable for AD jobs (that is, with a timestamp field) should exist in the Elasticsearch cluster once it’s deployed. In this example, the index is <code>filebeat-nginx-elasticco-full</code>.</p></li></ul><h2><strong>Terraform project structure for anomaly detection</strong></h2><p>A modular layout means that each resource has its own module, so job configs, datafeeds and state controllers can be shared and reused across teams independently.</p>.
├── main.tf                        # Root: providers, variables, module calls
└── modules/
    ├── job/
    │   ├── main.tf                # AD job resource
    │   ├── variables.tf           # Job parameters
    │   └── outputs.tf             # Exports job_id
    ├── datafeed/
    │   ├── main.tf                # Datafeed resource
    │   ├── variables.tf           # Datafeed parameters
    │   └── outputs.tf             # Exports datafeed_id
    ├── job_state/
    │   ├── main.tf                # Job state resource (open / close)
    │   ├── variables.tf           # State parameters
    │   └── outputs.tf             # Exports state
    └── datafeed_state/
        ├── main.tf                # Datafeed state resource (start / stop)
        ├── variables.tf           # State parameters
        └── outputs.tf             # Exports state<p>Outputs are key: They allow modules to be chained so that the datafeed automatically receives the <code>job_id</code> from the job module, and Terraform derives the correct creation and destruction order from this dependency graph.</p><h3>Why separate state from config in Terraform ML jobs?</h3><p>Note how the <strong>state modules are separate from the configuration modules</strong>. This reflects a real operational pattern in machine learning (ML): You’ll frequently need to stop a datafeed (for example, to reindex data) or close a job (for example, to reset a model after a pipeline incident) without changing the job's configuration at all. Keeping them separate means operational actions don't create noisy diffs in your config resources.</p><h2><strong>Configuring the Elastic Cloud deployment in Terraform</strong></h2><h3><strong>Providers and deployment</strong></h3><p>We use two providers: <code>elastic/ec</code> to provision the Elastic Cloud deployment; and <code>elastic/elasticstack</code> to manage the ML resources within it. The <code>elasticstack</code> provider's connection details are derived directly from the <code>ec_deployment</code> resource, so credentials are never hard-coded:</p>terraform {
  required_version = "&gt;= 1.0.0"

  required_providers {
    ec = {
      source  = "elastic/ec"
      version = "~&gt; 0.9"
    }
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~&gt; 0.14.3"
    }
  }
}

variable "ec_api_key" {
  type        = string
  description = "Elastic Cloud API key (account-level)."
}

variable "ec_region" {
  type        = string
  default     = "us-east-1"
  description = "Elastic Cloud region (e.g. us-east-1, gcp-us-central1)."
}

variable "deployment_template_id" {
  type        = string
  default     = "aws-cpu-optimized-faster-warm-arm"
  description = "Elastic Cloud deployment template ID."
}

variable "job_id" {
  description = "The ID of the anomaly detection job."
  type        = string
  default     = "nginx"
}

variable "datafeed_id" {
  description = "The ID of the datafeed."
  type        = string
  default     = "datafeed-nginx"
}

variable "indices" {
  description = "A list of indices for the datafeed (may include wildcards)."
  type        = list(string)
  default     = ["filebeat-nginx-elasticco-full"]
}<p>The deployment itself provisions Elasticsearch (with a dedicated ML node) and Kibana:</p>provider "ec" {
  apikey = var.ec_api_key
}

data "ec_stack" "latest" {
  version_regex = "latest"
  region        = var.ec_region
}

resource "ec_deployment" "demo" {
  name                   = "ml_terraform_example"
  region                 = var.ec_region
  version                = data.ec_stack.latest.version
  deployment_template_id = var.deployment_template_id

  elasticsearch = {
    hot = {
      autoscaling = {}
    }
    ml = {
      size          = "1g"
      size_resource = "memory"
      zone_count    = 1
      autoscaling   = {}
    }
  }

  kibana = {
    topology = {}
  }
}

provider "elasticstack" {
  elasticsearch {
    username  = ec_deployment.demo.elasticsearch_username
    password  = ec_deployment.demo.elasticsearch_password
    endpoints = [ec_deployment.demo.elasticsearch.https_endpoint]
  }

  kibana {
    endpoints = [ec_deployment.demo.kibana.https_endpoint]
  }
}<p>The <code>ml</code> block within <code>elasticsearch</code> is essential; it provisions a dedicated ML node. Without it, ML jobs cannot be opened. Here we allocate 1 GB of memory in a single availability zone, which is sufficient for this example. Depending on the characteristics of your AD job and your data, you may need to size your ML node differently.</p><p>Because the <code>elasticstack</code> provider references <code>ec_deployment.demo</code> directly, Terraform understands the dependency: It will provision the deployment first and then use the resulting credentials and endpoints automatically.</p><p><strong>Wiring the modules together</strong></p>module "job" {
  source = "./modules/job"
  job_id = var.job_id
}

module "datafeed" {
  source      = "./modules/datafeed"
  datafeed_id = var.datafeed_id
  job_id      = module.job.job_id
  indices     = var.indices
}

module "job_state" {
  source = "./modules/job_state"
  job_id = module.job.job_id
  state  = "closed"
}

module "datafeed_state" {
  source      = "./modules/datafeed_state"
  datafeed_id = module.datafeed.datafeed_id
  state       = "stopped"

  depends_on = [module.job_state]
}<p>The output references (<code>module.job.job_id</code>, <code>module.datafeed.datafeed_id</code>) create an implicit dependency graph: Terraform will always create the job before the datafeed, and the datafeed before its state resource. On destroy, the order is automatically reversed.</p><p>In the diagram below, solid arrows represent the <strong>implicit dependency graph</strong> created when one module’s outputs feed into another’s inputs. In contrast, the dotted arrow between <code>job_state</code> and <code>datafeed_state</code> denotes the explicit <code>depends_on</code> defined in the root <code>main.tf</code>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e7a48a93410f8ac/6a467b2bd8b87a37c9ac40b8/8d098e10becad172ef1fa1643737852379d8c71a-713x467.png" alt="Diagram titled “Module dependency graph (create order top → bottom)” showing dependencies among four modules." /><p>The explicit <code>depends_on</code> on <code>module.datafeed_state</code> deserves explanation: There's no data flow between the datafeed state and job state modules, but the Elasticsearch API requires the job to be open before a datafeed can start. Without this dependency, Terraform would attempt both in parallel, which would fail.</p><p>We start with the job <code>"closed"</code> and the datafeed <code>"stopped"</code>. We'll open and start them in later steps to demonstrate lifecycle management.</p><h2><strong>Module: Anomaly detection job</strong></h2><p>The job module defines the AD job configuration. Here's the resource itself (<code>modules/job/main.tf</code>):</p>resource "elasticstack_elasticsearch_ml_anomaly_detection_job" "nginx" {
  job_id          = var.job_id
  description     = "Anomaly detection for network traffic"
  custom_settings = jsonencode(var.custom_settings)

  analysis_config = {
    bucket_span = "15m"
    detectors = [
      {
        function             = "count"
        detector_description = "count"
      },
      {
        function             = "mean"
        field_name           = "nginx.access.body_sent.bytes"
        detector_description = "mean(\"nginx.access.body_sent.bytes\")"
      }
    ]
    influencers        = ["nginx.access.geoip.city_name", "nginx.access.user_agent.build"]
    model_prune_window = "30d"
  }

  analysis_limits = {
    model_memory_limit            = var.analysis_limits.model_memory_limit
    categorization_examples_limit = var.analysis_limits.categorization_examples_limit
  }

  data_description = {
    time_field  = "@timestamp"
    time_format = "epoch_ms"
  }

  model_snapshot_retention_days             = var.model_snapshot_retention_days
  daily_model_snapshot_retention_after_days = var.daily_model_snapshot_retention_after_days
}<p>There are a few things worth noting regarding module reuse:</p><ul><li><p>The Elasticsearch job API stores arbitrary metadata in a JSON object called custom_settings. In this module, that object is whatever you pass in as the Terraform variable <code>custom_settings</code>: The resource sets <code>custom_settings = jsonencode(var.custom_settings)</code>, so the cluster receives the JSON encoding of that map. The default value that is defined in <code>variables.tf</code> is therefore exactly the default metadata (<code>created_by = "terraform" and department = "ITOps</code>) unless a caller overrides <code>custom_settings</code> when invoking the module (for example, to record ownership when importing a legacy job that was created outside Terraform).</p></li><li><p>The same pattern applies to the other tunables: <code>analysis_limits</code>, <code>model_snapshot_retention_days</code>, and <code>daily_model_snapshot_retention_after_days</code> are variables with defaults so the module works out of the box, while teams can override them at the call site (for instance, raising <code>model_memory_limit</code> for a higher-cardinality job).</p></li></ul><p>Variable</p><p>Default</p><p>Purpose</p><p>custom_settings</p><p>created_by = "terraform"</p><p>Arbitrary job metadata; override to record ownership</p><p>analysis_limits.model_memory_limit</p><p>(see variables.tf)</p><p>Tune up for higher-cardinality jobs</p><p>model_snapshot_retention_days</p><p>(see variables.tf)</p><p>Retention period for model snapshots</p><p>The full variable definitions and outputs are in the <a href="https://github.com/elastic/terraform-ad-example/">GitHub repo</a>.</p><h2><strong>Module: Datafeed</strong></h2><p>The datafeed module connects an index pattern to an AD job and is the primary parameterization point for service teams. (<code>modules/datafeed/main.tf</code>):</p>resource "elasticstack_elasticsearch_ml_datafeed" "this" {
  datafeed_id = var.datafeed_id
  job_id      = var.job_id
  query = jsonencode({
    bool = {
      must = [{ match_all = {} }]
    }
  })
  indices = var.indices
}<p>The <code>indices</code> variable is the key parameterization point; each service team passes its own index pattern when calling the module.</p><h2><strong>Modules: Job state and datafeed state</strong></h2><p>Job state and datafeed state are managed by separate modules, so operational actions (stopping a datafeed, closing a job) don't require a config plan to execute.</p># modules/job_state/main.tf
resource "elasticstack_elasticsearch_ml_job_state" "this" {
  job_id      = var.job_id
  state       = var.state       # "opened" or "closed"
  job_timeout = var.job_timeout  # default: "30s"
}

# modules/datafeed_state/main.tf
resource "elasticstack_elasticsearch_ml_datafeed_state" "this" {
  datafeed_id      = var.datafeed_id
  state            = var.state            # "started" or "stopped"
  force            = var.force            # default: false
  datafeed_timeout = var.datafeed_timeout  # default: "60s"
}<h2><strong>How to run and apply the anomaly detection Terraform config</strong></h2><h3><strong>Set your API key and initialize</strong></h3>export TF_VAR_ec_api_key="your-elastic-cloud-api-key-here"
terraform init<p>The repo also includes an <code>elastic-env.sh</code> helper for managing secrets. See the <a href="https://github.com/elastic/terraform-ad-example/blob/main/README.md">README</a> for details.</p><h3><strong>Plan and create the resources</strong></h3>terraform plan<p>The plan shows all six resources that will be created:</p><ul><li><p>The Elastic Cloud deployment.</p></li><li><p>The AD job.</p></li><li><p>The datafeed.</p></li><li><p>The two state resources.</p></li><li><p>A scoped API key for bulk ingestion.</p></li></ul><p>Review the output carefully; this is one of Terraform's greatest strengths. Here's the key section:</p>Plan: 6 to add, 0 to change, 0 to destroy.<p>Once satisfied, apply:</p>terraform applyec_deployment.demo: Creating...
ec_deployment.demo: Creation complete after 1m54s
elasticstack_elasticsearch_security_api_key.bulk_ingest: Creating...
module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx: Creation complete after 0s
elasticstack_elasticsearch_security_api_key.bulk_ingest: Creation complete after 0s
module.job_state.elasticstack_..._job_state.this: Creation complete after 0s
module.datafeed.elasticstack_..._datafeed.this: Creation complete after 0s
module.datafeed_state.elasticstack_..._datafeed_state.this: Creation complete after 0s

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.<p>Notice the creation order:</p><ul><li><p>The deployment provisions first (~2 minutes).</p></li><li><p>Then the API key.</p></li><li><p>Then the AD job.</p></li><li><p>Then the datafeed and job state in parallel.</p></li><li><p>And finally the datafeed state.</p></li></ul><p>Terraform derived this order automatically from the dependency graph:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e7a48a93410f8ac/6a467b2bd8b87a37c9ac40b8/8d098e10becad172ef1fa1643737852379d8c71a-713x467.png" alt="Diagram titled “Module dependency graph (create order top → bottom)” showing dependencies among four modules." /><p>At this point, you can confirm the job exists in Kibana's ML UI; the <code>nginx</code> job will be visible in the closed state.</p><p><strong>Load sample data</strong></p><p>The initial <code>terraform apply</code> also creates a scoped Elasticsearch API key for bulk ingestion. We can use it to load some test data. The <a href="https://github.com/elastic/terraform-ad-example/">repo</a> includes a file (<code>sample_data.ndjson</code>) with a few sample documents matching the job's expected fields (<code>@timestamp</code>, <code>nginx.access.body_sent.bytes</code>, and the <code>influencer</code> fields: <code>nginx.access.geoip.city_name</code> and <code>nginx.access.user_agent.build</code>). It can be loaded into the deployment using the Elasticsearch <code>_bulk</code> API:</p>ES_URL=$(terraform output -raw elasticsearch_https_endpoint)
ES_API_KEY=$(terraform output -raw elasticsearch_api_key 2&gt;/dev/null)

curl -s -XPOST "${ES_URL}/_bulk" \
  -H "Authorization: ApiKey ${ES_API_KEY}" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @sample_data.ndjson<p>In practice, you'd want many more documents (hundreds to thousands across weeks/months) for the anomaly detection model to learn meaningful baselines; this sample is enough to verify that the pipeline works end to end.</p><h3><strong>Open the job</strong></h3><p>Change the state parameter in the <code>job_state</code> module call (defined in the top level <code>main.tf</code> file):</p>module "job_state" {
  source = "./modules/job_state"
  job_id = module.job.job_id
  state  = "opened"    # was "closed"
}terraform apply<p>Terraform updates only the job state resource; the job configuration and datafeed are untouched:</p>Apply complete! Resources: 0 added, 1 changed, 0 destroyed.<h3><strong>Start the datafeed</strong></h3><p>Similarly, update the datafeed state:</p>module "datafeed_state" {
  source      = "./modules/datafeed_state"
  datafeed_id = module.datafeed.datafeed_id
  state       = "started"    # was "stopped"

  depends_on = [module.job_state]
}terraform apply<p>The datafeed is now running. Since we haven't specified start or end times, it will process all available data in its indices and will continue polling for new data in real time.</p><h3><strong>Cleaning up</strong></h3><p>When you're done, a single command tears everything down in the correct reverse order:</p><ul><li><p>Datafeed state first.</p></li><li><p>Then job state.</p></li><li><p>Then datafeed.</p></li><li><p>Then job.</p></li><li><p>Then API key.</p></li><li><p>And then the deployment:</p></li></ul>terraform destroyDestroy complete! Resources: 6 destroyed.<h2><strong>How do you promote anomaly detection jobs from dev to production with Terraform?</strong></h2><p>With this modular structure, promoting a job from dev to production becomes a variable change rather than a manual migration. The platform team validates the job against a dev cluster and then updates a single variable:</p># terraform.tfvars (or a workspace-specific file)
ec_region = "us-west-2"         # production region
indices   = ["filebeat-nginx-prod-*"]<p>The same Terraform configuration, the same modules, the same reviewed workflow, just different parameters.</p><p>In practice, you'd use separate <a href="https://developer.hashicorp.com/terraform/cloud-docs/workspaces/best-practices">Terraform workspaces</a> or <code>.tfvars</code> files per environment, feeding into a continuous integration and continuous deployment (CI/CD) pipeline.</p><h2><strong>How do I import existing anomaly detection jobs into Terraform?</strong></h2><p>If you already have AD jobs running that were created through the UI or API, the provider supports importing them into Terraform state:</p>terraform import module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx &lt;deployment_id&gt;/nginx<p>This lets you gradually shift legacy jobs under Terraform management without recreating them.</p><h2><strong>What's next</strong></h2><p>Future releases of the Elasticsearch Terraform provider will add support for ML calendars and filters resources. In the meantime, this modular pattern can be extended to manage other Elasticsearch resources alongside your AD jobs.</p><p>To experience the full benefits, upgrade to 9.3 (or later) or <a href="https://www.elastic.co/cloud/cloud-trial-overview">start your Elastic Security free trial</a>. If you're also managing detection rules, see<a href="https://www.elastic.co/security-labs/managing-rules-with-terraform"> Managing Elastic Security Detection Rules with Terraform</a>.</p><h3><strong>Resources</strong></h3><ul><li><p><a href="https://github.com/elastic/terraform-ad-example">Full example code on GitHub</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs/resources/elasticsearch_ml_anomaly_detection_job">Anomaly Detection in Terraform documentation</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/elasticstack/latest/docs">Elastic Stack Terraform Provider documentation</a></p></li><li><p><a href="https://registry.terraform.io/providers/elastic/ec/latest/docs">Elastic Cloud Terraform Provider documentation</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/cloud/cloud-hosted/ec-regions-templates-instances">Elastic Cloud regions, deployment templates, and instances</a></p></li></ul><h3>Frequently Asked Questions</h3><p><strong>How do I manage Elastic anomaly detection jobs with Terraform?</strong></p><p>Use the Elastic Stack Terraform provider (v0.14.0 or later), which includes native resources for anomaly detection jobs (<code>elasticstack_elasticsearch_ml_anomaly_detection_job</code>), datafeeds and their operational state. A single <code>terraform apply</code> provisions the job, datafeed and all state resources in the correct dependency order.</p><p><strong>Why should I separate anomaly detection job state from job configuration in Terraform?</strong></p><p>Job state (open/closed) and datafeed state (started/stopped) change frequently during normal operations (reindexing, model resets, pipeline incidents) without any change to the underlying configuration. Keeping them in separate Terraform modules means operational actions don't produce diffs in your config resources and don't require a full config plan to execute.</p><p><strong>Can I import existing Elastic anomaly detection jobs into Terraform without recreating them?</strong></p><p>Yes. The Elastic Stack Terraform provider supports <code>terraform import</code> for existing AD jobs. Run <code>terraform import module.job.elasticstack_elasticsearch_ml_anomaly_detection_job.nginx &lt;deployment_id&gt;/nginx</code> to bring a job created through the Kibana UI or Elasticsearch API under Terraform management without deleting and recreating it.</p><p><strong>How do I promote an anomaly detection job from a dev to a production cluster with Terraform?</strong></p><p>With a modular Terraform layout, environment promotion is a variable change. Update <code>ec_region</code> and <code>indices</code> in your <code>.tfvars</code> file or workspace variable, then run <code>terraform apply</code> against the production cluster. The same reviewed configuration runs in both environments: no manual migration, no UI steps.</p><p><strong>What size ML node do I need for Terraform-managed anomaly detection on Elastic Cloud?</strong></p><p>The example allocates 1 GB of memory in a single availability zone, which is sufficient for low-cardinality AD jobs. Higher-cardinality jobs or larger datasets require a larger <code>size</code> value in the <code>ml</code> block of the Elasticsearch resource and the <code>model_memory_limit</code> variable in the job module is the primary tuning point.</p><p><strong>Why does the Elasticsearch Terraform provider require an explicit </strong><strong><code>depends_on</code></strong><strong> between datafeed state and job state?</strong></p><p>The Elasticsearch API requires a job to be open before its datafeed can start. Because there is no data flow between the two state modules, Terraform would otherwise attempt to start both in parallel and fail. The explicit <code>depends_on = [module.job_state]</code> in the root <code>main.tf</code> enforces the required sequencing.</p><p><strong>What is the difference between the Elastic Cloud Terraform provider and the Elastic Stack Terraform provider?</strong></p><p>The <code>elastic/ec</code> provider provisions Elastic Cloud infrastructure (deployments, node topology, regions). The <code>elastic/elasticstack</code> provider manages resources within a running Elasticsearch cluster (ML jobs, datafeeds, security API keys, index settings). A typical setup uses both: <code>ec</code> to create the deployment, <code>elasticstack</code> to configure it, with credentials passed automatically between them.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/anomaly-detection-terraform-lifecycle</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/anomaly-detection-terraform-lifecycle</guid>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Ed Savage]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt81a160baf05cdd3a/6a467b2e477436c868d3edc1/2051a88b1d927bfb310d11ae9d6c238de86f13f7-1920x1080.png" length="0" type="image/png"/>
    <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Bringing Fire to Elasticsearch: Adding Native Prometheus API Support]]></title>
    <description><![CDATA[Query Elasticsearch directly from Prometheus-compatible clients via native PromQL, discovery, and metadata endpoints. Send data to Elasticsearch with Prometheus Remote Write.]]></description>
    <content:encoded><![CDATA[<p>Point any Prometheus-compatible client at Elasticsearch and run PromQL directly against your existing metrics. Elasticsearch is adding native Prometheus query, discovery, and metadata endpoints as a tech preview that work over metrics ingested through Prometheus Remote Write, OpenTelemetry, or the Bulk API. The API runs on top of Elasticsearch's time series data streams (TSDS), so there's no separate Prometheus-specific storage layer to operate.</p><p>This post explains how the query, discovery, and metadata endpoints build on the earlier ingest and query work to form that API surface. Companion posts go deeper on individual pieces:</p><ul><li><p><a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">Native PromQL support in ES|QL</a> covers how PromQL queries are translated into ES|QL execution plans.</p></li><li><p><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch">Ship Prometheus Metrics to Elasticsearch with Remote Write</a> covers ingestion setup.</p></li><li><p><a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">How Prometheus Remote Write Ingestion Works in Elasticsearch</a> covers the remote write internals.</p></li></ul><p>This is still a work in progress. The sections below call out what is supported today and which parts are still evolving.</p><h2>The API surface</h2><p>Today, the Prometheus-compatible API surface falls into three groups.</p><h3>Query endpoints</h3><p>The query endpoints let Prometheus-compatible clients evaluate PromQL expressions:</p><ul><li><p><code>GET /_prometheus/api/v1/query_range</code> evaluates a PromQL expression over a time window (matrix results).</p></li><li><p><code>GET /_prometheus/api/v1/query</code> evaluates at a single point in time (vector results). Currently implemented as a short range query that returns the last sample.</p></li></ul><p>Only GET is supported for query endpoints today. Some clients default to POST, so you may need to configure them to use GET. The Prometheus POST convention uses <code>application/x-www-form-urlencoded</code> bodies, which Elasticsearch's HTTP layer rejects as a CSRF safeguard before the request ever reaches the handler.</p><p>For the full PromQL coverage status, see the <a href="https://www.elastic.co/observability-labs/blog/elasticsearch-supports-promql">companion post on PromQL in ES|QL</a>.</p><h3>Metadata endpoints</h3><p>The metadata endpoints serve the discovery information that clients need for autocomplete, variable dropdowns, and metric browsing.</p><p>The series, labels, and label values endpoints all accept <code>match[]</code> selectors and a time range (<code>start</code>/<code>end</code>). The <code>match[]</code> parameter takes a Prometheus series selector like <code>http_requests_total{job="api"}</code> and restricts the response to time series that match. This keeps responses fast and relevant on clusters with large numbers of metrics. For example:</p>GET /_prometheus/api/v1/series?match[]=http_requests_total{job="api"}GET /_prometheus/api/v1/labels?match[]=http_requests_totalGET /_prometheus/api/v1/label/instance/values?match[]=http_requests_total{job="api"}<p>The first returns all series for <code>http_requests_total</code> where <code>job="api"</code>, with their full label sets. The second returns only the label names that exist on <code>http_requests_total</code> series. The third returns only the <code>instance</code> values that appear on matching series.</p><p><code>GET /_prometheus/api/v1/metadata</code> is different: it returns type and unit for each metric, optionally filtered by name via a <code>metric</code> parameter.</p>GET /_prometheus/api/v1/metadata?metric=http_requests_total<p>It does not accept <code>match[]</code> selectors or a time range. In Prometheus, metadata is collected from active scrape targets (the <code>HELP</code>, <code>TYPE</code>, and <code>UNIT</code> lines they expose), so the response does not involve a data scan. Elasticsearch does not have a dedicated metadata store like that, so the current implementation discovers metric metadata by visiting time series data from the last 24 hours. This keeps the query fast without requiring a full index scan. That 24-hour lookback is fixed today: the Prometheus metadata API does not expose <code>start</code> or <code>end</code> parameters that Elasticsearch could use to make it user-adjustable.</p><p>How the metadata endpoints work under the hood, including the <code>TS_INFO</code> and <code>METRICS_INFO</code> commands that power them, is covered <a href="https://www.elastic.co/search-labs/blog//elasticsearch-native-prometheus-api#ts-info-and-metrics-info">below</a>.</p><h3>Index pre-filtering</h3><p>All query and metadata endpoints accept an optional <code>{index}</code> path segment after <code>/_prometheus/</code>:</p>GET /_prometheus/metrics-prod-*/api/v1/query_range?query=up&amp;start=...&amp;end=...<p>This restricts which Elasticsearch indices the query runs against before any expression evaluation begins. On clusters with many data streams across teams or environments, this avoids scanning unrelated indices and can significantly reduce query latency. You can configure separate data sources per index pattern to give teams scoped access to their own metrics.</p><h3>A note about Remote Write</h3><p>For ingestion, Elasticsearch also exposes the standard Prometheus Remote Write endpoint:</p><ul><li><p><code>POST /_prometheus/api/v1/write</code> ingests time series via the Prometheus Remote Write v1 protocol. v2 is not yet supported.</p></li></ul><p>Remote Write writes into Elasticsearch's existing time series data streams (TSDS), not a separate Prometheus-specific storage layer. Prometheus labels become TSDS dimensions, and metric names become fields in the index mapping. The <a href="https://www.elastic.co/observability-labs/blog/prometheus-remote-write-elasticsearch-architecture">remote write architecture post</a> covers the full mapping in detail, including how metric types are inferred and how labels are stored with a <code>labels.</code> prefix.</p><h3>How it works</h3><p>Under the hood, all endpoints work the same way: parse the incoming HTTP parameters, build an ES|QL query plan, execute it against time series data streams, and convert the columnar result back into the JSON format Prometheus clients expect.</p><h2>TS_INFO and METRICS_INFO</h2><p>The metadata endpoints need to answer questions like "what labels exist?" or "what metric types are defined?" across potentially millions of time series, without scanning every data point.</p><p>Internally, the Prometheus metadata endpoints answer those questions by building ES|QL plans around two new processing commands: <code>METRICS_INFO</code> and <code>TS_INFO</code>. You do not need to use these commands directly to use the Prometheus API, but they are the core execution primitives behind the metadata responses. Both work by visiting only one document per time series to extract its metadata, rather than scanning all samples. This means their cost scales with the number of distinct time series, not the number of data points.</p><p><code>METRICS_INFO</code> returns one row per distinct metric with its name, type, unit, and associated dimension fields. <code>TS_INFO</code> is more granular: one row per (metric, time series) combination, including the actual dimension values as a JSON object.</p><p>A dedicated blog post on <code>TS_INFO</code> and <code>METRICS_INFO</code> is coming soon, covering the two-phase execution model, how they scale, and how to use them directly in ES|QL queries beyond the Prometheus API.</p><h3>How the metadata endpoints use them</h3><p>Each metadata endpoint constructs an ES|QL plan with one of these commands at its core.</p><p><code>/api/v1/labels</code> and <code>/api/v1/series</code> use <code>TS_INFO</code>, since they need per-time-series detail (which labels exist, which dimension values identify each series). <code>/api/v1/metadata</code> and <code>/api/v1/label/__name__/values</code> use <code>METRICS_INFO</code>, since they only need per-metric information (metric names, types, units).</p><p><code>/api/v1/label/{name}/values</code> for regular labels (anything other than <code>__name__</code>) does not use either command. Regular labels like <code>job</code> or <code>instance</code> are actual dimension fields in the index, so the endpoint can query them directly with a group-by aggregation. When <code>match[]</code> selectors are provided, they are translated into a <code>WHERE</code> clause that filters the time series before the aggregation runs.</p><p>The <code>__name__</code> label needs a different strategy because it is not always present as a dimension field. Prometheus Remote Write does store <code>labels.__name__</code>, but metrics ingested through other paths (OpenTelemetry, the bulk API) do not have it. The metric name is encoded in the field name itself (e.g., <code>metrics.http_requests_total</code>). You could look at the index mappings to enumerate field names, but mappings alone do not tell you which metric has which dimensions, and they cannot be filtered by label values from a <code>match[]</code> selector. <code>METRICS_INFO</code> can do both: it enumerates metric names across indices while respecting upstream <code>WHERE</code> filters.</p><p>In all cases, the API layer handles the translation back to Prometheus conventions: stripping the <code>labels.</code> and <code>metrics.</code> storage prefixes and synthesizing <code>__name__</code> for non-Prometheus metrics that lack it.</p><h2>In conclusion</h2><p>The result: any Prometheus-compatible client can query and explore Elasticsearch metrics through endpoints it already understands. Remote Write metrics, OpenTelemetry metrics, and metrics indexed through other paths all show up through the same API, backed by the same TSDS indices.</p><p>All the Prometheus APIs mentioned here are available as tech preview in Elasticsearch Serverless today. For self-managed clusters and Elastic Cloud Hosted deployments, available as tech preview in Elasticsearch 9.4, with the exception of <code>GET /_prometheus/api/v1/metadata</code>. To experiment locally, use <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">start-local</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-native-prometheus-api</guid>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Felix Barnsteiner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt12b4e100d5bbb7f0/6a16f7a22b835ff747f4afdd/c7b333bd73e8a1f4e18486b2d692ba742788dcfd-1376x768.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Creating an Elasticsearch MCP server with TypeScript]]></title>
    <description><![CDATA[Learn how to create an Elasticsearch MCP server with TypeScript and Claude Desktop.]]></description>
    <content:encoded><![CDATA[<p>When working with large knowledge bases in Elasticsearch, finding information is only half the battle. Engineers often need to synthesize results from multiple documents, generate summaries, and trace answers back to their sources. Model Context Protocol (MCP) provides a standardized way to connect Elasticsearch with large language model–powered (LLM-powered) applications to accomplish this. While Elastic offers official solutions, like Elastic Agent Builder (which includes an <a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">MCP endpoint</a> among its features), building a custom MCP server gives you full control over search logic, result formatting, and how retrieved content is passed to an LLM for synthesis, summaries, and citations.</p><p>In this article, we’ll explore the benefits of building a custom Elasticsearch MCP server and show how to create one in TypeScript that connects Elasticsearch to LLM-powered applications.</p><h2>Why build a custom Elasticsearch MCP server?</h2><p>Elastic provides some alternatives for <a href="https://www.elastic.co/docs/solutions/search/mcp">MCP servers</a>:</p><ul><li><p><a href="https://www.elastic.co/docs/solutions/search/agent-builder/mcp-server">Elastic Agent Builder MCP server for Elasticsearch 9.2+</a></p></li><li><p><a href="https://github.com/elastic/mcp-server-elasticsearch?tab=readme-ov-file#elasticsearch-mcp-server">Elasticsearch MCP server for older versions (Python)</a></p></li></ul><p>If you need more control over how your MCP server interacts with Elasticsearch, building your own custom server gives you the flexibility to tailor it exactly to your needs. For example, Agent Builder's MCP endpoint is limited to Elasticsearch Query Language (ES|QL) queries, while a custom server allows you to use the full Query DSL. You also gain control over how results are formatted before being passed to the LLM and can integrate additional processing steps, like the OpenAI-powered summarization we'll implement in this tutorial.</p><p>By the end of this article, you’ll have an MCP server in TypeScript that searches for information stored in an Elasticsearch index, summarizes it, and provides citations. We'll use Elasticsearch for retrieval, OpenAI's <code>gpt-4o-mini</code> model to summarize and generate citations, and Claude Desktop as the MCP client and UI to take in user queries and give responses. The end result is an internal knowledge assistant that helps engineers discover and synthesize best practices across their organization’s technical docs.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltad9133cb083ad352/6a170c19b0367d411e72bd5b/ec5771a874cf9740d4cac6888622cbe8cd6aede7-1999x1133.png" alt="Creating an Elastic MCP server with TypeScript and Claude Desktop." /><h2>Prerequisites:</h2><ul><li><p>Node.js 20 +</p></li><li><p>Elasticsearch</p></li><li><p>OpenAI API key</p></li><li><p>Claude Desktop</p></li></ul><h3>What is MCP?</h3><p><a href="https://www.elastic.co/what-is/mcp">MCP</a> is an open standard, created by <a href="https://www.anthropic.com/news/model-context-protocol">Anthropic</a>, that provides secure, bidirectional connections between LLMs and external systems, like Elasticsearch. You can read more about the current state of MCP in <a href="https://www.elastic.co/search-labs/blog/mcp-current-state">this article</a>.</p><p>The MCP landscape is <a href="https://www.elastic.co/search-labs/blog/mcp-current-state#mcp-project-updates:-transport,-elicitation,-and-structured-tooling">evolving every day</a>, with servers available for a wide range of use cases. On top of that, it’s easy to build your own custom MCP server, as we’ll show in this article.</p><h3>MCP clients</h3><p>There’s a long <a href="https://modelcontextprotocol.io/clients">list of available MCP clients</a>, each with its own characteristics and limitations. For simplicity and popularity, we’ll use <a href="https://claude.ai/download">Claude Desktop</a> as our MCP client. It will serve as the chat interface where users can ask questions in natural language, and it will automatically invoke the tools exposed by our MCP server to search documents and generate summaries.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt06fd7a02042094e1/6a170c1b14b2700024e3c651/66eb0b11473347b6cf2d85718251eeac38d6249d-1999x1491.png" alt="Claude 4.5 Sonnet page, with the note, &quot;Coffee and Claude time? How can I help you today?&quot;" /><h2>Creating an Elasticsearch MCP server</h2><p>Using the <a href="https://github.com/modelcontextprotocol/typescript-sdk">TypeScript SDK</a>, we can easily create a server that understands how to query our Elasticsearch data based on a user query input.</p><p>Here are the steps in this article to integrate the Elasticsearch MCP server with the Claude Desktop client:</p><ol><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#configure-mcp-server-for-elasticsearch">Configure MCP server for Elasticsearch.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#load-the-mcp-server-into-claude-desktop">Load the MCP server into Claude Desktop.</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude#test-it-out">Test it out.</a></p></li></ol><h3>Configure MCP server for Elasticsearch</h3><p>To begin, let's initialize a node application:</p>npm init -y<p>This will create a <code>package.json</code> file, and with it, we can start installing the necessary dependencies for this application.</p>npm install @elastic/elasticsearch @modelcontextprotocol/sdk openai zod &amp;&amp; npm install --save-dev ts-node @types/node typescript<ul><li><p><strong>@elastic/elasticsearch</strong> will give us access to the Elasticsearch Node.js library.</p></li><li><p><strong>@modelcontextprotocol/sdk</strong> provides the core tools to create and manage an MCP server, register tools, and handle communication with MCP clients.</p></li><li><p><strong>openai</strong> allows interaction with OpenAI models to generate summaries or natural language responses.</p></li><li><p><a href="https://zod.dev/"><strong>zod</strong></a>helps define and validate structured schemas for input and output data in each tool.</p></li></ul><p><code>ts-node</code>, <code>@types/node</code>, and <code>typescript</code> will be used during development to type the code and compile the scripts.</p><h4>Set up the dataset</h4><p>To provide the data that Claude Desktop can query using our MCP server, we’ll use a mock <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/dataset.json">internal knowledge base dataset</a>. Here’s what a document from this dataset will look like:</p>{
    "id": 5,
    "title": "Logging Standards for Microservices",
    "content": "Consistent logging across microservices helps with debugging and tracing. Use structured JSON logs and include request IDs and timestamps. Avoid logging sensitive information. Centralize logs in Elasticsearch or a similar system. Configure log rotation to prevent storage issues and ensure logs are searchable for at least 30 days.",
    "tags": ["logging", "microservices", "standards"]
}<p>To ingest the data, we prepared a script that creates an index in Elasticsearch and loads the dataset into it. You can find it <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/setup.ts">here</a>.</p><h4>MCP server</h4><p>Create a file named <a href="https://github.com/Delacrobix/typescript-elasticsearch-mcp/blob/main/index.ts"><code>index.ts</code></a> and add the following code to import the dependencies and handle environment variables:</p>// index.ts
import { z } from "zod";
import { Client } from "@elastic/elasticsearch";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import OpenAI from "openai";

const ELASTICSEARCH_ENDPOINT =
  process.env.ELASTICSEARCH_ENDPOINT ?? "http://localhost:9200";
const ELASTICSEARCH_API_KEY = process.env.ELASTICSEARCH_API_KEY ?? "";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
const INDEX = "documents";<p>Also, let’s initialize the clients to handle the Elasticsearch and OpenAI calls:</p>const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});

const _client = new Client({
  node: ELASTICSEARCH_ENDPOINT,
  auth: {
    apiKey: ELASTICSEARCH_API_KEY,
  },
});<p>To make our implementation more robust and ensure structured input and output, we'll define schemas using <a href="https://zod.dev/"><code>zod</code></a>. This allows us to validate data at runtime, catch errors early, and make the tool responses easier to process programmatically:</p>const DocumentSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
});

const SearchResultSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  tags: z.array(z.string()),
  score: z.number(),
});

type Document = z.infer&lt;typeof DocumentSchema&gt;;
type SearchResult = z.infer&lt;typeof SearchResultSchema&gt;;<p>Learn more about structured outputs <a href="https://www.elastic.co/search-labs/blog/structured-outputs-elasticsearch-guide">here</a>.</p><p>Now let’s initialize the MCP server:</p>const server = new McpServer({
  name: "Elasticsearch RAG MCP",
  description:
    "A RAG server using Elasticsearch. Provides tools for document search, result summarization, and source citation.",
  version: "1.0.0",
});<h4>Defining the MCP tools</h4><p>With everything configured, we can start writing the tools that will be exposed by our MCP server. This server exposes two tools:</p><ul><li><p><strong><code>search_docs</code></strong><strong>: </strong>Searches for documents in Elasticsearch using full-text search.</p></li><li><p><strong><code>summarize_and_cite</code></strong><strong>:</strong> Summarizes and synthesizes information from previously retrieved documents to answer a user question. This tool also adds citations referencing the source documents.</p></li></ul><p>Together, these tools form a simple “retrieve-then-summarize” workflow, where one tool fetches relevant documents and the other uses those documents to generate a summarized, cited response.</p><h4>Tool response format</h4><p>Each tool can accept arbitrary input parameters, but it must respond with the following structure:</p><ul><li><p><strong>Content:</strong> This is the response of the tool in an unstructured format. This field is usually used to return text, images, audio, links, or embeddings. For this application, it will be used to return formatted text with the information generated by the tools.</p></li><li><p><strong>structuredContent: </strong>This is an optional return used to provide the results of each tool in a structured format. This is useful for programmatic purposes. Although it isn't used in this MCP server, it can be useful if you want to develop other tools or process the results programmatically.</p></li></ul><p>With that structure in mind, let’s dive into each tool in detail.</p><h4>Search_docs tool</h4><p>This tool performs a <a href="https://www.elastic.co/docs/solutions/search/full-text">full-text search</a> in the Elasticsearch index to retrieve the most relevant documents based on the user query. It highlights key matches and provides a quick overview with relevance scores.</p>server.registerTool(
  "search_docs",
  {
    title: "Search Documents",
    description:
      "Search for documents in Elasticsearch using full-text search. Returns the most relevant documents with their content, title, tags, and relevance score.",
    inputSchema: {
      query: z
        .string()
        .describe("The search query terms to find relevant documents"),
      max_results: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of results to return"),
    },
    outputSchema: {
      results: z.array(SearchResultSchema),
      total: z.number(),
    },
  },
  async ({ query, max_results }) =&gt; {
    if (!query) {
      return {
        content: [
          {
            type: "text",
            text: "Query parameter is required",
          },
        ],
        isError: true,
      };
    }

    try {
      const response = await _client.search({
        index: INDEX,
        size: max_results,
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query: query,
                  fields: ["title^2", "content", "tags"],
                  fuzziness: "AUTO",
                },
              },
            ],
            should: [
              {
                match_phrase: {
                  title: {
                    query: query,
                    boost: 2,
                  },
                },
              },
            ],
          },
        },
        highlight: {
          fields: {
            title: {},
            content: {},
          },
        },
      });

      const results: SearchResult[] = response.hits.hits.map((hit: any) =&gt; {
        const source = hit._source as Document;

        return {
          id: source.id,
          title: source.title,
          content: source.content,
          tags: source.tags,
          score: hit._score ?? 0,
        };
      });

      const contentText = results
        .map(
          (r, i) =&gt;
            `[${i + 1}] ${r.title} (score: ${r.score.toFixed(
              2,
            )})\n${r.content.substring(0, 200)}...`,
        )
        .join("\n\n");

      const totalHits =
        typeof response.hits.total === "number"
          ? response.hits.total
          : (response.hits.total?.value ?? 0);

      return {
        content: [
          {
            type: "text",
            text: `Found ${results.length} relevant documents:\n\n${contentText}`,
          },
        ],
        structuredContent: {
          results: results,
          total: totalHits,
        },
      };
    } catch (error: any) {
      console.log("Error during search:", error);

      return {
        content: [
          {
            type: "text",
            text: `Error searching documents: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);<p><em>We configure </em><a href="https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query"><em><code>fuzziness</code></em></a><em><code>: “AUTO”</code></em><em> to have a variable typo tolerance based on the length of the token that’s being analyzed. We also set </em><em><code>title^2</code></em><em> to increase the score of the documents where the match happens on the title field.</em></p><h4>summarize_and_cite tool</h4><p>This tool generates a summary based on documents retrieved in the previous search. It uses OpenAI’s <code>gpt-4o-mini</code> model to synthesize the most relevant information to answer the user’s question, providing responses derived directly from the search results. In addition to the summary, it also returns citation metadata for the source documents used.</p>server.registerTool(
  "summarize_and_cite",
  {
    title: "Summarize and Cite",
    description:
      "Summarize the provided search results to answer a question and return citation metadata for the sources used.",
    inputSchema: {
      results: z
        .array(SearchResultSchema)
        .describe("Array of search results from search_docs"),
      question: z.string().describe("The question to answer"),
      max_length: z
        .number()
        .optional()
        .default(500)
        .describe("Maximum length of the summary in characters"),
      max_docs: z
        .number()
        .optional()
        .default(5)
        .describe("Maximum number of documents to include in the context"),
    },
    outputSchema: {
      summary: z.string(),
      sources_used: z.number(),
      citations: z.array(
        z.object({
          id: z.number(),
          title: z.string(),
          tags: z.array(z.string()),
          relevance_score: z.number(),
        })
      ),
    },
  },
  async ({ results, question, max_length, max_docs }) =&gt; {
    if (!results || results.length === 0 || !question) {
      return {
        content: [
          {
            type: "text",
            text: "Both results and question parameters are required, and results must not be empty",
          },
        ],
        isError: true,
      };
    }

    try {
      const used = results.slice(0, max_docs);

      const context = used
        .map(
          (r: SearchResult, i: number) =&gt;
            `[Document ${i + 1}: ${r.title}]\\n${r.content}`
        )
        .join("\n\n---\n\n");

      // Generate summary with OpenAI
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content:
              "You are a helpful assistant that answers questions based on provided documents. Synthesize information from the documents to answer the user's question accurately and concisely. If the documents don't contain relevant information, say so.",
          },
          {
            role: "user",
            content: `Question: ${question}\\n\\nRelevant Documents:\\n${context}`,
          },
        ],
        max_tokens: Math.min(Math.ceil(max_length / 4), 1000),
        temperature: 0.3,
      });

      const summaryText =
        completion.choices[0]?.message?.content ?? "No summary generated.";

      const citations = used.map((r: SearchResult) =&gt; ({
        id: r.id,
        title: r.title,
        tags: r.tags,
        relevance_score: r.score,
      }));

      const citationText = citations
        .map(
          (c: any, i: number) =&gt;
            `[${i + 1}] ID: ${c.id}, Title: "${c.title}", Tags: ${c.tags.join(
              ", ",
            )}, Score: ${c.relevance_score.toFixed(2)}`,
        )
        .join("\n");

      const combinedText = `Summary:\\n\\n${summaryText}\\n\\nSources used (${citations.length}):\\n\\n${citationText}`;

      return {
        content: [
          {
            type: "text",
            text: combinedText,
          },
        ],
        structuredContent: {
          summary: summaryText,
          sources_used: citations.length,
          citations: citations,
        },
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error generating summary and citations: ${error.message}`,
          },
        ],
        isError: true,
      };
    }
  }
);<p>Finally, we need to start the server using <a href="https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file#stdio">stdio</a>. This means the MCP client will communicate with our server by reading and writing to its standard input and output streams. stdio is the simplest transport option and works well for local MCP servers launched as subprocesses by the client. Add the following code at the end of the file:</p>const transport = new StdioServerTransport();
server.connect(transport);<p>Now compile the project using the following command:</p>npx tsc index.ts --target ES2022 --module node16 --moduleResolution node16 --outDir ./dist --strict --esModuleInterop<p>This will create a <code>dist</code> folder, and inside it, an <code>index.js</code> file.</p><h3>Load the MCP server into Claude Desktop</h3><p>Follow <a href="https://modelcontextprotocol.io/docs/develop/connect-local-servers">this guide</a> to configure the MCP server with Claude Desktop. In the Claude configuration file, we need to set the following values:</p>{
  "mcpServers": {
    "elasticsearch-rag-mcp": {
      "command": "node",
      "args": [   "/Users/user-name/app-dir/dist/index.js"
      ],
      "env": {
        "ELASTICSEARCH_ENDPOINT": "your-endpoint-here",
        "ELASTICSEARCH_API_KEY": "your-api-key-here",
        "OPENAI_API_KEY": "your-openai-key-here"
      }
    }
  }
}<p>The <code>args</code> value should point to the compiled file in the <code>dist</code> folder. You also need to set the environment variables in the configuration file with the exact same names defined in the code.</p><h3>Test it out</h3><p>Before executing each tool, click on <strong>Search and Tools</strong> to make sure that the tools are enabled. Here you can also enable or disable each one:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt395a7337021f9820/6a170c1c67045bb74d45c228/172981c2a54adabc70d5819013c3007670935605-1999x1002.png" alt="Claude 4.5 Sonnet page, with the note, &quot;Good afternoon, Jeff. How can I help you today?&quot;" /><p>Finally, let’s test the MCP server from the Claude Desktop chat and start asking questions:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf4ac458dc0206271/6a170c1e66c4f91328f8c072/03654c0f8c53c714f801fba8b25747071179209b-1999x1353.png" alt="User search request in Claude Desktop chat for documents about authentication methods and role-based access control, along with Claude's responses." /><p>For the question “<strong>Search for documents about authentication methods and role-based access control</strong>”, the <code>search_docs</code> tool is executed and returns the following results:</p>Most Relevant Documents:
Access Control and Role Management (highest relevance) - This document covers role-based access control (RBAC) principles, including ensuring users only have necessary permissions, regular auditing of user roles, revoking inactive accounts, and implementing just-in-time access for sensitive operations.
User Authentication with OAuth 2.0 - This document explains OAuth 2.0 authentication, which enables secure delegated access without credential sharing. It covers configuring identity providers, token management with limited scope and lifetime, and secure storage of refresh tokens.
Container Security Guidelines - While primarily about container security, this document touches on access control aspects like running containers as non-root users and avoiding embedded credentials.
Incident Response Playbook - This mentions role assignment during incidents (incident commander, communications lead, etc.), which relates to access control in emergency scenarios.
Logging Standards for Microservices - This document includes guidance on avoiding logging sensitive information, which is relevant to authentication security.<p>The response is, “Great! I found 5 relevant documents about authentication methods and role-based access control. Here's what was found:”</p><p>The tool call returns the source documents as part of its response payload, which are later used to generate citations.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbaf48a9468770ce2/6a170c21509168bffee1bb14/25ff4c7e9563d99752f95540dafdc7fd211a66e3-800x530.gif" alt="Claude 4.5 Sonnet page, with scrolling responses that include the five relevant documents about authentication methods and role-based access control." /><p>It’s also possible to chain multiple tools in a single interaction. In this case, Claude Desktop analyzes the user's question and determines that it needs to first call <code>search_docs</code> to retrieve relevant documents and to then pass those results to <code>summarize_and_cite</code> to generate the final answer, all without requiring separate prompts from the user:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta46ff45274e64192/6a170c230c4857a91501aac1/e6a8a46acb4236e77058f18bcd2f0737b5882c05-1999x1101.png" alt="Claude Desktop chat, with the note, &quot;Jeff returns,&quot; plus a new user question, &quot;What are the main recommendations to improve authentication and access control across our systems? Include references.&quot;" /><p>In this case, for the query “<strong>What are the main recommendations to improve authentication and access control across our systems? Include references.</strong>”, we obtained the following results:</p>Based on the documentation, here are the main recommendations to improve authentication and access control across your systems:

Key Recommendations

1. Implement Role-Based Access Control (RBAC)
Ensure users have only the permissions necessary for their job functions. This principle of least privilege minimizes security risks by limiting access to what's truly needed. [1]

2. Regular Access Audits
Conduct regular audits of user roles and promptly revoke access for inactive accounts. This helps prevent unauthorized access from dormant or compromised accounts. [1]

3. Just-in-Time (JIT) Access
Implement just-in-time access for sensitive operations, where permissions are granted only when needed and for a limited duration. This reduces the attack surface by minimizing standing privileges. [1]

4. OAuth 2.0 for Secure Authentication
Use OAuth 2.0 to enable secure delegated access without sharing user credentials. This protocol allows third-party applications to access resources on behalf of users without exposing passwords. [2]

5. Token Security and Management
Configure your identity provider to issue tokens with limited scope and lifetime

Store refresh tokens securely
Validate access tokens consistently to maintain security [2]
References

Access Control and Role Management (Tags: security, access-control)
User Authentication with OAuth 2.0 (Tags: authentication, oauth)
These recommendations work together to create a defense-in-depth approach, where multiple security layers protect your systems from unauthorized access.<p>As in the previous step, we can see the response from each tool for this question:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8f633c518e708a99/6a170c25ab7f082991db9ed6/cb606d356b2f7d5e4878a5eff71bc881869ac0ee-800x585.gif" alt="Claude Desktop chat page, with scrolling text that includes the response from each tool for the question, “What are the main recommendations to improve authentication and access control across our systems? Include references.”" /><p><em>Note: If a submenu appears asking whether you approve the use of each tool, select </em><em><strong>Always allow</strong></em><em> or </em><em><strong>Allow once</strong></em><em>.</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6627ee0bff1862df/6a170c266f7f040f6f91488c/aea942ba9b0037526ea215bec65690f1a5c3099c-1522x250.png" alt="Claude Desktop &quot;Always allow&quot; and &quot;Allow once&quot; options for a user to choose from." /><h2>Conclusion</h2><p>MCP servers represent a significant step toward standardizing LLM tools for both local and remote applications. Though full compatibility is still in the works, we’re moving fast in that direction.</p><p>In this article, we learned how to build a custom MCP server in TypeScript that connects Elasticsearch to LLM-powered applications. Our server exposes two tools: <code>search_docs</code> for retrieving relevant documents using Query DSL; and <code>summarize_and_cite</code> for generating summaries with citations via OpenAI models and Claude Desktop as client UI.</p><p>The future of compatibility between different client and server providers looks promising. Next steps include adding more functionalities and flexibility to your agent. There’s a practical <a href="https://www.elastic.co/search-labs/blog/llm-functions-elasticsearch-intelligent-query">article</a> on how you can parameterize your queries using search templates to gain precision and flexibility.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elastic-mcp-server-typescript-claude</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5600198cb47666a5/6a170c28509168ce3ae1bb18/0bb24c05fff391f42070c2883182ea6fe9cb9680-1280x720.png" length="0" type="image/png"/>
    <pubDate>Fri, 27 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Using Elasticsearch Inference API along with Hugging Face models]]></title>
    <description><![CDATA[Learn how to connect Elasticsearch to Hugging Face models using inference endpoints, and build a multilingual blog recommendation system with semantic search and chat completions.]]></description>
    <content:encoded><![CDATA[<p>In recent updates, Elasticsearch introduced a native integration to connect to models hosted on the <a href="https://endpoints.huggingface.co/">Hugging Face Inference Service</a>. In this post, we’ll explore how to configure this integration and perform inference through simple API calls using a large language model (LLM). We’ll use <a href="https://huggingface.co/HuggingFaceTB/SmolLM3-3B">SmolLM3-3B</a>, a lightweight general-purpose model with a good balance between resource usage and answer quality.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9094997548bd70f8/6a170d6a839dfa0ad6dcff54/7ddadf1976421a860a7d62087239adb9150d808b-1999x1388.png" alt="Scatter plot showing several small language models plotted by model size (in billions of parameters) on the x‑axis and win rate (percentage) on the y‑axis. SmolLM3‑3B appears near the top of the efficiency trend, with a higher win rate than other models of similar size." /><h2>Prerequisites</h2><ul><li><p><strong>Elasticsearch 9.3 or Elastic Cloud Serverless: </strong>You can create a cloud deployment following <a href="https://www.elastic.co/search-labs/tutorials/install-elasticsearch/elastic-cloud">these instructions</a>, or you can use the <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart#local-dev-quick-start"><code>start-local</code></a> quickstart instead.</p></li><li><p><strong>Python 3.12: </strong>Download Python <a href="https://www.python.org/">here</a>.</p></li><li><p><strong>Hugging Face </strong><a href="https://huggingface.co/docs/hub/en/security-tokens">access token</a>.</p></li></ul><h2>Chat completions using a Hugging Face inference endpoint</h2><p>First, we’ll build a practical example that connects Elasticsearch to a Hugging Face <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put">inference endpoint</a> to generate AI-powered recommendations from a collection of blog posts. For the app knowledge base, we’ll use a dataset of company blog articles, which contains valuable but often hard-to-navigate information.</p><p>With this endpoint, <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> retrieves the most relevant articles for a given query, and a Hugging Face LLM generates short, contextual recommendations based on those results.</p><p>Let’s take a look at a high-level overview of the information flow we’re going to build:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf217b7b7db4e1e6c/6a170d6ca929cf8022ae0a3b/1dfbc2323438feaaa42e13ab242dd1f7166f74aa-1200x676.png" alt="Flow diagram showing an Elasticsearch index feeding semantic search results into an inference endpoint, which returns article recommendations." /><p>In this article, we’ll test <strong>SmolLM3-3B </strong>capacity tocombine its compact size with strong multilingual reasoning and tool-calling capabilities. Based on a search query, we’ll send all the matching content (in English and Spanish) to the LLM to generate a list of recommended articles with a custom-made description based on the search query and results.</p><p>Here’s what the UI of an article site with an AI recommendations generation system could look like.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt20e69b9a06fecd65/6a170d6e839dfa6f97dcff58/8d3b86b212f28ff279f2da67a33e6134039f0e4e-1999x949.png" alt="UI of an article site with an AI recommendations generation system, listing three examples, with text in English and titles in either English or Spanish." /><p>You can find the full implementation of this application in the linked <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/notebook.ipynb">notebook</a>.</p><h3>Configuring Elasticsearch inference endpoints</h3><p>To use the Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face inference endpoint</a>, we need two important elements: a Hugging Face API key and a running Hugging Face endpoint URL. It should look like this:</p>PUT _inference/chat_completions/hugging-face-smollm3-3b
{
    "service": "hugging_face",
    "service_settings": {
        "api_key": "hugging-face-access-token", 
        "url": "url-endpoint" 
    }
}<p>The Hugging Face inference endpoint in Elasticsearch supports different task types: <code>text_embedding</code>, <code>completion</code>, <code>chat_completion</code>, and <code>rerank</code>. In this blog post, we use <code>chat_completion</code> because we need the model to generate conversational recommendations based on the search results and a system prompt.This endpoint allows us to perform chat completions directly from Elasticsearch in a simple way using the Elasticsearch API:</p>POST _inference/chat_completion/hugging-face-smollm3-3b/_stream
{
  "messages": [
      { "role": "user", "content": "&lt;user prompt&gt;" }
  ]
}<p>This will serve as the core of the application, receiving the prompt and the search results that will pass through the model. With the theory covered, let’s start implementing the application.</p><h4>Setting up ​​inference endpoint on Hugging Face</h4><p>To deploy the Hugging Face model, we’re going to use <a href="https://huggingface.co/inference-endpoints/dedicated">Hugging Face one-click deployments</a>, an easy and fast service for deploying model endpoints. Keep in mind that this is a paid service, and using it may incur additional costs. This step will create the model instance that will be used to generate the recommendations of the articles.</p><p>You can pick a model from the one-click catalog:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta7bdfa43d6766324/6a170d6fb339d59e5476a039/b816e9fba1fe172687bf58f5143fb1f838c1077f-549x331.png" alt="Interface view of a model catalog filtered to “smoll3,” showing one model named “smollm3‑3b” with text generation, vLLM, GPU 1× Nvidia L4, and a listed price of $0.8, plus a note suggesting extending the search to all Hugging Face models." /><p>Let’s pick the <strong>SmolLM3-3B</strong> model:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdb0a2e6ffd7deb20/6a170d710c48574b7401aafc/610d3aba0429f3666c2df3616d513eb6a4397c0c-502x478.png" alt="Interface for creating an endpoint for the SmolLM3‑3B model, showing the model name, a &quot;verified by Hugging Face&quot; note, an endpoint name field, a cost of $0.80 per hour per running replica, a cURL option, and a &quot;Create Endpoint&quot; button." /><p>From here, grab the Hugging Face endpoint URL:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt25714021711ed6ff/6a170d72c1e8a54853f88336/025094ddb2cfbd1f0f216a5ec4e119b0f4fa2c42-646x328.png" alt="Dashboard view of a Hugging Face inference endpoint named “smollm3‑3b‑pnz,” showing a green Running status, one active replica, zero requests in the last hour, navigation tabs, and the displayed endpoint URL." /><p>As mentioned in the Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-inference-put-hugging-face">Hugging Face inference endpoints documentation</a>, text generation requires a model that’s compatible with the OpenAI API. For that reason, we need to append the <code>/v1/chat/completions</code> subpath to the Hugging Face endpoint URL. The final result will look like this:</p>https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions<p>With this in place, we can start coding in a Python notebook.</p><h4>Generating Hugging Face API key</h4><p>Create a <a href="https://huggingface.co/join">Hugging Face account</a>, and obtain an API token by following <a href="https://huggingface.co/docs/hub/en/security-tokens#user-access-tokens">these instructions</a>. You can choose between three token types: <em>fine-grained</em> (recommended for production, as it provides access only to specific resources); <em>read</em> (for read-only access); or <em>write</em> (for read and write access). For this tutorial, a read token is sufficient, since we only need to call the inference endpoint. Save this key for the next step.</p><h4>Setting up Elasticsearch inference endpoint</h4><p>First, let’s declare an Elasticsearch Python client:</p>os.environ["ELASTICSEARCH_API_KEY"] = "your-elasticsearch-api-key"
os.environ["ELASTICSEARCH_URL"] = "https://xxxx.us-central1.gcp.cloud.es.io:443"

es_client = Elasticsearch(
    os.environ["ELASTICSEARCH_URL"], api_key=os.environ["ELASTICSEARCH_API_KEY"]
)<p>Next, let’s create an Elasticsearch inference endpoint that uses the Hugging Face model. This endpoint will allow us to generate responses based on the blog posts and the prompt passed to the model.</p>INFERENCE_ENDPOINT_ID = "smollm3-3b-pnz"

os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"] = (
 "https://j2g31h0futopfkli.us-east-1.aws.endpoints.huggingface.cloud/v1/chat/completions"
)
os.environ["HUGGING_FACE_API_KEY"] = "hf_xxxxx"

resp = es_client.inference.put(
        task_type="chat_completion",
        inference_id=INFERENCE_ENDPOINT_ID,
        body={
            "service": "hugging_face",
            "service_settings": {
                "api_key": os.environ["HUGGING_FACE_API_KEY"],
                "url": os.environ["HUGGING_FACE_INFERENCE_ENDPOINT_URL"],
            },
        },
    )<h3>Dataset</h3><p>The dataset contains the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/dataset.json">blog posts</a> that will be queried, representing a multilingual content set used throughout the workflow:</p>// Articles dataset document example: 
{
    "id": "6",
    "title": "Complete guide to the new API: Endpoints and examples",
    "author": "Tomas Hernandez",
    "date": "2025-11-06",
    "category": "tutorial",
    "content": "This guide describes in detail all endpoints of the new API v2. It includes code examples in Python, JavaScript, and cURL for each endpoint. We cover authentication, resource creation, queries, updates, and deletion. We also explain error handling, rate limiting, and best practices. Complete documentation is available on our developer portal."
  }<h4>Elasticsearch mappings</h4><p>With the dataset defined, we need to create a data schema that properly fits the blog post structure. The following <a href="https://www.elastic.co/docs/manage-data/data-store/mapping">index mappings</a> will be used to store the data in Elasticsearch:</p>INDEX_NAME = "blog-posts"

mapping = {
    "mappings": {
        "properties": {
            "id": {"type": "keyword"},
            "title": {
                "type": "object",
                "properties": {
                    "original": {
                        "type": "text",
                        "copy_to": "semantic_field",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                    "translated_title": {
                        "type": "text",
                        "fields": {"keyword": {"type": "keyword"}},
                    },
                },
            },
            "author": {"type": "keyword", "copy_to": "semantic_field"},
            "category": {"type": "keyword", "copy_to": "semantic_field"},
            "content": {"type": "text", "copy_to": "semantic_field"},
            "date": {"type": "date"},
            "semantic_field": {"type": "semantic_text"},
        }
    }
}


es_client.indices.create(index=INDEX_NAME, body=mapping)<p>Here, we can see more clearly how the data is structured. We’ll use semantic search to retrieve results based on natural language, along with the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> property to copy the field contents into the <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> field. Additionally, the <code>title</code> field contains two subfields: the <code>original</code> subfield stores the title in either English or Spanish, depending on the original language of the article; and the <code>translated_title</code> subfield is present only for Spanish articles and contains the English translation of the original title.</p><h3>Ingesting data</h3><p>The following code snippet ingests the blog posts dataset into Elasticsearch using the <a href="https://www.elastic.co/docs/reference/elasticsearch/clients/javascript/bulk_examples">bulk API</a>:</p>def build_data(json_file, index_name):
    with open(json_file, "r") as f:
        data = json.load(f)

    for doc in data:
        action = {"_index": index_name, "_source": doc}
        yield action


try:
    success, failed = helpers.bulk(
        es_client,
        build_data("dataset.json", INDEX_NAME),
    )
    print(f"{success} documents indexed successfully")

    if failed:
        print(f"Errors: {failed}")
except Exception as e:
    print(f"Error: {str(e)}")<p>Now that we have the articles ingested into Elasticsearch, we need to create a function capable of searching against the <code>semantic_text</code> field:</p>def perform_semantic_search(query_text, index_name=INDEX_NAME, size=5):
    try:
        query = {
            "query": {
                "match": {
                    "semantic_field": {
                        "query": query_text,
                    }
                }
            },
            "size": size,
        }

        response = es_client.search(index=index_name, body=query)
        hits = response["hits"]["hits"]

        return hits
    except Exception as e:
        print(f"Semantic search error: {str(e)}")
        return []<p>We also need a function that calls the inference endpoint. In this case, we’ll call the endpoint using the <strong><code>chat_completion</code></strong>task type to get streaming responses:</p>def stream_chat_completion(messages: list, inference_id: str = INFERENCE_ENDPOINT_ID):
    url = f"{ELASTICSEARCH_URL}/_inference/chat_completion/{inference_id}/_stream"
    payload = {"messages": messages}
    headers = {
        "Authorization": f"ApiKey {ELASTICSEARCH_API_KEY}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.post(url, json=payload, headers=headers, stream=True)
        response.raise_for_status()

        for line in response.iter_lines(decode_unicode=True):
            if line:
                line = line.strip()

                if line.startswith("event:"):
                    continue

                if line.startswith("data: "):
                    data_content = line[6:]

                    if not data_content.strip() or data_content.strip() == "[DONE]":
                        continue

                    try:
                        chunk_data = json.loads(data_content)

                        if "choices" in chunk_data and len(chunk_data["choices"]) &gt; 0:
                            choice = chunk_data["choices"][0]
                            if "delta" in choice and "content" in choice["delta"]:
                                content = choice["delta"]["content"]
                                if content:
                                    yield content

                    except json.JSONDecodeError as json_err:
                        print(f"\nJSON decode error: {json_err}")
                        print(f"Problematic data: {data_content}")
                        continue

    except requests.exceptions.RequestException as e:
        yield f"Error: {str(e)}"<p>Now we can write a function that calls the semantic search function, along with the <code>chat_completions</code> inference endpoint and the recommendations endpoint, to generate the data that will be allocated in the cards:</p>def recommend_articles(search_query, index_name=INDEX_NAME, max_articles=5):
    print(f"\n{'='*80}")
    print(f"🔍 Search Query: {search_query}")
    print(f"{'='*80}\n")

    articles = perform_semantic_search(search_query, index_name, size=max_articles)

    if not articles:
        print("❌ No relevant articles found.")
        return None, None

    print(f"✅ Found {len(articles)} relevant articles\n")

    # Build context with found articles
    context = "Available blog articles:\n\n"
    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)
        context += f"Article {i}:\n"
        context += f"- Title: {source.get('title', 'N/A')}\n"
        context += f"- Author: {source.get('author', 'N/A')}\n"
        context += f"- Category: {source.get('category', 'N/A')}\n"
        context += f"- Date: {source.get('date', 'N/A')}\n"
        context += f"- Content: {source.get('content', 'N/A')}\n\n"

    system_prompt = """You are an expert content curator that recommends blog articles.

    Write recommendations in a conversational style starting with phrases like:
    - "If you're interested in [topic], this article..."
    - "This post complements your search with..."
    - "For those looking into [topic], this article provides..."


    FORMAT REQUIREMENTS:
    - Return ONLY a JSON array
    - Each element must have EXACTLY these three fields: "article_number", "title", "recommendation"
    - If the original title is in spanish, use the "translated_title" subfield in the "title" field

    Keep each recommendation concise (2-3 sentences max) and focused on VALUE to the reader.

    EXAMPLE OF CORRECT FORMAT:
    [
        {"article_number": 1, "title": "Article title in english", "recommendation": "If you are interested in [topic], this article provides..."},
        {"article_number": 2, "title": "Article title in english", "recommendation": " for those looking into [topic], this article provides..."}
    ]

    Return ONLY the JSON array following this exact structure."""

    user_prompt = f"""Search query: "{search_query}"

    Generate recommendations for the following articles: {context}
    """

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

    # LLM generation
    print(f"{'='*80}")
    print("🤖 Generating personalized recommendations...\n")

    full_response = ""

    for chunk in stream_chat_completion(messages):
        print(chunk, end="", flush=True)
        full_response += chunk

    return context, articles, full_response<p>Finally, we need to extract the information and format it to be printed:</p>def display_recommendation_cards(articles, recommendations_text):
    print("\n" + "=" * 100)
    print("📇 RECOMMENDED ARTICLES".center(100))
    print("=" * 100 + "\n")

    # Parse JSON recommendations - clean tags and extract JSON
    recommendations_list = []
    try:

        # Clean up &lt;think&gt; tags
        cleaned_text = re.sub(
            r"&lt;think&gt;.*?&lt;/think&gt;", "", recommendations_text, flags=re.DOTALL
        )
        # Remove markdown code blocks ( ... ``` or ``` ... ```)
        cleaned_text = re.sub(r"```(?:json)?", "", cleaned_text)
        cleaned_text = cleaned_text.strip()

        parsed = json.loads(cleaned_text)

        # Extract recommendations from list format
        for item in parsed:
            article_number = item.get("article_number")
            title = item.get("title", "")
            rec_text = item.get("recommendation", "")

            if article_number and rec_text:
                recommendations_list.append(
                    {
                        "article_number": article_number,
                        "title": title,
                        "recommendation": rec_text,
                    }
                )
    except json.JSONDecodeError as e:
        print(f"⚠️  Could not parse recommendations as JSON: {e}")
        return

    for i, article in enumerate(articles, 1):
        source = article.get("_source", article)

        # Card border
        print("┌" + "─" * 98 + "┐")

        # Find recommendation and title for this article number
        recommendation = None
        title = None
        for rec in recommendations_list:
            if rec.get("article_number") == i:
                recommendation = rec.get("recommendation")
                title = rec.get("title")
                break

        # Print title
        title_lines = textwrap.wrap(f"📌 {title}", width=94)
        for line in title_lines:
            print(f"│  {line}".ljust(99) + "│")

        # Card border
        print("├" + "─" * 98 + "┤")

        # Print recommendation
        if recommendation:
            recommendation_lines = textwrap.wrap(recommendation, width=94)
            for line in recommendation_lines:
                print(f"│  {line}".ljust(99) + "│")

        # Card bottom
        print("└" + "─" * 98 + "┘")<p>Let’s test this by asking a question about the security blog posts:</p>search_query = "Security and vulnerabilities"

context, articles, recommendations = recommend_articles(search_query)

print("\nElasticsearch context:\n", context)

# Display visual cards
display_recommendation_cards(articles, recommendations)<p>Here we can see the cards in the console generated by the workflow:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4aa221a08a51aeb3/6a170d7460084be1413c45d6/730d35212594bb3db30447c3ea7e2a92857287b7-1999x1515.png" alt="Section titled “Recommended Articles” showing five boxed article summaries, including topics on an authentication system vulnerability, migration risks, REST API v2 performance and authentication improvements, notification system changes, and a complete guide to the new API." /><p>You can see the full results, including all hits and the LLM response, in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/elasticsearch-inference-api-and-hugging-face/results.md">this file</a>.</p><p>We’re asking for articles related to: “Security and vulnerabilities.” This question is used as the search query against the documents stored in Elasticsearch. The retrieved results are then passed to the model, which generates recommendations based on their content. As we can see, the model did a great job generating engaging short text that can motivate the reader to click on it.</p><h2>Conclusion</h2><p>This example shows how Elasticsearch and Hugging Face can be combined to create a fast and efficient centralized system for AI applications. This approach reduces manual effort and provides flexibility, thanks to Hugging Face’s extensive model catalog. Using SmolLM3-3B, in particular, shows how compact, multilingual models can still deliver meaningful reasoning and content generation when paired with semantic search. Together, these tools offer a scalable and effective foundation for building intelligent content analysis and multilingual applications.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hugging-face-elasticsearch-inference-api</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5f961af4cb26ec97/6a170d767d8d6790c770e790/1417d6ff033712206c9bd4bcc22074ee3437ce96-1999x1125.png" length="0" type="image/png"/>
    <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Hybrid search with Java: LangChain4j Elasticsearch integration]]></title>
    <description><![CDATA[Learn how to use hybrid search in LangChain4j via its Elasticsearch integrations, with a complete Java example.]]></description>
    <content:encoded><![CDATA[<p>In our <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> on hybrid search with Elasticsearch in LangChain, we explained why hybrid search can help retrieve better results than simple vector search, along with how it works. We recommend reading that article first.</p><p>In addition to Python and JavaScript, the LangChain ecosystem also has a community-driven Java project called <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>, which will be the focus of this article, showing how powerful hybrid search can be by writing a complete application using LangChain4j, Elasticsearch, and Ollama.</p><h2>Setting up the environment</h2><h3>Running a local Elasticsearch instance</h3><p>Before running the examples, you'll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><p>&gt; <strong>Note: This script is for local testing only. Do not use it in production. For production installations, refer to the </strong><a href="https://www.elastic.co/downloads/elasticsearch"><strong>official documentation</strong></a><strong> for Elasticsearch.</strong></p><h3>Running a local Ollama instance</h3><p>You’ll also need to connect your application to an embedding model. Although you can choose between any provider supported by LangChain4j (check the <a href="https://docs.langchain4j.dev/integrations/language-models/">complete list</a>), for this example we’ll be using Ollama, which can be easily set up locally following the <a href="https://docs.ollama.com/quickstart">quickstart</a>.</p><h2>Let’s start coding</h2><p>The idea for the application is simple: Given a dataset of movies (taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>), we want to be able to find movies whose descriptions are relevant to our queries. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub repo</a>, along with the full code for this demo.</p><h2>Step 1: Dependencies and environment</h2><p>Open your favorite integrated development environment (IDE), create a new blank project, preferably with a modern Java version (we’re using Java24) and a gradle/maven version to match (in our case, Gradle 9.0).</p><p>We only need three dependencies:</p>dependencies {
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.17.0")
    implementation("dev.langchain4j:langchain4j-elasticsearch:1.11.0-beta19")
    implementation("dev.langchain4j:langchain4j-ollama:1.11.0")
}<p>The first one is needed to ingest the data that we’ll embed and query; the other two are the necessary LangChain4j dependencies to connect and manage our Elasticsearch vector store and Ollama embedding model.</p><p>The best way to connect to the external services is to set up environment variables and set them at the start of our main function:</p>String elasticsearchServerUrl = System.getenv("ES_LOCAL_URL");
String elasticsearchApiKey = System.getenv("ES_LOCAL_API_KEY");

String ollamaUrl = System.getenv("ollama-url");
String ollamaModelName = System.getenv("model-name");<h2>Step 2: Ingesting the dataset</h2><p>Since the dataset is a CSV, we’ll be using <a href="https://github.com/FasterXML/jackson-dataformats-text">Jackson dataformat</a>’s <code>jackson-dataformat-csv</code> to easily read the data and map it to a Java class, defined as:</p>public record Movie(
    String movie_id,
    String movie_name,
    Integer year,
    String genre,
    String description,
    String director
) {
}<p>Now we can create an instance of <code>CsvSchema</code> mapping the CSV structure and read the file into an iterator:</p>CsvSchema schema = CsvSchema.builder()                    
    .addColumn("movie_id") // same order as in the csv    
    .addColumn("movie_name")                              
    .addColumn("year")                                    
    .addColumn("genre")                                   
    .addColumn("description")                             
    .addColumn("director")                                
    .setColumnSeparator(',')                              
    .setSkipFirstDataRow(true)                            
    .build();                                             
                                                          
CsvMapper csvMapper = new CsvMapper();                    
                                                          
File initialFile = new File("src/main/resources/scifi_1000.csv");
InputStream csvContentStream = new FileInputStream(initialFile);
                                                          
MappingIterator&lt;Movie&gt; it = csvMapper                     
    .readerFor(Movie.class)                               
    .with(schema)                                         
    .readValues(new InputStreamReader(csvContentStream)); <p>Each row needs to be embedded first, and then both the embedded content and the text representation will be ingested by Elasticsearch.</p><p>Let’s start by creating an instance of the Ollama embedding model class:</p>EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
    .baseUrl(ollamaUrl)
    .modelName(ollamaModelName)
    .build(); <p>And then the Elasticsearch vector store, which needs an instance of the Elasticsearch Java RestClient:</p>RestClient restClient = RestClient
    .builder(HttpHost.create(elasticsearchServerUrl))
    .setDefaultHeaders(new Header[]{
        new BasicHeader("Authorization", "ApiKey " + elasticsearchApiKey)
    })
    .build(); 

EmbeddingStore&lt;TextSegment&gt; embeddingStore = ElasticsearchEmbeddingStore.builder()
    .restClient(restClient)
    .build(); <p>For the ingestion loop, the LangChain4j library requires the data to be split in two lists for ingestion, one for the vector representation and one for the original text, so we’ll set up two lists which will be filled by the loop:</p>List&lt;Embedding&gt; embeddings = new ArrayList&lt;&gt;();
List&lt;TextSegment&gt; embedded = new ArrayList&lt;&gt;();<p>Where <code>Embedding</code> and <code>TextSegment</code> are both library specific classes.</p><p>We’ll iterate on the movie dataset iterator, use the embedding model to retrieve the vector representation for each movie information (a text representation of all the fields merged), and add the name separately as metadata so that the result will be easier to read.</p>boolean hasNext = true;

while (hasNext) {
    try {
        Movie movie = it.nextValue();
        String text = movie.toString();

        Embedding embedding = embeddingModel.embed(text).content();
        embeddings.add(embedding);

        Metadata metadata = new Metadata();
        metadata.put("movie_name", movie.movie_name());
        embedded.add(new TextSegment(text, metadata));

        hasNext = it.hasNextValue();
    } catch (JsonParseException | InvalidFormatException e) {
        // ignore malformed data
    }
}<p>Finally, the vector list and text list are passed to the vector store method <code>addAll()</code>, which will handle asynchronously sending the data to the vector store:</p>embeddingStore.addAll(embeddings, embedded);<h2>Step 3: Querying</h2><p>Our goal is to find movies with time loops in the plot, so our prompt will be:</p>String query = "Find movies where the main character is stuck in a time loop and reliving the same day.";<p>Let’s try a simple vector search first, by creating a content retriever with a <a href="https://www.elastic.co/docs/solutions/search/vector/knn">k-nearest neighbor (kNN) query</a> default configuration and then running the query and printing the results:</p>ElasticsearchContentRetriever contentRetrieverVector = ElasticsearchContentRetriever.builder()
                .restClient(restClient)
                .configuration(ElasticsearchConfigurationKnn.builder().build())
                .maxResults(5)
                .embeddingModel(embeddingModel)
                .build();

List&lt;Content&gt; vectorSearchResult = contentRetrieverVector.retrieve(Query.from(query));

System.out.println("Vector search results:");
vectorSearchResult.forEach(v -&gt; System.out.println(v.textSegment().metadata().getString(
                "movie_name")));<p>This outputs:</p>Vector search results:
The Witch: Part 1 - The Subversion
Divinity
The Maze Runner
Spider-Man
Spider-Man: Into the Spider-Verse<p>Now let’s see how hybrid search performs:</p>ElasticsearchContentRetriever contentRetrieverHybrid = ElasticsearchContentRetriever.builder()
    .restClient(restClient)
    .configuration(ElasticsearchConfigurationHybrid.builder().build())
    .maxResults(5)
    .embeddingModel(embeddingModel)
    .build();

List&lt;Content&gt; hybridSearchResult = contentRetrieverHybrid.retrieve(Query.from(query));

System.out.println("Hybrid search results:");
hybridSearchResult.forEach(v -&gt; System.out.println(v.textSegment().metadata().getString(
            "movie_name")));Hybrid search results:
Edge of Tomorrow
The Witch: Part 1 - The Subversion
Boss Level
Divinity
The Maze Runner<h2>Why these results?</h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li><p>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped / escape) can appear.</p></li><li><p>Hybrid (BM25 + kNN + reciprocal rank fusion [RRF]) rewards documents that match keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</p></li></ul><p>Hybrid search doesn’t guarantee that every result is perfect; it balances lexical and semantic signals, so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords. Check the <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> for more information on how hybrid search works.</p><h2>Full code example</h2><p>You can find the full demo code on <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub</a>.</p><h2>Conclusion</h2><p>In this article, we demonstrated how to use hybrid search in LangChain4j through its Elasticsearch integrations, with a complete Java example. This article is an extension of a <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a>, which presents the LangChain integrations for Python and JavaScript and introduces and explains hybrid search. We’re planning to continue our collaboration with LangChain4j in the future by contributing to the embedding models with our Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">Inference API</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[Java]]></category>
    <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63218799e944e16d/6a1710ef6f7f04ee68914952/93d8e0d84fb4cfbf5e51df85df7ec2e600d9dcc7-1088x607.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[SearchClaw: Bring Elasticsearch to OpenClaw with composable skills]]></title>
    <description><![CDATA[Give your local AI agent access to Elasticsearch data using OpenClaw, composable skills, and agents, no custom code required.]]></description>
    <content:encoded><![CDATA[<p>In recent weeks, <a href="https://openclaw.ai/">OpenClaw</a> has been appearing frequently in AI community discussions, particularly among developers interested in agents, automation, and local runtimes. The project gained traction quickly, which naturally raised a technical question:</p><p><em>What real problem does it solve for engineers?</em></p><p><strong>OpenClaw</strong> is a self-hosted gateway for AI agents: a single runtime that coordinates execution, treats agents as isolated processes, and uses skills (structured instructions in markdown files) as the unit of integration. Conceptually, this isn’t entirely different from what we already do with command line interfaces (CLIs) and scripts, but it’s now formalized around agent-driven workflows.</p><p>This led to a practical exploration within the Elastic Stack:</p><p><em>If we treat OpenClaw as an orchestration runtime, how does it behave when Elasticsearch is the back end? And how straightforward is integration using OpenClaw skills?</em></p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8eb543674b064eee/6a170e562b835f4412f4b2bc/ec61e65f54b96b83975b52b2d88305170001d9bd-1999x1445.png" alt="Chart showing GitHub star history, from 2020 to 2026, for four different open‑source automation and AI‑agent frameworks: OpenClaw, LangChain, CrewAI, and n8n-io." /><p>Let's build an integration using composable skills.</p><h2><strong>Solution architecture</strong></h2><p>In this tutorial, we’ll teach OpenClaw how to access and query Elasticsearch data through a custom read-only skill, and we’ll then demonstrate how it composes multiple skills together; for example, combining Elasticsearch queries with real-time weather data to generate dynamic reports.</p><p>Before diving into the hands-on steps, let’s look at what we’re building. The solution is composed of three integrated layers that work together through OpenClaw orchestration.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3b365123e76e74dd/6a170e587d8d67349c70e7d2/ca8dc124a7410ba036ddf887eee011c42125cdf3-1270x680.png" alt="SearchClaw (OpenClaw and Elasticsearch) solution architecture, with the OpenClaw Gateway Runtime as the central hub. It loads skills for context and then interacts directly with each back end." /><h3>Layer 1: Storage and search (Elasticsearch)</h3><p>The data layer runs on Elasticsearch via <a href="https://github.com/elastic/start-local"><code>start-local</code></a>, a single command that spins up Elasticsearch and Kibana locally with Docker.</p><p>Two sample indices demonstrate different use cases:</p><ul><li><p><strong><code>fresh_produce</code></strong><strong>:</strong> 10 products with semantic search (ecommerce scenario)</p></li><li><p><strong><code>app-logs-synthetic</code></strong><strong>:</strong> 30 log entries across four services (observability scenario)</p></li></ul><p>The same read-only skill works with both indices without any reconfiguration; the agent inspects the mapping and adapts its queries accordingly.</p><h3>Layer 2: Orchestration (OpenClaw Gateway)</h3><p>The gateway receives natural language requests and loads the Elasticsearch skill, and the large language model (LLM) decides which queries to construct. The skill is a pure <strong><code>SKILL.md</code></strong> with reference docs, meaning that its operations require no custom code.</p><p>To understand how the gateway organizes this, two core OpenClaw concepts are worth knowing:</p><ul><li><p><strong>Agents:</strong> Independent AI instances, each with its own configuration, workspace, and set of skills. You can run multiple agents for different purposes.</p></li><li><p><strong>Workspace:</strong> A folder that defines an agent’s context:<strong><code>AGENTS.md</code></strong> (the agent’s permanent briefing), <strong><code>.env</code></strong>(credentials), and a <strong><code>skills/</code></strong> directory. Think of it as the agent’s working environment.</p></li></ul><h3>Layer 3: Skills (composable capabilities)</h3><p>Skills are structured instructions in markdown files (<code>SKILL.md</code>) that teach the agent how to use specific tools or APIs. They can be global (available to all agents), workspace-specific, or bundled with OpenClaw. The agent selectively loads only the skills relevant to each request.</p><p>This tutorial uses two skills:</p><ul><li><p><strong><code>Elasticsearch-openclaw</code></strong><strong> (custom, built for this tutorial):</strong> A read-only skill that teaches the agent how to search, filter, aggregate, and explore Elasticsearch indices using curl.</p></li><li><p><strong><code>Weather</code></strong><strong> (community skill, used for composition demo):</strong> A skill that fetches current weather conditions from external APIs.</p></li></ul><p>Later in the tutorial, we'll demonstrate how OpenClaw composes both skills in a single request, querying Elasticsearch products based on real-time weather data without any custom integration code.</p><h4>Read-only by design</h4><p>The <code>elasticsearch-openclaw</code> skill is <strong>read-only by design</strong>. It provides patterns for searching, filtering, and aggregating data, but it never writes, updates, or deletes. This minimizes the security footprint when giving AI agents access to your Elasticsearch cluster.</p><p>Even if the agent environment is compromised, your data remains safe from modification or deletion. This is enforced through:</p><ul><li><p><strong>Skill design:</strong> No write operation patterns in <code>SKILL.md</code> or reference files.</p></li><li><p><strong>API key permissions:</strong> The tutorial uses a read-only API key with only <code>read</code> and <code>view_index_metadata</code> privileges.</p></li><li><p><strong>Agent instructions:</strong> <code>AGENTS.md</code> explicitly states "You can SEARCH, FILTER, and AGGREGATE data, but you can NEVER write, update, or delete."</p></li></ul><p>This security-first approach is why infrastructure setup (index creation, data loading) must be done manually; by design, the agent cannot do it for you.</p><h2><strong>Prerequisites</strong></h2><p>To follow this tutorial, you’ll need:</p><p><strong>Software and tools:</strong></p><ul><li><p>Docker Desktop installed and running (Docker Engine with Compose V2).</p></li><li><p>Elasticsearch running locally via <code>start-local</code>. (We’ll set this up in the next section.)</p></li><li><p>Jina API key (free): <a href="https://jina.ai/embeddings">https://jina.ai/embeddings</a>.</p></li><li><p>OpenClaw installed: <a href="https://openclaw.ai">https://openclaw.ai</a>.</p></li></ul><h3><strong>Setting up the environment</strong></h3><p>Start by cloning the starter project, which contains the skill, workspace configuration, and Dev Tools scripts:</p>git clone https://github.com/salgado/elasticsearch-openclaw-start-blog
cd elasticsearch-openclaw-start-blog<p>The repository contains:</p>elasticsearch-openclaw-start-blog/
├── devtools_fresh_produce.md         ← Creates fresh_produce index (10 products)
├── devtools_app_logs_synthetic.md    ← Creates app-logs-synthetic index (30 logs)
└── openclaw-workspace-elastic-blog/
    ├── AGENTS.md                      ← Agent briefing
    ├── .env.example                   ← Credentials template<p><em><strong>Note:</strong></em><em> The </em><em><code>devtools*.md</code></em><em> files contain Kibana Dev Tools commands formatted as reference documentation.</em></p><h4>Installing OpenClaw</h4><p>OpenClaw is a self-hosted gateway. This means you maintain full control over execution and data, but you need to prepare your local environment or server.</p><p>I installed OpenClaw on a separate machine, which is why I included the disclaimer below.</p><p><strong>** Security and responsibility disclaimer **</strong></p><p>Since OpenClaw is an early-stage, rapidly evolving open-source project, the community has raised important discussions about potential security vulnerabilities, especially around token handling and third-party script execution.</p><p><strong>Deployment recommendations:</strong></p><ul><li><p><strong>Isolated environments:</strong> If you’re not an advanced infrastructure security user, we recommend installing OpenClaw strictly in isolated, controlled environments (such as a dedicated virtual machine [VM], a rootless Docker container, or a test machine).</p></li><li><p><strong>Do not use in production:</strong> Avoid running the gateway on servers containing sensitive data or with unrestricted access to your corporate network until the project reaches a more stable, audited version.</p></li><li><p><strong>Least privilege:</strong> We reinforce the need to use Elasticsearch API keys with restricted permissions (read-only) to mitigate risks, in case the environment is compromised.</p></li><li><p><strong>Network segmentation:</strong> Both Elasticsearch and OpenClaw bind to <code>localhost</code> by default. Keep it that way, unless you have a specific reason to expose them.</p></li><li><p><strong>Credential rotation:</strong> Rotate API keys periodically. OpenClaw stores credentials locally, so treat the machine’s security as the perimeter.</p></li><li><p><strong>Audit logging:</strong> Enable Elasticsearch audit logging to track all API calls made by OpenClaw. This creates a full trail of what the agent accessed and when.</p></li><li><p><strong>Keep the installation up to date.</strong></p></li></ul><p>For a deeper analysis of the security architecture and deployment options, consult the <a href="https://docs.openclaw.ai">official OpenClaw documentation</a>.</p><h4>Runtime installation</h4><p>OpenClaw manages daemons and skill isolation via CLI. Since it’s a recent project that has undergone naming changes, we recommend strictly following the <a href="https://docs.openclaw.ai/install">official documentation</a> to ensure installation compatibility.</p># Global gateway installation
curl -fsSL https://openclaw.ai/install.sh | bash<h2><strong>Preparing the Elasticsearch back end</strong></h2><p>Before connecting any agent runtime, we need a working Elasticsearch environment with data to query and a secure, <strong>read-only access layer</strong>. In the next two sections, we’ll spin up Elasticsearch locally using <code>start-local</code>, create an index with <code>semantic_text</code> and Jina v5 embeddings, load sample data, validate that semantic search works, and generate a read-only API key. Once this foundation is in place, the Elasticsearch side is complete and we can focus entirely on teaching the agent how to use it.</p><h3>Part 1: Setting up Elasticsearch locally</h3><p>Start a local Elasticsearch and Kibana instance with a single command:</p>curl -fsSL https://elastic.co/start-local | sh<p>Once complete: Elasticsearch at <code>http://localhost:9200</code>, Kibana at <code>http://localhost:5601</code>, and credentials in <code>elastic-start-local/.env</code>.</p><h3>Part 2: Configuring the index in Kibana Dev Tools</h3><p>Open <code>http://localhost:5601</code> → Dev Tools and run <code>devtools_fresh_produce.md</code> in order.</p><ul><li><p><strong>Step 1:</strong> Replace <code>YOUR_JINA_API_KEY</code> with your actual Jina API key (free).</p></li><li><p><strong>Step 2:</strong> Save the encoded field immediately; it cannot be retrieved later.</p></li></ul><p>The key commands in the Dev Tools file are:</p><p><strong>Create the Jina inference endpoint:</strong></p>PUT _inference/text_embedding/jina-embeddings-v5
{
  "service": "jinaai",
  "service_settings": {
    "api_key": "YOUR_JINA_API_KEY",
    "model_id": "jina-embeddings-v5-text-small"
  }
}<p><strong>Create the index with </strong><strong><code>semantic_text</code></strong><strong>:</strong></p>PUT /fresh_produce
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "fields": { "keyword": { "type": "keyword" } }
      },
      "description": { "type": "text" },
      "category": { "type": "keyword" },
      "price": { "type": "float" },
      "stock_kg": { "type": "float" },
      "on_sale": { "type": "boolean" },
      "image_url": { "type": "keyword" },
      "semantic_content": {
        "type": "semantic_text",
        "inference_id": "jina-embeddings-v5"
      }
    }
  }
}<p>The <code>semantic_text</code> field type handles embedding generation automatically at index time.</p><p><strong>Index sample products</strong> using the bulk API (see <code>devtools_fresh_produce.md</code> for the full dataset of 10 products).</p><p><strong>Validate semantic search:</strong></p>GET /fresh_produce/_search
{
  "query": {
    "semantic": {
      "field": "semantic_content",
      "query": "healthy colorful meals"
    }
  },
  "size": 3,
  "_source": ["name", "description", "category"]
}<p>The semantic query type handles inference on the query side automatically; no need to specify model IDs or embedding details.</p><p><strong>Create a read-only API key:</strong></p>POST /_security/api_key
{
  "name": "openclaw-readonly",
  "role_descriptors": {
    "reader": {
      "cluster": ["monitor"],
      "indices": [
        {
          "names": ["fresh_produce", "app-logs-synthetic"],
          "privileges": ["read", "view_index_metadata"]
        }
      ]
    }
  }
}<p>Save the encoded value from the response. This is your API key for the OpenClaw configuration.</p><h2>Connecting to OpenClaw</h2><p>With the Elasticsearch back end ready, we can now wire it into OpenClaw. Several Elasticsearch integrations already exist in the ecosystem, from <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server">Elastic’s own Model Context Protocol (MCP) server</a> to community-built MCP servers. However, most of these offer full CRUD access or are designed for different agent runtimes. Given that the technology is still in its early stages and security remains a primary concern, I chose to build a dedicated skill, simple, read-only, and purpose-built for OpenClaw. This approach ensures that the agent can search, filter, and aggregate data but never modify it, keeping the blast radius minimal even if the environment is compromised.</p><p>In the next sections, we’ll configure credentials, install the skill, create a dedicated agent, and explore how the workspace ties everything together.</p><h3>Install the skill and create the agent</h3><h4>Step 1: Configure credentials</h4><p>From the cloned repository, configure the credentials by copying the environment template and filling in your Elasticsearch URL and the read-only API key:</p>cp openclaw-workspace-elastic-blog/.env.example 
openclaw-workspace-elastic-blog/.env<p>Edit the .env file with these two values:</p>ELASTICSEARCH_URL: http://localhost:9200 (from start-local)
ELASTICSEARCH_API_KEY: The encoded value from the read-only API key you created in Part 2 (the POST /_security/api_key response)<p>Example .env file:</p>ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_API_KEY=VnVaRmxLSDRCQxxxxxxxxbGVfa2V5<h4>Step 2: Install the skill from ClawHub</h4><p><a href="https://clawhub.ai/">ClawHub</a> is OpenClaw's public skill registry. Think of it as npm for AI agent skills. At the time of this writing, ClawHub hosts over 3,200 skills, covering everything from Slack and GitHub integrations to Internet of Things (IoT) device automation. For this tutorial, we created <code>elasticsearch-openclaw</code>, a custom skill focused on read-only queries using <code>semantic_text</code>, aggregations, and observability on Elasticsearch 9.x. It’s published on ClawHub so you can install it directly. As a best practice, only install skills from trusted sources with known provenance; as with any package manager, review the content before granting access to your agent.</p><p>The <code>elasticsearch-openclaw</code> skill is published on ClawHub.</p><p><strong>Recommended:</strong> Open the OpenClaw Web UI (http://127.0.0.1:18789/) and ask:</p>Install the elasticsearch-openclaw skill from https://clawhub.ai/salgado/elasticsearch-openclaw<p>OpenClaw will:</p><ul><li><p>Fetch the skill from ClawHub.</p></li><li><p>Install it in the appropriate directory.</p></li><li><p>Confirm when ready to use.</p></li></ul><h4>Step 3: Create the agent</h4><p>Do this by registering a dedicated agent with its own workspace, and then restart the gateway to load the new configuration:</p>openclaw agents add elasticsearch-agent \
  --workspace ~/path/to/elasticsearch-openclaw-start-blog/openclaw-workspace-elastic-blog \
  --non-interactive

openclaw gateway restart<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltffac27a4e3fd8fe9/6a170e5a964ceac34508bc5d/abc051a513b0cc7dff4a7f02493d51e220c72ad4-1999x1095.png" alt="OpenClaw web chat screen, with the focus on &quot;Find products, in my Elasticsearch, that would be good for a fresh salad.&quot;" /><h3>Understanding the workspace</h3><p>Now that the agent is running, let’s look at what makes it tick.</p><h4><code>AGENTS.md</code></h4><p>The <code>AGENTS.md</code> file is the agent’s permanent briefing. It defines who the agent is, what it can do, and how it should behave. For our Elasticsearch agent, this file instructs the agent about the available indices, the read-only constraint, and the preferred query patterns.</p><h4>Skills: When they make a difference</h4><p>Without skill</p><p>With `elasticsearch-openclaw` skill</p><p>Agent has no knowledge of Elasticsearch query syntax.</p><p>Agent knows semantic, full-text, filtered, and aggregation patterns.</p><p>Agent might attempt write operations.</p><p>Agent is instructed to never write, update, or delete.</p><p>Agent guesses field names and types.</p><p>Agent inspects mappings first and then constructs appropriate queries.</p><p>Generic curl commands with trial and error.</p><p>Structured query templates with best practices for Elasticsearch 9.x.</p><h2><strong>Exploring with the agent</strong></h2><p>With the Elasticsearch back end configured and the OpenClaw agent connected, it’s time to see what the agent can actually do. In the next sections, we’ll test natural language queries, explore observability data, and compose multiple skills together.</p><h3><strong>Testing in OpenClaw</strong></h3><p>Open the OpenClaw web UI, and try some natural language queries. The agent will inspect the index mapping, choose the appropriate query type, and return results.</p><p>Type:</p>“Find products that would be good for a healthy summer salad.”<p>Result:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a0015d9a2d3abfd/6a170e5b1949f754dce7aad9/d5b4bbe71ad56af5462bccc1475bd10d5233abd9-1011x557.png" alt="OpenClaw web chat page with &quot;Semantic search working&quot; message, along with a list of salad ingredients." /><p>Others ideas to explore:</p><ul><li><p><strong>Index exploration:</strong> &gt; “What indices do I have in Elasticsearch? Show me the fields of <code>fresh_produce</code>.”</p></li><li><p><strong>Filtered search:</strong> &gt; “Show me all products on sale under $15.”</p></li><li><p><strong>Aggregations:</strong> &gt; “What’s the average price by category?”</p></li></ul><h3>Observability</h3><p>To demonstrate that the skill works beyond a single use case, the repository includes a second index: <code>app-logs-synthetic</code>, with 30 synthetic log entries across four fictional services, created from <code>devtools_app_logs_synthetic.md</code>.</p><h4>Setting up the log data</h4><p>Since the skill is read-only, you need to populate the index first. The <code>devtools_app_logs_synthetic.md</code> file contains <strong>five commands</strong> (three for setup and two for verification):</p><ul><li><p><strong><code>Create ingest pipeline</code></strong><strong>:</strong> Adds @timestamp to log entries automatically.</p></li><li><p><strong><code>Create index mapping</code></strong><strong>:</strong> Defines the <code>app-logs-synthetic</code> structure (classic fields only, no <code>semantic_text</code>).</p></li><li><p><strong><code>Bulk insert logs</code></strong><strong>:</strong> Loads 30 synthetic log entries across four services.</p></li><li><p><strong><code>Count query</code></strong><strong>:</strong> Verify 30 documents were indexed.</p></li><li><p><strong><code>Sample search</code></strong><strong>:</strong> Quick test to confirm that data is queryable.</p></li></ul><h4>How to run:</h4><ol><li><p>Open Kibana Dev Tools: http://localhost:5601 → Dev Tools.</p></li><li><p>Copy each numbered block from the .md file.</p></li><li><p>Paste into the Dev Tools console.</p></li><li><p>Press <em><strong>Ctrl/Cmd+Enter</strong></em> to execute.</p></li><li><p>Wait for a successful response before continuing to the next block.</p></li></ol><p>This creates the <code>app-logs-synthetic</code> index with sample data ready for querying.</p><p>Try this query in the OpenClaw web UI:</p>Show me the distribution of HTTP status codes across all services.<p>Result:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8600731ee46ee0f8/6a170e5d961e69607fc4cfae/d35fc1c0ea6d647f1c85163eb0ab8e268c6c4f89-1002x565.png" alt="OpenClaw web chat with &quot;the full picture across 30 logs,&quot; listing &quot;ok,&quot; &quot;bad requests,&quot; &quot;server errors,&quot; and more." /><p>Other ideas to explore:</p><ul><li><p>“How many 500 errors do I have in <code>app-logs-synthetic</code>? Which services are failing?”</p></li><li><p>“Which endpoints have the slowest response times?”</p></li><li><p>“What happened with the <code>payment-service</code> in the last 24 hours?”</p></li></ul><p>This is the same skill, same agent, same setup, just pointed at different data. The agent inspects the new index mapping, adapts its queries, and returns relevant results without any reconfiguration.</p><h2><strong>Composing skills in action</strong></h2><p>This is where composable skills truly shine. Start by asking the agent:</p>Install the weather skill.<p>OpenClaw will search for the weather skill, automatically attempt the installation, and guide you through the process. Just follow the on-screen instructions; no new API key is required for the weather skill. Afterward, try this:</p>“Find the products on sale in the fresh_produce index that match today’s weather in São Paulo. Generate a nice HTML report with product cards using the image_url field from each document, price, description, and stock. Save it to ~/Desktop/report.html and open it in the browser.”<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb362465ddf7556fe/6a170e5f509168515ce1bb8a/14fa4303bb2f1eb19530d8844f09c99948b3c752-1965x1079.png" alt="SearchClaw results for &quot;products on sale that match today's weather,&quot; including images of watermelon and avocado." /><p>In a single request, the agent chains multiple skills: the <strong>weather skill</strong> to check current conditions, the <strong>Elasticsearch skill </strong>to run a hybrid search on products that match the context, and its built-in file and browser tools to generate an HTML report and open it. No custom integration code, no glue scripts, just skills composed by the LLM at runtime.</p><p>This is what makes OpenClaw different from a traditional automation framework. You don’t preprogram the workflow. You describe the outcome, and the agent figures out the composition.</p><h2><strong>Conclusion</strong></h2><p>SearchClaw started as a simple experiment and ended up demonstrating what composable, LLM-driven integration looks like in practice. The key takeaway is not the individual tools (all are familiar) but the approach. Instead of writing a specific application with hardcoded queries, we gave the agent capabilities and let it compose solutions dynamically. This is what makes OpenClaw native: composable, LLM-driven, and local-first.</p><p>As with any early-stage project, OpenClaw should be used thoughtfully, especially regarding security and environment isolation. The read-only skill approach demonstrated here is one way to limit risk while still unlocking the value of your Elasticsearch data.</p><p>The full code is available in the repository and can serve as a starting point for your own integrations: <a href="https://github.com/salgado/elasticsearch-openclaw-start-blog">https://github.com/salgado/elasticsearch-openclaw-start-blog</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/openclaw-elasticsearch-ai-agents</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/openclaw-elasticsearch-ai-agents</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Alex Salgado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltee8bba6ac4830abd/6a170e60cdacbf48277d2a92/ce3248c3cb7a352e3fdafef4ac8116ab998ab4f4-1950x1137.png" length="0" type="image/png"/>
    <pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From vectors to keywords: Elasticsearch hybrid search in LangChain]]></title>
    <description><![CDATA[Learn how to use hybrid search in LangChain via its Elasticsearch integrations, with complete Python and JavaScript examples.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch hybrid search is available for LangChain across our <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a> integrations. Here we’ll discuss what hybrid search is, when it can be useful and we’ll run through some simple examples to get started.</p><p>We’re also planning to support hybrid search in the community-driven <a href="https://github.com/langchain4j/langchain4j">Java integration</a> very soon.</p><h2><strong>What is hybrid search?</strong></h2><p><em>Hybrid search</em> is an information retrieval approach that combines<em> keyword-based full-text search</em> (lexical matching) with <em>semantic search</em> (vector similarity). Practically, it means a query can match documents because they contain the right terms and/or because they express the right meaning (even if the wording differs).In simple terms, you can think of it like this:</p><ul><li><p>Lexical retrieval: “Do these documents contain the words I typed (or related words)?”</p></li><li><p>Semantic retrieval: “Do these documents mean something similar to what I typed?”</p></li></ul><p>These two retrieval methods produce scores on different scales, so hybrid search systems typically use a fusion strategy to merge them into one ranking, for example, using <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf0bfbf25638698c9/6a170d5f964cea3fa308bc25/a36692581ec5adb54d3c517e171b6d2f372efd92-1249x514.png" alt="BM25 example flow for hybrid search" /><p>In the figure above, we show an example: <a href="https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables">BM25</a> (keyword search) returns Docs A, B, and C, while semantic search returns Docs X, A, and B. The RRF algorithm then combines these two result lists into the final ranking: Doc A, Doc B, Doc X, and Doc C. With hybrid search, Doc C is included in the results thanks to BM25.</p><h2><strong>Why hybrid search matters</strong></h2><p>If you’ve built search or retrieval-augmented generation (RAG) features in production, you’ve probably seen the same failure modes show up again and again: </p><ul><li><p>Keyword search can be too literal. If the user doesn’t use the exact terms that appear in your documents, relevant content gets buried or missed.</p></li><li><p>Semantic search can be too fuzzy. It’s great at meaning, but it can also return results that feel related while missing a critical constraint, like a product name, an error code, or a specific phrase the user actually typed.</p></li></ul><p>Hybrid search exists because real user queries in production environments usually need <em>both</em>.</p><p>Next we’ll dive into how you get started with hybrid search in the LangChain integration for <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a>. If you want to read more about hybrid search, check out <a href="https://www.elastic.co/what-is/hybrid-search"><strong>What is hybrid search?</strong></a>and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-hybrid-search"><strong>When hybrid search truly shines</strong></a>.</p><h3>Setting up a local Elasticsearch instance</h3><p>Before running the examples, you'll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><h2>Getting started with hybrid search in LangChain (Python and JavaScript)</h2><p>The dataset is a CSV with information on 1,000 science fiction movies, taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>, along with the full code for this demo.</p><h3>Step 1: Install what you need.</h3><p>First you’ll need the LangChain Elasticsearch integration and Ollama for embeddings. (You can also use some other embedding model if you wish.)</p><p><strong>In Python:</strong></p>pip install langchain-elasticsearch langchain-ollama<p><strong>In JavaScript:</strong></p>npm install @langchain/community @langchain/ollama @elastic/elasticsearch csv-parse<h3>Step 2: Configure your connection and dataset path.</h3><p><strong>In Python:</strong></p><p>At the top of the script, we set:</p><ul><li><p>Where Elasticsearch is <code>(ES_LOCAL_URL)</code>.</p></li><li><p>How to authenticate <code>(ES_LOCAL_API_KEY)</code>.</p></li><li><p>Which demo index name to use <code>(INDEX_NAME)</code>.</p></li><li><p>Which CSV file we’ll ingest <code>(scifi_1000.csv)</code>.</p></li></ul>ES_URL = os.getenv("ES_LOCAL_URL", "http://localhost:9200") 
ES_API_KEY = os.getenv("ES_LOCAL_API_KEY")
INDEX_NAME = "scifi-movies-hybrid-demo" 
CSV_PATH = Path(__file__).with_name("scifi_1000.csv")<p><strong>In JavaScript:</strong></p><p>Notes for JavaScript:</p><ul><li><p>JavaScript uses <code>process.env</code> instead of <code>os.getenv</code>.</p></li><li><p>Path resolution requires <code>fileURLToPath</code> and <code>dirname</code> for Elasticsearch modules.</p></li><li><p>The class is called <code>ElasticVectorSearch</code> (not <code>ElasticsearchStore</code> as in Python).</p></li></ul>import { Client } from "@elastic/elasticsearch";
import { OllamaEmbeddings } from "@langchain/ollama";
import {
  ElasticVectorSearch,
  HybridRetrievalStrategy,
} from "@langchain/community/vectorstores/elasticsearch";
import { parse } from "csv-parse/sync";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

const ES_URL = process.env.ES_LOCAL_URL || "http://localhost:9200";
const ES_API_KEY = process.env.ES_LOCAL_API_KEY;
const INDEX_NAME = "scifi-movies-hybrid-demo";
const CSV_PATH = join(__dirname, "scifi_1000.csv");<p>We can now also create the client.</p><p>In Python:</p>es = Elasticsearch(ES_URL, api_key=ES_LOCAL_API_KEY)<p>In JavaScript:</p>const client = new Client({
  node: ES_URL,
  auth: ES_API_KEY ? { apiKey: ES_LOCAL_API_KEY } : undefined,
});<h3>Step 3: Ingest the dataset, and then compare vector-only vs. hybrid.</h3><h4>Step 3a: Read the CSV and build what we index.</h4><p>We build three lists:</p><ul><li><p><code>texts</code>: The actual text that will be embedded + searched.</p></li><li><p><code>metadata</code>: Structured fields stored alongside the document.</p></li><li><p><code>ids</code>: Stable IDs (so Elasticsearch can dedupe if needed).</p></li></ul><p><strong>In Python:</strong></p># --- Ingest dataset ---
texts: list[str] = []
metadatas: list[dict] = []
ids: list[str] = []

with CSV_PATH.open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        movie_id = (row.get("movie_id") or "").strip()
        movie_name = (row.get("movie_name") or "").strip()
        year = (row.get("year") or "").strip()
        genre = (row.get("genre") or "").strip()
        description = (row.get("description") or "").strip()
        director = (row.get("director") or "").strip()

        # This text is both:
        #  - embedded (vector search)
        #  - keyword-matched (BM25 in hybrid mode)
        text = "\n".join(
            [
                f"{movie_name} ({year})" if year else movie_name,
                f"Director: {director}" if director else "Director: (unknown)",
                f"Genres: {genre}" if genre else "Genres: (unknown)",
                f"Description: {description}" if description else "Description: (missing)",
            ]
        )
        texts.append(text)
        metadatas.append(
            {
                "movie_id": movie_id or None,
                "movie_name": movie_name or None,
                "year": year or None,
                "genre": genre or None,
                "director": director or None,
            }
        )
        ids.append(movie_id or movie_name)<p><strong>In JavaScript:</strong></p>async function main() {
  // --- Ingest dataset ---
  const texts = [];
  const metadatas = [];
  const ids = [];

  const csvContent = readFileSync(CSV_PATH, "utf-8");
  const records = parse(csvContent, {
    columns: true,
    skip_empty_lines: true,
  });

  for (const row of records) {
    const movieId = (row.movie_id || "").trim();
    const movieName = (row.movie_name || "").trim();
    const year = (row.year || "").trim();
    const genre = (row.genre || "").trim();
    const description = (row.description || "").trim();
    const director = (row.director || "").trim();

    // This text is both:
    //  - embedded (vector search)
    //  - keyword-matched (BM25 in hybrid mode)
    const text = [
      year ? `${movieName} (${year})` : movieName,
      director ? `Director: ${director}` : "Director: (unknown)",
      genre ? `Genres: ${genre}` : "Genres: (unknown)",
      description ? `Description: ${description}` : "Description: (missing)",
    ].join("\n");

    texts.push(text);
    metadatas.push({
      movie_id: movieId || null,
      movie_name: movieName || null,
      year: year || null,
      genre: genre || null,
      director: director || null,
    });
    ids.push(movieId || movieName);
  }<p><strong>What’s important here:</strong></p><ul><li><p>We don’t embed only the description. We embed a combined text block (title/year + director + genre + description). That makes results easier to print and sometimes improves retrieval.</p></li><li><p>The same text is what the lexical side uses, too (in hybrid mode), because it’s indexed as searchable text.</p></li></ul><h4>Step 3b: Add texts to Elasticsearch using LangChain.</h4><p>This is the indexing step. Here we embed texts and write them to Elasticsearch.</p><p>For asynchronous applications, please use <a href="https://reference.langchain.com/python/integrations/langchain_elasticsearch/#langchain_elasticsearch._async.vectorstores.AsyncElasticsearchStore"><code>AsyncElasticsearchStore</code></a> with the same API.</p><p>You can find our <a href="https://reference.langchain.com/python/integrations/langchain_elasticsearch/">reference docs</a> for both the sync and async versions of ElasticsearchStore, along with more parameters for advanced fine-tuning RRF.</p><p><strong>In Python:</strong></p>print(f"Ingesting {len(texts)} movies into '{INDEX_NAME}' from '{CSV_PATH.name}'...") 

vector_store = ElasticsearchStore(
    index_name=INDEX_NAME,
    embedding=OllamaEmbeddings(model="llama3"),
    es_url=ES_LOCAL_URL,
    es_api_key=ES_LOCAL_API_KEY,
    strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=False),
)

#This is the indexing step. We embed the texts and add them to Elasticsearch
vectore_store.add_texts(texts=texts, metadatas=metadatas, ids=ids)<p><strong>In JavaScript:</strong></p>  console.log(
    `Ingesting ${texts.length} movies into '${INDEX_NAME}' from 'scifi_1000.csv'...`
  );

  const embeddings = new OllamaEmbeddings({ model: "llama3" });

  // Vector-only store (no hybrid)
  const vectorStore = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
  });

  // This is the indexing step. We embed the texts and add them to Elasticsearch
  await vectorStore.addDocuments(
    texts.map((text, i) =&gt; ({
      pageContent: text,
      metadata: metadatas[i],
    })),
    { ids }
  );<h4>Step 3c: Create another store for hybrid search.</h4><p>We create another ElasticsearchStore object pointing at the same index but with different retrieval behavior: <code>hybrid=False</code> is <em><strong>vector-only</strong></em> search and <code>hybrid=True</code> is <em><strong>hybrid search</strong></em> (BM25 + kNN, fused with RRF).</p><p><strong>In Python:</strong></p># Since we are using the same INDEX_NAME we can avoid adding texts again 
# This ElasticsearchStore will be used for hybrid search

hybrid_store = ElasticsearchStore(
    index_name=INDEX_NAME,
    embedding=OllamaEmbeddings(model="llama3"),
    es_url=ES_LOCAL_URL,
    es_api_key=ES_LOCAL_API_KEY,
    strategy=ElasticsearchStore.ApproxRetrievalStrategy(hybrid=True),
)<p><strong>In JavaScript:</strong></p>  // Since we are using the same INDEX_NAME we can avoid adding texts again
  // This ElasticVectorSearch will be used for hybrid search
  const hybridStore = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
    strategy: new HybridRetrievalStrategy(),
  });

  // With custom RRF parameters
  const hybridStoreCustom = new ElasticVectorSearch(embeddings, {
    client,
    indexName: INDEX_NAME,
    strategy: new HybridRetrievalStrategy({
      rankWindowSize: 100,  // default: 100
      rankConstant: 60,     // default: 60
      textField: "text",    // default: "text"
    }),
  });<h4>Step 3d: Run the same query both ways, and print results.</h4><p>As an example, let’s run the query <em>“Find movies where the main character is stuck in a time loop and reliving the same day." </em>and compare the results from hybrid search and vector search.</p><p><strong>In Python:</strong></p>query = "Find movies where the main character is stuck in a time loop and reliving the same day."
k = 5

print(f"\n=== Query: {query} ===")

vec_docs = vector_store.similarity_search(query, k=k)
hyb_docs = hybrid_store.similarity_search(query, k=k)

print("\nVector search (kNN) top results:")
for i, doc in enumerate(vec_docs, start=1):
    print(f"{i}. {(doc.page_content or '').splitlines()[0]}")

print("\nHybrid search (BM25 + kNN + RRF) top results:")
for i, doc in enumerate(hyb_docs, start=1):
    print(f"{i}. {(doc.page_content or '').splitlines()[0]}")<p><strong>In JavaScript:</strong></p>  const query =
    "Find movies where the main character is stuck in a time loop and reliving the same day.";
  const k = 5;

  console.log(`\n=== Query: ${query} ===`);

  const vecDocs = await vectorStore.similaritySearch(query, k);
  const hybDocs = await hybridStore.similaritySearch(query, k);

  console.log("\nVector search (kNN) top results:");
  vecDocs.forEach((doc, i) =&gt; {
    console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
  });

  console.log("\nHybrid search (BM25 + kNN + RRF) top results:");
  hybDocs.forEach((doc, i) =&gt; {
    console.log(`${i + 1}. ${(doc.pageContent || "").split("\n")[0]}`);
  });
}

main().catch(console.error);<p><strong>Example output</strong></p>Ingesting 1000 movies into 'scifi-movies-hybrid-demo' from 'scifi_1000.csv'...

=== Query: Find movies where main character is stuck in a time loop and reliving the same day. ===

Vector search (kNN) top results:
1. The Witch: Part 1 - The Subversion (20  18)
2. Divinity (2023)
3. The Maze Runner (2014)
4. Spider-Man (2002)
5. Spider-Man: Into the Spider-Verse (2018)

Hybrid search (BM25 + kNN + RRF) top results:
1. Edge of Tomorrow (2014)
2. The Witch: Part 1 - The Subversion (2018)
3. Boss Level (2020)
4. Divinity (2023)
5. The Maze Runner (2014)<h2><strong>Why these results? </strong></h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li><p>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped/escape) can appear.</p></li><li><p>Hybrid (BM25 + kNN + RRF) rewards documents that match both keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</p></li></ul><p>Hybrid search doesn’t guarantee that every result is perfect. It balances lexical and semantic signals so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords.</p><h2>Full code example</h2><p>You can find our full demo code in Python and JavaScript, as well as the dataset used, hosted on <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>.</p><h2>Conclusion</h2><p>Hybrid search provides a pragmatic and powerful retrieval strategy by combining traditional BM25 keyword search with modern vector similarity into a single, unified ranking. Instead of choosing between lexical precision and semantic understanding, you get the best of both worlds, without adding significant complexity to your application.</p><p>In real-world datasets, this approach consistently yields results that feel more intuitively correct. Exact term matches help anchor results to the user’s explicit intent, while embeddings ensure robustness against paraphrasing, synonyms, and incomplete queries. This balance is especially valuable for noisy, heterogeneous, or user-generated content, where relying on only one retrieval method often falls short.</p><p>In this article, we demonstrated how to use hybrid search in LangChain through its Elasticsearch integrations, with complete examples in both Python and JavaScript. We’re also contributing to other open-source projects, such as <a href="https://github.com/langchain4j/langchain4j/pull/4069">LangChain4j</a>, to extend hybrid search support with Elasticsearch.</p><p>We believe hybrid search will be a key capability for generative AI (GenAI) and agentic AI applications, and we plan to continue collaborating with libraries, frameworks, and programming languages across the ecosystem to make high-quality retrieval more accessible and robust.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Margaret Gu,Eyo Eshetu]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe53f88c9e39c39e/6a170d61a6c2b9013be79762/9159af2b07b88f288e5c7cb719c8dcbe5d3b37d6-1080x608.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>