<?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[Python - 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[Python - 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/python-programming</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/python-programming</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/python-programming.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Fri, 18 Sep 2026 23:12:59 GMT</lastBuildDate>
  <item>
    <title><![CDATA[How to instrument your search API with OpenTelemetry and query it with ES|QL]]></title>
    <description><![CDATA[Add custom attributes to OpenTelemetry spans and run six ES|QL queries that reveal your top searches, zero-result rate and slowest queries.]]></description>
    <content:encoded><![CDATA[<p>Instrument your search API with about 20 lines of OpenTelemetry (OTel) code, and Elasticsearch Query Language (ES|QL) can tell you what people are searching for, how often they get nothing back, and how fast search actually runs. This builds directly on <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first post in this series</a>, where we made the case for using OpenTelemetry over a bespoke analytics pipeline. Here, we wire up a FastAPI search endpoint with custom <code>search.*</code> attributes and run six ES|QL queries against the resulting trace data. On our demo cluster, 17.7% of searches came back empty, a gap we found within minutes of turning the instrumentation on. No separate logging pipeline is required. It's the same spans, attributes, and query language you're probably already running somewhere else in Elastic.</p><h3>What you'll discover</h3><p>In this post, you'll learn how to:</p><ul><li><p>Set up OpenTelemetry in an example Python FastAPI back end.</p></li><li><p>Add custom <code>search.*</code> attributes to your search spans in ~20 lines of code.</p></li><li><p>Understand how OTel-native ingestion maps attributes to queryable data.</p></li><li><p>Write six ES|QL queries against real trace data: top queries, zero-results rate, which queries return nothing, average and max latency, slow query investigation, and search volume over time.</p></li><li><p>Turn those queries into saved Kibana visualizations.</p></li></ul><h3>What you'll need</h3><ul><li><p>An Elastic Cloud deployment (or self-managed with OTel-native ingestion enabled). Examples tested on Elastic Stack 9.x.</p></li></ul><h2>From concept to code: Building the search analytics instrumentation</h2><p>In the <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">first post</a>, we made the case for using OpenTelemetry to capture search analytics. The idea: Add <code>search.*</code> attributes to your existing OTel spans, send them to Elastic APM, and query them with <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL</a>.</p><p>Now let's build it.</p><p><strong>Want working code?</strong> A <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">companion reference project</a> accompanies this series. It's a minimal FastAPI app with the exact instrumentation described below. Clone it, add your Elastic Cloud credentials, and you'll have search analytics data flowing in 10 minutes. Each blog stage maps to a commented-out code block you can enable as you progress.</p><h3>An OpenTelemetry primer for search API developers</h3><p>If you've been building search systems but haven't worked with OpenTelemetry before, here's the minimum you need to know.</p><p>OTel is an open standard for collecting observability data, traces, metrics, and logs from your applications. It's vendor-neutral: You instrument your code once, and send data to any compatible back end.</p><p>The core concept is the <em>span</em>. A span represents a single operation, for example, an API call, a database query, or a search request. Every span has a start time, an end time (the difference is the <em>span duration</em>), and <em>attributes</em>, which are key-value pairs that describe what happened.</p><p>Spans nest inside each other to form <em>traces</em>. A trace is a tree of spans that represents one end-to-end request. When a user searches, the trace might look like: browser request → API handler → search logic → Elasticsearch query. Each step is a span, and the parent-child relationships show you exactly where time was spent. This is <em>distributed tracing</em>, which works across services and network boundaries, so a single trace can follow a request from front end to back end to database and back.</p><p><strong>Why traces instead of logs?</strong> You could log <code>"search query=headphones results=15 took=120ms"</code> and parse it later. But a log line is flat; it can't show you that the 120ms Elasticsearch time sat inside a 250ms API call, revealing 130ms of overhead in your application layer. Traces give you hierarchy, timing, and correlation across services. For search analytics, that means you can see not just <em>what</em> happened but also <em>where</em> time was spent and <em>how</em> operations relate to each other.</p><p>For this post, we don't need to understand the full OTel ecosystem. We just need three things:</p><ol><li><p><strong>Create a span</strong> when a search request happens.</p></li><li><p><strong>Add attributes</strong> to that span, describing the search (such as <code>search.query</code> or <code>result_count</code>).</p></li><li><p><strong>Send the span</strong> to Elastic, where we can query it with ES|QL.</p></li></ol><p>That's it. If you can call <code>span.set_attribute("key", value)</code>, you can build search analytics.</p><h3>What you'll build</h3><p>By the end of this post, every search request in your API will emit an OTel span that looks like this:</p>span.name:                     "search"
search.query:                  "wireless headphones"
search.result_count:           15
search.query_id:               "e2afdb85eb63382e..."
search.took_ms:                165<p>And you'll run six ES|QL queries against real data to answer questions that your team is already asking, using about 20 lines of instrumentation code in total.</p><h2>Install the OTel SDK</h2><p>We're using Python and FastAPI here. The same pattern applies to any language with an OTel SDK; the concepts are identical, only the imports change.</p><p>Elastic provides the <a href="https://github.com/elastic/elastic-otel-python">Elastic Distribution of OpenTelemetry Python (EDOT)</a>, which bundles the standard OTel SDK with sensible defaults, early access to Elastic-contributed improvements, and a single <code>configure_opentelemetry()</code> call that handles all the boilerplate. We recommend it:</p>pip install elastic-opentelemetry \
    opentelemetry-instrumentation-fastapi \
    opentelemetry-instrumentation-elasticsearch<p>Three packages, two roles:</p><ul><li><p><code>elastic-opentelemetry</code> EDOT: The OTel API, SDK, and OpenTelemetry Protocol (OTLP) exporter in one package, preconfigured for Elastic.</p></li><li><p><code>opentelemetry-instrumentation-fastapi</code>: Auto-instruments HTTP endpoints (automatic spans for every request).</p></li><li><p><code>opentelemetry-instrumentation-elasticsearch</code>: Auto-instruments Elasticsearch client calls (automatic spans for every query).</p></li></ul><p>The auto-instrumentation packages are doing real work here. Without writing a single line of tracing code, you already get HTTP request spans and Elasticsearch query spans. What we're adding is the search-specific context that turns generic traces into analytics.</p><p><strong>Using the standard OTel SDK instead? </strong>Replace <code>elastic-opentelemetry</code> with <code>opentelemetry-api</code>, <code>opentelemetry-sdk</code>, and <code>opentelemetry-exporter-otlp-proto-http</code>. You'll need to wire up the <code>TracerProvider</code>, <code>OTLPSpanExporter</code>, and <code>BatchSpanProcessor</code> manually (about 10 extra lines). Everything else in this post works the same either way. See the <a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">Elastic OTel guide</a> for the full setup.</p><h2>Configure the connection</h2><p>OTel uses environment variables for connection configuration. You need four to get started:</p><p>Variable</p><p>Purpose</p><p>Example</p><p>`OTEL_EXPORTER_OTLP_ENDPOINT`</p><p>Managed OTLP (mOTLP) endpoint URL</p><p>`https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com`</p><p>`OTEL_EXPORTER_OTLP_HEADERS`</p><p>Authentication</p><p>`Authorization=ApiKey &lt;your-api-key&gt;`</p><p>`OTEL_SERVICE_NAME`</p><p>Service name (shown in Kibana APM)</p><p>`search-analytics-demo`</p><p>`OTEL_RESOURCE_ATTRIBUTES`</p><p>Other resource attributes</p><p>`service.version=1.0.0`</p><p>Where to find these values: In Elastic Cloud, your mOTLP endpoint follows the pattern <code>https://&lt;deployment&gt;.ingest.&lt;region&gt;.gcp.elastic-cloud.com</code>. You can find it in the Elastic Cloud console under your deployment's details or in Kibana at the APM integration page (<code>/app/home#/tutorial/apm</code>) under the <strong>OpenTelemetry</strong> tab. Your API key can be created from Kibana's Stack Management &gt; API Keys or via the Elasticsearch Create API Key API. For self-managed deployments, you can use the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a> as an intermediary that receives OTLP and forwards to Elasticsearch.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb920fc0478908c55/6a6a3402c40efb7565d30950/86c9ed08f305c168429eff0c0ad059c4d8262197-1440x708.png" alt="Kibana APM integration page showing OpenTelemetry configuration settings for search API instrumentation" /><p>Set them in your environment or <code>.env</code> file:</p>export OTEL_EXPORTER_OTLP_ENDPOINT="https://my-deployment.ingest.us-central1.gcp.elastic-cloud.com"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=ApiKey &lt;your-api-key&gt;"
export OTEL_SERVICE_NAME="search-analytics-demo"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.0.0"<h2>Initialize the tracer</h2><p>With EDOT and environment variables configured, initialization wires up three things: the tracer provider and two auto-instrumentation packages that automatically create spans for every HTTP request and every Elasticsearch query:</p>from opentelemetry import trace
from elastic_opentelemetry import configure_opentelemetry
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.elasticsearch import ElasticsearchInstrumentor

def init_otel(app):
    configure_opentelemetry()
    FastAPIInstrumentor.instrument_app(app)
    ElasticsearchInstrumentor().instrument()

tracer = trace.get_tracer("search-api")<p><code>configure_opentelemetry()</code> reads the <code>OTEL_*</code> environment variables and sets up the tracer provider, exporter, and batch processor with Elastic-optimized defaults, including using the HTTP exporter automatically, which is what the mOTLP endpoint requires. If you see connection errors with vanilla OTel, the most common cause is accidentally using the gRPC exporter instead of HTTP.</p><p>The two instrumentors patch the FastAPI and <code>elasticsearch-py</code> libraries at startup so every request and every Elasticsearch call automatically generates a span, without any code changes to individual endpoints.</p><h2>Instrument your search API</h2><p>Here's where it gets interesting. This is the code that turns a generic API endpoint into a search analytics source:</p>from opentelemetry import trace

tracer = trace.get_tracer("search-api")

@app.post("/api/search")
def search(request: SearchRequest):
    with tracer.start_as_current_span("search") as span:
        # Set attributes BEFORE the query
        # (available even if the query fails)
        query_id = format(span.get_span_context().trace_id, "032x")
        span.set_attribute("search.query", request.query)
        span.set_attribute("search.query_id", query_id)

        results = es.search(
            index="products",
            body=build_query(request)
        )

        # Set attributes AFTER the query
        total_hits = results["hits"]["total"]["value"]
        span.set_attribute("search.result_count", total_hits)
        span.set_attribute("search.took_ms", results["took"])

        # Include query_id in the response so the frontend can link
        # click and conversion events back to this search
        return {
            **format_response(results),
            "query_id": query_id,
        }<p>A few things worth unpacking.</p><ul><li><p><code>start_as_current_span</code> creates the span and sets it as the active span in the current context. This matters because the Elasticsearch client instrumentation picks up the active span and nests its own spans underneath it. You get a span hierarchy automatically.</p></li><li><p><code>search.query_id</code> is derived from the trace ID. Every trace already has a unique identifier, and we're reusing it as the query identifier. There’s no UUID generation or database sequence. When we add click tracking later, clicks will reference this same <code>query_id</code> to link back to the search that produced the results.</p></li><li><p><code>search.result_count</code> does double duty. It tells you how many results came back, and when it's zero, you know you have a content gap. There’s no need for a separate boolean flag: Just filter on <code>result_count == 0</code> in your queries.</p></li></ul><p>Note that the application name is no longer set as a span attribute; it's the <code>service.name</code> resource attribute, configured once via <code>OTEL_SERVICE_NAME</code>. This is the standard OTel approach: Resource attributes describe the service, and span attributes describe the operation.</p><h3>Normalize search queries before analyzing them</h3><p>Notice that we're storing <code>request.query</code> as is. That means "Laptop Bag", "laptop bag", and " laptop bag " will be counted as three different queries when you aggregate with <code>STATS ... BY attributes.search.query</code>.</p><p>For cleaner analytics, normalize before setting the attribute:</p>span.set_attribute("search.query", request.query.strip().lower())<p>Lowercasing and trimming whitespace is enough for most cases. If you need the original phrasing (for display or debugging), store it in a separate attribute, like <code>search.query.original</code>. But start simple. You can always add the raw version later if you find you need it.</p><h3>What a search API trace looks like</h3><p>Once this is running, a single search request produces this trace:</p>HTTP POST /api/search          (root — auto-instrumented by FastAPI)
└── search                     (our span — search.* attributes live here)
    ├── info                   (ES client — auto-instrumented)
    ├── query_rules.get_ruleset (ES client)
    └── search                 (ES client — the actual Elasticsearch query)<p>The auto-instrumented spans give you HTTP latency and Elasticsearch query detail. Your <code>search</code> span in the middle ties them together with the business context: what the user searched for, how many results came back, how long Elasticsearch took.</p><h3>The search span attributes you need to capture</h3><p>Here's the full set of attributes we're capturing on the search span:</p><p>Attribute</p><p>Type</p><p>When set</p><p>Purpose</p><p>`search.query`</p><p>string</p><p>Before query</p><p>The query as the user entered it</p><p>`search.query_id`</p><p>string</p><p>Before query</p><p>Unique identifier, derived from trace ID</p><p>`search.result_count`</p><p>int</p><p>After query</p><p>Total matching results (0 = zero-result search)</p><p>`search.took_ms`</p><p>int</p><p>After query</p><p>Elasticsearch execution time in milliseconds</p><p>`search.query_response_hit_ids`</p><p>string[]</p><p>After query</p><p>Document IDs returned (optional; enables per-result analytics)</p><p>`feature_flag.key`</p><p>string</p><p>Before query</p><p>A/B test flag name (optional; pair with `feature_flag.result.variant` for the assigned variant; enables per-variant click-through rate (CTR) comparison)</p><p>We use the <code>search.*</code> namespace following OTel's convention of domain-specific prefixes (<code>http.*</code>, <code>db.*</code>, <code>messaging.*</code>). While there aren't standardized search conventions in OTel yet, <code>search.*</code> is self-describing and vendor-neutral. The naming is informed by the <a href="https://www.ubisearch.dev/">User Behavior Insights (UBI)</a> Standard, which defines a schema for search events. We reference it for structure without coupling to it. Where established OTel conventions exist, like <code>feature_flag.key</code> (flag name) and <code>feature_flag.result.variant</code> (assigned variant) for A/B experiments, we reuse them rather than inventing custom attributes.</p><p>You'll notice that <code>enduser.pseudo.id</code> isn't in the search span table above. We don't need it for query analytics, but you'll add it as soon as you introduce click tracking in our third blog; it ties click events back to a specific browser session, enabling per-user CTR and Mean Reciprocal Rank (MRR). Blog 3 adds <code>enduser.pseudo.id</code> (browser-generated, persistent across sessions) to link clicks back to searches. Our fourth blog focussing on revenue attribution documents <code>session.id</code> and <code>user.id</code> as optional extensions for authenticated users who want cross-device attribution.</p><h2>How OpenTelemetry attributes become queryable ES|QL fields</h2><p>Before we start querying, you need to understand how OTel attributes map to Elasticsearch fields. With OTel-native ingestion into Elastic, the mapping is straightforward.</p><p>OTel attribute</p><p>Type</p><p>ES|QL field</p><p>`search.query`</p><p>string</p><p>`attributes.search.query`</p><p>`search.result_count`</p><p>int</p><p>`attributes.search.result_count`</p><p>`search.took_ms`</p><p>int</p><p>`attributes.search.took_ms`</p><p>`search.query_id`</p><p>string</p><p>`attributes.search.query_id`</p><p>`feature_flag.key`</p><p>string</p><p>`attributes.feature_flag.key`</p><p>With OTel-native ingestion, attribute names preserve their dot notation under <code>attributes.*</code>. All types live in the same namespace; there’s no split between string and numeric fields. Booleans are stored as native booleans, not strings. If you've used Elastic APM's classic ingestion before, you'll appreciate the simplicity: What you set in code is what you query.</p><h2>Running ES|QL queries in Kibana Discover</h2><p>With spans flowing to Elastic, open Kibana and go to <strong>Discover</strong> (in the left sidebar under <strong>Analytics</strong>, or use the global search bar and type "Discover"). By default, you'll see the KQL query bar, a filter language familiar from Kibana dashboards. Click <strong>Try ES|QL</strong> in the top right to switch to the ES|QL editor. Unlike KQL (which filters documents) or the JSON query DSL (which requires nested objects), ES|QL is a piped language: Each <code>|</code> step transforms the previous output, making aggregations like <code>STATS count BY field</code> read naturally from left to right.</p><p>The editor gives you a full-width text area where you type piped queries. Results appear as both a table and an auto-generated chart; Kibana picks a sensible visualization based on your query shape. For <code>STATS ... BY</code> queries, you'll get a bar chart automatically.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt839635c0a21e4916/6a6a3403d57c1d4bd1c13ef0/d284fdad7c58576f79209e72b7579b37f4ebbe63-1440x708.png" alt="Kibana Discover ES|QL query results showing search count and average results by search query" /><p>Set the time range wide enough to capture your data (top-right date picker). If you're just getting started, try "Last 30 days".</p><h2>Six ES|QL queries for search analytics</h2><p>Everything below runs against <code>traces-generic.otel-default</code>, the index where Elastic's OTel-native ingestion automatically stores trace data. You don't need to create this index; it's provisioned by Elastic when the first OTLP span arrives.</p><p>These queries ran against our live demo cluster: 62 searches, ~20 distinct queries, latency range 77ms–153ms.</p><p><strong>Note:</strong> Results below are illustrative. When you run the <a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">reference project</a> and generate traffic with <code>python generate_traffic.py --blog 2 --sessions 50</code>, your exact numbers and top queries will vary based on session count and random query selection. The query patterns and ES|QL syntax are what matter here.</p><h3>Query 1: Are spans arriving?</h3><p>Start simple. Count your search spans.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS total_searches = COUNT(*)<p><strong>Result:</strong> 62.</p><p>If this returns zero, your spans aren't arriving. Check your OTLP endpoint and API key and that <code>init_otel()</code> is being called before any requests. The <code>name == "search"</code> filter ensures that you're counting your custom spans, not the auto-instrumented Elasticsearch client spans (which are also named "search").</p><h3>Query 2: What are users searching for?</h3><p>The first question every search team asks.</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND attributes.search.query != ""
  AND name == "search"
| STATS
    search_count = COUNT(*),
    avg_results = ROUND(AVG(attributes.search.result_count), 0)
  BY attributes.search.query
| SORT search_count DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Searches</p><p>Average results</p><p>laptop</p><p>9</p><p>8</p><p>headphones</p><p>7</p><p>12</p><p>running shoes</p><p>6</p><p>5</p><p>"laptop" was the most popular query, with nine searches. There were around 20 distinct queries total (including zero-result ones).</p><p>The <code>avg_results</code> column tells you whether popular queries are actually returning content. A query with high volume and low results is a relevance problem worth investigating. If your query has high volume and high results, check whether users are actually clicking. We'll get to that in the next blog focussing on measuring search quality with click data.</p><h3>Query 3: What percentage of searches return nothing?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    total = COUNT(*),
    zero_results = COUNT(CASE(attributes.search.result_count == 0, 1))
| EVAL zero_rate_pct = ROUND(100.0 * zero_results / total, 1)<p><strong>Result:</strong> 17.7% (11 out of 62 searches returned nothing).</p><p>We're using <code>attributes.search.result_count == 0</code>, a straightforward numeric comparison. No separate boolean attribute is needed when you already have the count.</p><p>A zero-results rate above 10% is worth investigating. Every zero-result search is a user who asked for something and got nothing back. Some of those are junk queries, but others reveal real content gaps or query parsing failures.</p><h3>Query 4: Which queries return nothing?</h3><p>The rate tells you there's a problem. This query tells you where.</p>FROM traces-generic.otel-default
| WHERE attributes.search.result_count == 0
  AND name == "search"
| STATS occurrences = COUNT(*) BY attributes.search.query
| SORT occurrences DESC
| LIMIT 20<p><strong>Results:</strong></p><p>Query</p><p>Occurrences</p><p>quantum physics calculator</p><p>4</p><p>unicorn saddle</p><p>3</p><p>holographic projector</p><p>2</p><p>time machine parts</p><p>2</p><p>Three different failure modes: "quantum physics calculator" and "unicorn saddle" are out-of-catalog queries you'll never be able to serve. This is useful to know but nothing to fix. "holographic projector" might be a real emerging category worth considering. "time machine parts" is probably noise. In a production catalog, these would be mixed with legitimate zero-result queries that <em>are</em> fixable, like missing synonyms, phrasing mismatches, or product gaps.</p><p>Repeated zero-result queries are the highest-priority fixes. One-off failures are usually noise.</p><h3>Query 5: How fast is search?</h3>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms)<p><strong>Result:</strong> Average 81ms, max 153ms.</p><p><code>search.took_ms</code> captures Elasticsearch's self-reported execution time, the <code>took</code> field from the search response. This is different from <em>span duration</em>, the wall-clock time from when the span started to when it ended (as we covered in the primer above). Span duration measures end-to-end time, including network round trips, serialization, and application logic. You want both: Comparing them tells you where overhead lives. If <code>took_ms</code> is 50ms but the span duration is 200ms, the extra 150ms is network or application overhead, not a query problem.</p><p>This attribute also keeps your analytics portable. If you're using OTel log records instead of spans (a lighter-weight alternative we mention in <a href="https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry">the first blog in the series</a>), there's no span duration. <code>took_ms</code> is the only timing signal you have.</p><p>We'll go deeper on search performance monitoring (Service Level Objectives [SLOs], alerting on latency regressions, and using this data for operational dashboards) in the last blog in the series focussing on Search Reliability Engineering.</p><p>Want to find the slow queries specifically?</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| STATS
    avg_ms = ROUND(AVG(attributes.search.took_ms), 0),
    max_ms = MAX(attributes.search.took_ms),
    search_count = COUNT(*)
  BY attributes.search.query
| SORT avg_ms DESC
| LIMIT 20<p>A query with high average latency and high result count is hitting many documents; consider query optimization. High latency with low results might mean complex filters or slow aggregations. Outlier max values are often cold caches or cluster issues.</p><h3>Query 6: How does search volume change over time?</h3><p>Counts, rates, and latencies tell you the <em>what</em>. Volume over time tells you the <em>when</em>: W<em>hen did traffic spike, when did it drop, and when did that zero-results rate jump?</em></p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
  AND name == "search"
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS searches = COUNT(*) BY bucket
| SORT bucket<p><code>DATE_TRUNC(5 minutes, @timestamp)</code> rounds each timestamp down to the nearest 5-minute boundary. The result is a time series that Kibana's Lens can render as a bar chart or line, showing your search traffic pattern for any time window.</p><p>Narrow the bucket for higher granularity (<code>1 minute</code>), widen it for trend analysis (<code>1 hour</code>, <code>1 day</code>). When you add this to a dashboard alongside your zero-results rate, you can answer: <em>Did zero-results spike because traffic changed or because something broke?</em></p><h2>Verify that your search API instrumentation is working</h2><p>If you're using the reference project, the full setup is:</p>git clone https://github.com/elastic/elasticsearch-labs.git
cd elasticsearch-labs/supporting-blog-content/search-analytics-otel
cp .env.example .env           # fill in ELASTICSEARCH_URL, ELASTIC_API_KEY,
                               # OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS
python3 -m venv venv &amp;&amp; source venv/bin/activate
pip install -r requirements.txt
python load_data.py             # index products into Elasticsearch
python app.py                   # starts on http://localhost:8000<p>Then trigger a search:</p>curl -X POST http://localhost:8000/api/search \
  -H "Content-Type: application/json" \
  -d '{"query":"laptop"}'<p>Wait 5–10 seconds for the <code>BatchSpanProcessor</code> to flush, and then open Kibana → Discover → switch to ES|QL mode and run:</p>FROM traces-generic.otel-default
| WHERE attributes.search.query IS NOT NULL
| LIMIT 5<p>You should see rows with <code>attributes.search.query</code>, <code>attributes.search.result_count</code>, and <code>attributes.search.took_ms</code>.</p><p>If no rows appear, check in order:</p><ol><li><p><code>OTEL_EXPORTER_OTLP_ENDPOINT</code> points to the mOTLP endpoint, not your Elasticsearch URL.</p></li><li><p><code>OTEL_EXPORTER_OTLP_HEADERS</code> includes <code>Authorization=ApiKey &lt;your-key&gt;</code>.</p></li><li><p><code>OTEL_TRACES_SAMPLER=always_on</code> is set (default sampler may drop spans).</p></li><li><p>Kibana → Observability → APM → Services shows <code>search-analytics-demo</code> (confirms export is working).</p></li></ol><h2>Turn ES|QL results into Kibana visualizations</h2><p>The bar chart that Discover auto-generates from your ES|QL results is a good start, but you can customize it. Click the <strong>pencil icon</strong> in the top-right corner of the chart to open the inline Lens editor.</p><p>From here you can:</p><ul><li><p>Change chart type (bar, line, area, pie, table, metric).</p></li><li><p>Adjust axes and add breakdown dimensions.</p></li><li><p>Save the visualization to a dashboard.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0e8869398e3cb98b/6a6a3404c40efb00b9d30954/e3db0028b0dff2ce5a524b62f600bfcfd8820f22-1440x708.png" alt="Kibana Lens configuration panel for a bar chart visualization of ES|QL search analytics data" /><p>This is the path from ad hoc ES|QL exploration to a persistent dashboard panel. You don't need to build visualizations from scratch; Discover and Lens handle the chart rendering from your query results.</p><p>Lens is Elastic's drag-and-drop visualization editor, and it's more capable than this quick workflow suggests. You can build multilayer charts, combine metrics with breakdowns, add reference lines, and design full dashboards that mix ES|QL panels with traditional aggregation-based visualizations. For search analytics, that means you can put top queries, zero-results trends, and latency percentiles side by side in a single view.</p><p>To go deeper:</p><ul><li><p><a href="https://www.elastic.co/guide/en/kibana/current/lens.html">Lens documentation</a>: A full guide to the visualization editor.</p></li><li><p><a href="https://www.elastic.co/docs/explore-analyze/visualize/esorql">ES|QL in Lens</a>: Using ES|QL queries as data sources for dashboard panels.</p></li><li><p><a href="https://www.elastic.co/guide/en/kibana/current/dashboard.html">Kibana Dashboards</a>: Building and sharing operational dashboards.</p></li><li><p>Use an <a href="https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/agent-builder-dashboards-and-visualizations">Agent built in Kibana to make visualizations for you</a></p></li></ul><p>We'll build a full search analytics dashboard in a later post.</p><h2>How sampling affects search analytics accuracy</h2><p>Most application performance monitoring (APM) configurations sample traces to control costs, capturing 10% or 25% of requests. For application monitoring, that's fine. For search analytics, it's a problem.</p><p>If you're sampling at 10%, your "total searches" count is 90% lower than reality. Your zero-results rate is still accurate (it's a ratio), but volume counts are off.</p><p>Two approaches:</p><p><strong>Approach 1: Configure 100% sampling for search endpoints.</strong> Your search API probably handles far fewer requests than your main application, so the data volume increase is manageable. The simplest way is through environment variables:</p># 100% sampling (capture every trace)
export OTEL_TRACES_SAMPLER=always_on

# Or sample a percentage (e.g. 50%)
export OTEL_TRACES_SAMPLER=traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.5<p>These are head-based sampling decisions made at the start of each trace. They apply globally to the service, which is fine if your search API is a dedicated service. If search shares a service with other endpoints and you need per-endpoint sampling rules, you can implement a custom <code>Sampler</code> in the OTel SDK that inspects the span name or attributes before deciding.</p><p>More sophisticated routing (sampling differently per endpoint, dropping noisy spans, or making decisions after a trace completes [tail-based sampling]) typically involves deploying an OTel Collector (such as the <a href="https://www.elastic.co/docs/reference/edot-collector">EDOT Collector</a>) as an intermediary between your application and Elastic. That's a valuable architecture pattern, but it's beyond the scope of this post. See the <a href="https://opentelemetry.io/docs/collector/">OTel Collector documentation</a> and <a href="https://www.elastic.co/docs/reference/edot-collector/modes">Elastic's EDOT Deployment</a> for more on collector-based sampling and routing architectures.</p><p><strong>Approach 2: Upscale in your queries.</strong> If you know the sampling rate, multiply: <code>EVAL estimated_total = total_searches * 10</code>. Ratios and averages stay correct; only absolute counts need adjustment.</p><p>For more on sampling strategies generally, see the <a href="https://opentelemetry.io/docs/concepts/sampling/">OTel sampling documentation</a>.</p><p>For the queries in this post, we used 100% sampling.</p><h2>What's next: Adding click tracking to search analytics</h2><p>The six ES|QL queries in this post answer: <em>What do users search for, what returns nothing, how fast is search, and when does traffic spike?</em> They're all derived from a single instrumentation point: the search span.</p><p>But they can't tell you whether users are finding what they need. A search that returns 15 results looks healthy from the server side. But if nobody clicks any of those results, your ranking has a problem.</p><p>In the next post, we add <em>click tracking</em>, a second span that captures which result the user clicked and where it appeared in the list. If you've been running the reference project, you already have 62 search spans; the next post builds directly on that data. With searches and clicks linked together, we'll calculate:</p><ul><li><p><strong>Click-through rate (CTR):</strong> What percentage of searches result in a click.</p></li><li><p><strong>Mean Reciprocal Rank (MRR):</strong> How far down the results users have to scroll.</p></li><li><p><strong>Click position distribution:</strong> The shape of where users click.</p></li></ul><p>The pattern is the same: You add attributes to spans and query them with ES|QL. You get a richer view of the same data without introducing new infrastructure.</p><h2>Resources to get started with search analytics on OpenTelemetry</h2><ul><li><p><a href="https://github.com/elastic/elasticsearch-labs/tree/main/supporting-blog-content/search-analytics-otel">Reference project</a>: Working code for the entire blog series; clone, configure, run.</p></li><li><p><a href="https://github.com/elastic/elastic-otel-python">EDOT Python</a>: Elastic distribution of OpenTelemetry for Python.</p></li><li><p><a href="https://www.elastic.co/docs/solutions/observability/apm/opentelemetry">OpenTelemetry with Elastic</a>: How to send OTel data to Elastic APM.</p></li><li><p><a href="https://opentelemetry.io/docs/languages/python/">OpenTelemetry Python SDK</a>: Upstream SDK documentation and instrumentation guides.</p></li><li><p><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a>: Query language reference.</p></li><li><p><a href="https://www.ubisearch.dev/">UBI Standard</a>: Reference schema for search event structure.</p></li></ul><p><em>This is the second post in a series on search analytics with OpenTelemetry and Elastic. Next up: Measuring search quality: Click tracking, CTR, MRR, and click position analysis.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/search-analytics-opentelemetry-esql</guid>
    <category><![CDATA[Analytics]]></category>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf69e1ba73bd5d402/6a6a340599442cd9e0df1d94/1794a179d9536e693a0982634da59aff209c9d68-1280x720.png" length="0" type="image/png"/>
    <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Training LTR models in Elasticsearch with judgement lists based on user behavior data]]></title>
    <description><![CDATA[Learn how to use UBI data to create judgment lists to automate the training of your Learning to Rank (LTR) models in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>A big challenge when using <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr"><em><strong>Learning-to-rank</strong></em></a> models is to create a high-quality <a href="https://www.elastic.co/search-labs/blog/judgment-lists"><em><strong>judgment list</strong></em></a> to train the model on. Traditionally, this process involves a <em><strong>manual</strong></em> evaluation of query-document relevance to assign a grade to each one. This is a slow process that does not scale well and is hard to maintain (imagine having to update a list with hundreds of entries by hand).</p><p>Now, what if we could use real user interactions with our search application to create this training data? Using <a href="https://www.elastic.co/search-labs/blog/elasticsearch-plugin-user-behavior-insights"><em><strong>UBI</strong></em></a> data lets us do just that. Creating an automatic system that can capture and use our searches, clicks, and other interactions to generate a judgment list. This process can scale and be repeated far more easily than a manual interaction and would tend to yield better results. In this blog, we will explore how we can query UBI data stored in Elasticsearch to calculate meaningful signals to generate a training dataset for an <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction"><em><strong>LTR</strong></em></a> model.</p><p><em><strong>You can find the full experiment </strong></em><a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git"><em><strong>here</strong></em></a><em><strong>.</strong></em></p><h2>Why UBI data can be useful to train your LTR model</h2><p>UBI data offers several advantages over a manual annotation:</p><ul><li><p><strong>Volume:</strong> Given that UBI data comes from real interactions, we can collect much more data than we can generate manually. This is assuming we have enough traffic to generate this data, of course.</p></li><li><p><strong>Real User intent:</strong> Traditionally, a manual judgment list comes from an expert evaluation of the available data. On the other hand, UBI data reflects real user behavior. This means we can generate better training data that will improve our search system's accuracy, because it's based on how users actually interact with and find value in your content rather than theoretical assumptions about what should be relevant.</p></li><li><p><strong>Continuous updates:</strong> Judgment lists need to be refreshed over time. If we create them from UBI data, we can have current data that results in updated judgment lists.</p></li><li><p><strong>Cost effectiveness:</strong> Without the overhead of manually creating a judgment list, the process can be repeated efficiently any number of times.</p></li><li><p><strong>Natural query distribution</strong>: UBI data represent real user queries, which can drive deeper changes. For example, do our users use natural language to search in our system? If so, we might want to implement a semantic search or hybrid search approach.</p></li></ul><p>It does come with some warnings, though:</p><ul><li><p><strong>Bias amplification: </strong>Popular content is more likely to receive clicks, just because it gets more exposure. So this might end up amplifying popular items and possibly drowning out better options.</p></li><li><p><strong>Incomplete coverage: </strong>New content lacks any interactions, so it might be difficult for it to be high in the results. Rare queries can also lack sufficient data points to create meaningful training data.</p></li><li><p><strong>Seasonal variations:</strong> If you expect user behaviour to change drastically over time, historical data might not tell you much about what is a good result.</p></li><li><p><strong>Task ambiguity:</strong> A click doesn’t always guarantee that the user found what they were looking for.</p></li></ul><h2>Grades calculation</h2><h3>Grades for LTR training</h3><p>To train LTR models, we need to provide some numerical representation of how relevant a document is for a query. In our implementation, this number is a continuous score going from 0.0 to 5.0+, where higher scores indicate higher relevance.</p><p>To show how this grading system works, consider this manually created example:</p><p>Query</p><p>Document content</p><p>Grade</p><p>Explanation</p><p>"best pizza recipe"</p><p>"Authentic Italian Pizza Dough Recipe with Step-by-Step Photos"</p><p>4.0</p><p>Highly relevant, exactly what the user is looking for </p><p>"best pizza recipe"</p><p>"History of Pizza in Italy"</p><p>1.0</p><p>Somewhat in topic, it is about pizza but is not a recipe</p><p>"best pizza recipe"</p><p>"Quick 15-Minute Pizza Recipe for Beginners"</p><p>3.0</p><p>Relevant, a good result but it maybe misses the mark on being the “best” recipe. </p><p>"best pizza recipe"</p><p>"Car Maintenance Guide"</p><p>0.0</p><p>Not relevant at all, completely unrelated to the query</p><p>As we can see here, the grade is a numerical representation of how relevant a document is to our sample query of “best pizza recipe”. With these scores, our LTR model can learn which documents should be presented higher in the results.</p><p>How to calculate the grades is the core of our training dataset. There are <a href="https://www.elastic.co/search-labs/blog/judgment-lists">multiple approaches</a> to do this, each with its own strengths and weaknesses. For example, we could assign a binary score of 1 for relevant 0 for not relevant or we could just count the number of clicks in a resulting document for each query.</p><p>In this blog post, we will be using a different approach, <em><strong>taking into account the user behavior as our input and calculating a grade number as the output</strong></em>. We will also be correcting bias that could occur from the fact that higher results tend to be more clicked, regardless of the relevancy of the document.</p><h2>Calculating the grades - COEC algorithm</h2><p>The COEC (<a href="https://www.wsdm-conference.org/2010/proceedings/docs/p351.pdf">Clicks over Expected Clicks</a>) algorithm is a methodology for calculating judgment grades from user clicks.
As we stated earlier, users tend to click on higher-positioned results even if the document is not the most relevant to the query; this is called <a href="https://eugeneyan.com/writing/position-bias/">Position Bias</a>. The core idea for using the COEC algorithm is that not all clicks are equally significant; a click on a document at position 10 indicates that the document is much more relevant to the query than a click on a document at position 1. To quote the research paper about the COEC algorithm (linked above):</p><p><em>“It is well known that the click-through rate (CTR) of search results or advertisements decreases significantly depending on the position of the results.”</em></p><p>You can further read about position bias <a href="https://www.researchgate.net/publication/200110550_An_experimental_comparison_of_click_position-bias_models">here</a>.</p><p>To address this with the COEC algorithm, we follow these steps:</p><p><strong>1. Establish position baselines:</strong> We calculate the click-through rate (CTR) for each search position from 1 to 10. This means we determine what percentage of users typically click on position 1, position 2, and so on. This step captures the users’ natural position bias.

We calculate the CTR using:Where:</p><p> = Position. From 1 to 10</p><p>
= Total clicks (on any document) at position p across all queries</p><p>
 = Total impressions: How many times any document appeared at the position p across all queries</p><p>Here, we expect higher positions to get more clicks.</p><p></p><p><strong>2.</strong> <strong>Calculate Expected Clicks (EC)</strong>:</p><p>This metric establishes how many clicks a document “should” have received based on the positions it appeared in and the CTR for those positions We calculate EC using:Where:</p><p> = All queries where the document d appeared</p><p>
= Position of the document d in the query q results</p><p></p><p>3. <strong>Count actual clicks: </strong>We count the actual total clicks a document received across all queries where it appeared, hereafter called <strong>A(d).</strong></p><p></p><p>4. <strong>Compute the COEC score:</strong> This is the ratio of Actual clicks (A(d)) over the Expected clicks (EC(d)):This metric normalizes for position bias like this:</p><ul><li><p>A score of 1.0 means the document performed exactly as expected given the positions it appeared in.</p></li><li><p>A score above 1.0 means the document performed better than expected by looking at its positions. So this document is more relevant for the query.</p></li><li><p>A score under 1.0 means the document performed worse than expected by looking at its positions. So this document is less relevant for the query.</p></li></ul><p><em><strong>The end result is a grade number that captures what users are looking for, taking into account position-based expectations extracted from real interactions with our search system.</strong></em></p><h2>Technical implementation</h2><p>We will be creating a script to create a judgment list to train an LTR model.</p><p>The input for this script is the UBI data indexed in Elastic (queries and events).</p><p>The output is a judgment list in a CSV file generated from these UBI documents using the COEC algorithm. This judgment list can be used with <a href="https://www.elastic.co/search-labs/blog/elasticsearch-learning-to-rank-introduction">Eland</a> to extract relevant features and train an LTR model.</p><h3>Quick start</h3><p>To generate a judgment list from the sample data in this blog, you can follow these steps:</p><p>1. Clone the repository:</p>git clone https://github.com/Alex1795/elastic-ltr-judgement_list-blog.git  
cd elastic-ltr-judgement_list-blog<p>2. Install required libraries</p><p>For this script, we need the following libraries:</p><ul><li><p><em>pandas</em>: to save the judgment list</p></li><li><p><em>elasticsearch</em>: To get the UBI data from our Elastic deployment</p></li></ul><p>We also need Python 3.11</p>pip install -r requirements.txt<p>3. Update the environment variables for your Elastic deployment in a <a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog/blob/main/.env-example">.env file</a></p><ul><li><p>ES_HOST</p></li><li><p>API_KEY</p></li></ul><p>To add the environment variables, use:</p>source .env<p>4. Create the ubi_queries, ubi_events indices, and upload the sample data. Run the setup.py file:</p>python setup.py<p>5. Run the Python script:</p>python judgement_list-generator.py<p>If you follow these steps, you should see a new file called judgment_list.csv that looks like this:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt94317eda8f7af194/6a170aa46f7f04542f914821/2531090131ac9fe3e4e1d79de9d156fc47a7825a-782x531.png" alt="" /><p>This script calculates the grades applying the COEC algorithm discussed before using the <strong>calculate_relevance_grade()</strong> function that is shown below.</p><h2>Data architecture</h2><h3>Ubi queries</h3><p>Our UBI queries index has information about the queries executed in our search system. This is a sample document:</p>{
          "client_id": "client_002",
          "query": "italian pasta recipes",
          "query_attributes": {
            "search_type": "recipe",
            "category": "food",
            "cuisine": "italian"
          },
          "query_id": "q002",
          "query_response_id": "qr002",
          "query_response_object_ids": [
            "doc_011",
            "doc_012",
            "doc_013",
            "doc_014",
            "doc_015",
            "doc_016",
            "doc_017",
            "doc_018",
            "doc_019",
            "doc_020"
          ],
          "timestamp": "2024-08-14T11:15:00Z",
          "user_query": "italian pasta recipes"
        }<p>Here we can see data from the user (client_id), from the results of the query (query_response_object_ids), and the query itself (timestamp, user_query)</p><h3>Ubi click events</h3><p>Our ubi_events index has data from each time a user clicked a document in the results. This is a sample document:</p>{
          "action_name": "click",
          "application": "recipe_search",
          "client_id": "client_001",
          "event_attributes": {
            "object": {
              "description": "Authentic Italian Pizza Dough Recipe with Step-by-Step Photos",
              "device": "desktop",
              "object_id": "doc_001",
              "position": {
                "ordinal": 1,
                "page_depth": 1
              },
              "user": {
                "city": "New York",
                "country": "USA",
                "ip": "192.168.1.100",
                "location": {
                  "lat": 40.7128,
                  "lon": -74.006
                },
                "region": "NY"
              }
            }
          },
          "message": "User clicked on document doc_001",
          "message_type": "click",
          "query_id": "q001",
          "timestamp": "2024-08-14T10:31:00Z",
          "user_query": "best pizza recipe"
        }<h2>Judgment list generation script</h2><h3>General script overview</h3><p>This script automates the generation of the judgment list using UBI data from Queries and Click events stored in Elasticsearch. It executes these tasks:</p><ul><li><p>Fetches and processes the UBI data in Elasticsearch.</p></li><li><p>Correlates UBI events with its queries.</p></li><li><p>Calculates the CTR for each position.</p></li><li><p>Calculates the expected clicks (EC) for each document.</p></li><li><p>Counts the actual clicks for each document.</p></li><li><p>Calculates the COEC score for each query-document pair.</p></li><li><p>Generates a judgment list and writes it in a CSV file.</p></li></ul><p>Let’s go over each function:</p><h3>connect_to_elasticsearch()</h3>def connect_to_elasticsearch(host, api_key):
    """Create and return Elasticsearch client"""
    try:
        es = Elasticsearch(
            hosts=[host],
            api_key=api_key,
            request_timeout=60
        )
        # Test the connection
        if es.ping():
            print(f"✓ Successfully connected to Elasticsearch at {host}")
            return es
        else:
            print("✗ Failed to connect to Elasticsearch")
            return None
    except Exception as e:
        print(f"✗ Error connecting to Elasticsearch: {e}")
        return None<p>This function returns an Elasticsearch client object using the host and api key.</p><h3>fetch_ubi_data()</h3>def fetch_ubi_data(es_client: Elasticsearch, queries_index: str, events_index: str,
                   size: int = 10000) -&gt; Tuple[List[Dict], List[Dict]]:
    """
    Fetch UBI queries and events data from Elasticsearch indices.

    Args:
        es_client: Elasticsearch client
        queries_index: Name of the UBI queries index
        events_index: Name of the UBI events index
        size: Maximum number of documents to fetch

    Returns:
        Tuple of (queries_data, events_data)
    """
    logger.info(f"Fetching data from {queries_index} and {events_index}")

    # Fetch queries with error handling
    try:
        queries_response = es_client.search(
            index=queries_index,
            body={
                "query": {"match_all": {}},
                "size": size
            }
        )
        queries_data = [hit['_source'] for hit in queries_response['hits']['hits']]
        logger.info(f"Fetched {len(queries_data)} queries")

    except Exception as e:
        logger.error(f"Error fetching queries from {queries_index}: {e}")
        raise

    # Fetch events (only click events for now) with error handling
    try:
        events_response = es_client.search(
            index=events_index,
            body={
                "query": {
                    "term": {"message_type.keyword": "CLICK_THROUGH"}
                },
                "size": size
            }
        )
        events_data = [hit['_source'] for hit in events_response['hits']['hits']]
        logger.info(f"Fetched {len(events_data)} click events")

    except Exception as e:
        logger.error(f"Error fetching events from {events_index}: {e}")
        raise

    logger.info(f"Data fetch completed successfully - Queries: {len(queries_data)}, Events: {len(events_data)}")

    return queries_data, events_data<p>This function is the data extraction layer; it connects with Elasticsearch to fetch UBI queries using a match_all query and filters UBI events to get ‘CLICK_THROUGH’ events only.</p><h3>process_ubi_data()</h3>def process_ubi_data(queries_data: List[Dict], events_data: List[Dict]) -&gt; pd.DataFrame:
    """
    Process UBI data and generate judgment list.

    Args:
        queries_data: List of query documents from UBI queries index
        events_data: List of event documents from UBI events index

    Returns:
        DataFrame with judgment list (qid, docid, grade, keywords)
    """
    logger.info("Processing UBI data to generate judgment list")

    # Group events by query_id
    clicks_by_query = {}
    for event in events_data:
        query_id = event['query_id']
        if query_id not in clicks_by_query:
            clicks_by_query[query_id] = {}

        # Extract clicked document info
        object_id = event['event_attributes']['object']['object_id']
        position = event['event_attributes']['object']['position']['ordinal']

        clicks_by_query[query_id][object_id] = {
            'position': position,
            'timestamp': event['timestamp']
        }

    judgment_list = []

    # Process each query
    for query in queries_data:
        query_id = query['query_id']
        user_query = query['user_query']
        document_ids = query['query_response_object_ids']

        # Get clicks for this query
        query_clicks = clicks_by_query.get(query_id, {})

        # Generate judgment for each document shown
        for doc_id in document_ids:
            grade = calculate_relevance_grade(doc_id, query_clicks, document_ids, queries_data, events_data)

            judgment_list.append({
                'qid': query_id,
                'docid': doc_id,
                'grade': grade,
                'query': user_query
            })

    df = pd.DataFrame(judgment_list)
    logger.info(f"Generated {len(df)} judgment entries for {df['qid'].nunique()} unique queries")

    return df<p>This function handles the judgment list generation. It starts processing the UBI data by associating UBI events and queries. Then it calls the calculate_relevance_grade() function for each document-query pair to obtain the entries for the judgment list. Finally, it returns the resulting list as a pandas dataframe.</p><h3>calculate_relevance_grade()</h3>def calculate_relevance_grade(document_id: str, clicks_data: Dict,
                              query_response_ids: List[str], all_queries_data: List[Dict] = None,
                              all_events_data: List[Dict] = None) -&gt; float:
    """
    Calculate COEC (Click Over Expected Clicks) relevance score for a document.

    Args:
        document_id: ID of the document
        clicks_data: Dictionary of clicked documents with their positions for current query
        query_response_ids: List of document IDs shown in search results (ordered by position)
        all_queries_data: All queries data for calculating position CTR averages
        all_events_data: All events data for calculating position CTR averages

    Returns:
        COEC relevance score (continuous value, typically 0.0 to 5.0+)
    """

    # If no global data provided, fall back to simple position-based grading
    if all_queries_data is None or all_events_data is None:
        logger.warning("No global data provided, falling back to position-based grading")
        # Simple fallback logic
        if document_id in clicks_data:
            position = clicks_data[document_id]['position']
            if position &gt; 3:
                return 4.0
            elif position &gt;= 1 and position &lt;= 3:
                return 3.0
        if document_id in query_response_ids:
            position = query_response_ids.index(document_id) + 1
            if position &lt;= 5:
                return 2.0
            elif position &gt;= 6 and position &lt;= 10:
                return 1.0
        return 0.0

    # Calculate rank-aggregated click-through rates
    position_ctr_averages = {}
    position_impression_counts = {}
    position_click_counts = {}

    # Initialize counters
    for pos in range(1, 11):  # Positions 1-10
        position_impression_counts[pos] = 0
        position_click_counts[pos] = 0

    # Count impressions (every document shown contributes)
    for query in all_queries_data:
        for i, doc_id in enumerate(query['query_response_object_ids'][:10]):  # Top 10 positions
            position = i + 1
            position_impression_counts[position] += 1

    # Count clicks by position
    for event in all_events_data:
        if event.get('action_name') == 'click':
            position = event['event_attributes']['object']['position']['ordinal']
            if position &lt;= 10:
                position_click_counts[position] += 1

    # Calculate average CTR per position
    for pos in range(1, 11):
        if position_impression_counts[pos] &gt; 0:
            position_ctr_averages[pos] = position_click_counts[pos] / position_impression_counts[pos]
        else:
            position_ctr_averages[pos] = 0.0

    # Calculate expected clicks for this specific document
    expected_clicks = 0.0

    # Count how many times this document appeared at each position for any query
    for query in all_queries_data:
        if document_id in query['query_response_object_ids']:
            position = query['query_response_object_ids'].index(document_id) + 1
            if position &lt;= 10:
                expected_clicks += position_ctr_averages[position]

    # Count total actual clicks for this document across all queries
    actual_clicks = 0
    for event in all_events_data:
        if (event.get('action_name') == 'click' and
                event['event_attributes']['object']['object_id'] == document_id):
            actual_clicks += 1

    # Calculate COEC score
    if expected_clicks &gt; 0:
        coec_score = actual_clicks / expected_clicks
    else:
        coec_score = 0.0

    logger.debug(
        f"Document {document_id}: {actual_clicks} clicks / {expected_clicks:.3f} expected = {coec_score:.3f} COEC")

    return coec_score<p>This is the function that implements the COEC algorithm. It calculates the CTR for each position, then it compares the actual clicks for a document-query pair, and finally calculates the actual COEC score for each one.</p><h3>generate_judgment_statistics()</h3>def generate_judgment_statistics(df: pd.DataFrame) -&gt; Dict:
    """Generate statistics about the judgment list."""
    stats = {
        'total_judgments': len(df),
        'unique_queries': df['qid'].nunique(),
        'unique_documents': df['docid'].nunique(),
        'grade_distribution': df['grade'].value_counts().to_dict(),
        'avg_judgments_per_query': len(df) / df['qid'].nunique() if df['qid'].nunique() &gt; 0 else 0,
        'queries_with_clicks': len(df[df['grade'] &gt; 1]['qid'].unique()),
        'click_through_rate': len(df[df['grade'] &gt; 1]) / len(df) if len(df) &gt; 0 else 0
    }
    return stats<p>It generates useful statistics from the judgment list, such as total queries, total unique documents, or the grade distribution. This is purely informational and does not change the resulting judgment list.</p><h2>Results and impact</h2><p>If you follow the instructions in the Quick start section, you should see a resulting CSV file containing a judgment list with 320 entries (you can see a <a href="https://github.com/Alex1795/elastic-ltr-judgement_list-blog/blob/main/judgment_list.csv">sample output</a> in the repo). With these fields:</p><ul><li><p>qid: unique ID of the query</p></li><li><p>docid: unique identifier for a resulting document</p></li><li><p>grade: the calculated grade for the query-document pair</p></li><li><p>query: The user query</p></li></ul><p> Let’s look at the results for the query “Italian recipes”:</p><p>qid</p><p>docid</p><p>grade</p><p>query</p><p>q1-italian-recipes</p><p>recipe_pasta_basics</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_pizza_margherita</p><p>3.333333</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_risotto_guide</p><p>10.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_french_croissant</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_spanish_paella</p><p>0.0</p><p>Italian recipes</p><p>q1-italian-recipes</p><p>recipe_greek_moussaka</p><p>1.875</p><p>Italian recipes</p><p>We can see from the results that for the query “Italian recipes”:</p><ul><li><p>The risotto recipe is definitely the best result for the query, receiving 10 times more clicks than expected</p></li><li><p>Pizza Margherita is a great result too.</p></li><li><p>The Greek mousaka (surprisingly) is a good result as well and performs better than its position on the results would suggest. This means a few users looking for Italian recipes got interested in this recipe instead. Maybe these users are interested in Mediterranean dishes in general. At the end, what this tells us is that this could be a good result to be shown under the other two ‘better’ matches we discussed above.</p></li></ul><h2>Conclusion</h2><p>Using UBI data lets us automate the training of LTR models, creating high-quality judgment lists from our own users. UBI data provides a big dataset that reflects how our search system is being used.By using the COEC algorithm to generate the grades, we account for inherent bias while at the same time, it reflects what a user considers a better result. The method outlined here can be applied to real use cases to provide a better search experience that evolves with real usage trends.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/training-learning-to-rank-models-elasticsearch-ubi-data</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Elastic Cloud Hosted]]></category>
    <dc:creator><![CDATA[Alexander Dávila]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt037eb2f4d380fe65/6a170aa67d8d67397170e6e6/762bf09c28829d626d42c2cfadc719e1dd618d1b-1536x1024.png" length="0" type="image/png"/>
    <pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Export your Kibana Dev Console requests to Python and JavaScript Code]]></title>
    <description><![CDATA[The Kibana Dev Console now offers the option to export requests to Python and JavaScript code that is ready to be integrated into your application.]]></description>
    <content:encoded><![CDATA[<p>Have you used the Kibana Dev Console? This is a fantastic prototyping tool that allows you to build and test your Elasticsearch requests interactively. But what do you do after you have a working request in the Console?</p><p>In this article we'll take a look at the new code generation feature in the Kibana Dev Console, and how it can significantly reduce your development effort by generating ready to use code for you.</p><p>This feature is available in our Serverless platform and in Elastic Cloud and self-hosted releases 8.16 and up.</p><h2>The Kibana Dev Console</h2><p>This section provides a quick introduction to the <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana Dev Console</a>, in case you have never used it before. Skip to the next section if you are already familiar with it.</p><p>While you are in any part of the Search section in Kibana, you will notice a "Console" link at the bottom of your browser's page:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7c0bbd6964b0886d/6a170aeb6f7f04dfb991484b/e80850635ecc74536696743181afb3ac0c74e38f-1024x742.png" alt="The Kibana Dev Console - Open Console" /><p>When you click this link, the Console expands to cover the page. Click it again to collapse it.</p><p>In the left-side panel of the Dev Console, you can enter Elasticsearch requests, with the help of an interactive editor that provides auto-completion and checks your syntax. Some example requests are already pre-populated so that you have something to start experimenting with.</p><p>When the cursor is on a request, a "play" button appears to its right. You can click this button to send the request to your Elasticsearch server.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8e8bf8f61f065daa/6a170aed964cea4ffa08bb9b/520637e15cd03234aefd26502e42c80310b3734f-1006x230.png" alt="Kibana Dev Console Send Request" /><p>After you execute a request, the response from the server appears in the panel on the right.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c9a61493ab010d9/6a170aef0e2e49de6d41a0e2/ef250921da3ff260a6f56d6d4745842096809564-1024x642.png" alt="Kibana Dev Console Response" /><h2>Code Export feature in Kibana Dev Console</h2><p>The Dev Console makes it easy to prototype your requests or queries until you get exactly what you want. But what happens next? If you need to convert the request to code so that you can incorporate it into your application, then you can save time using the new code export feature.</p><p>Next to the Play button you will find the three dot or "kebab" button, which opens a menu of options. The first option provides access to the code export feature. If you've never used this feature before, it will appear with a "Copy as curl" label.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69fd88f2ae0eadae/6a170af02b835f8ca2f4b205/27fe3d5aa5874d094d26d35ff2188ccc0e435b9f-1272x476.png" alt="Kibana Dev Console Options Menu" /><p>If you select this option, your clipboard will be loaded with a <a href="https://curl.se/">curl</a> command that is equivalent to the selected request.</p><p>Now, things get more interesting when you click the "Change" link, which allows you to switch to a different target language. In this initial release, the code export adds support for Python and JavaScript. More languages are expected to be added in future releases.</p><p>You can now select your desired language and click "Copy code" to put the exported code in your clipboard. You can also change the default language that is offered in the menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" alt="Kibana Dev Console Select Language" /><p>The exported code is a complete script in the selected language, using the official Elasticsearch client for that language. Here is an example of how the <code>PUT /my-index</code> request shown above looks when exported to the Python language:</p>import os
from elasticsearch import Elasticsearch

client = Elasticsearch(
    hosts=["&lt;your-elasticsearch-endpoint-url-here"],
    api_key=os.getenv("ELASTIC_API_KEY"),
)

resp = client.indices.create(
    index="my-index",
)
print(resp)<p>To use the exported code follow these steps:</p><ul><li><p>Paste the code from the clipboard to a new file with the correct extension (<code>.py</code> for Python, or <code>.js</code> for JavaScript).</p></li><li><p>In your terminal, add an environment variable called <code>ELASTIC_API_KEY</code> with a valid API Key for your Elasticsearch cluster. You can <a href="https://www.elastic.co/guide/en/kibana/current/api-keys.html#create-api-key">create an API key</a> right in Kibana if you don't have one yet.</p></li><li><p>Execute the script with the <code>python</code> or <code>node</code> commands depending on your language, making sure the official Elasticsearch client is installed.</p></li></ul><p>Now you are ready to adapt the exported code as needed to integrate it into your application!</p><h2>Conclusion</h2><p>In this article you have learned about the new Code Export feature in the Kibana Dev Console. We hope this feature will streamline your development process with Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" length="0" type="image/png"/>
    <pubDate>Wed, 30 Oct 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From ES|QL to native Pandas dataframes in Python]]></title>
    <description><![CDATA[Learn how to export ES|QL queries as native Pandas dataframes in Python through practical examples.]]></description>
    <content:encoded><![CDATA[<p>Since Elasticsearch 8.15 or with Elasticsearch Serverless, <a href="https://github.com/elastic/elasticsearch/pull/109873">ES|QL responses support the Apache Arrow streaming format</a>. This blog post will show you how to take advantage of it in Python. In an <a href="https://www.elastic.co/search-labs/blog/esql-pandas-dataframes-python">earlier blog post</a>, I demonstrated how to convert ES|QL queries to Pandas dataframes using CSV as an intermediate representation. Unfortunately, CSV requires explicit type declarations, is slow (especially for larger datasets) and does not handle nested arrays and objects. Apache Arrow lifts all these limitations.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta5d7fdd54f312f06/6a17d7bbfaa913809b93c6db/7decbe330061eae8108f7ad6a32a2df01f55244f-389x144.svg" alt="ES|QL produces tables" /><h2>ES|QL to Pandas dataframes in Python</h2><h3>Importing test data</h3><p>First, let's import some test data. As before, we will be using the <code>employees</code> <a href="https://github.com/elastic/elasticsearch/blob/d46bcc968e6cabca55f1a62b2218e9fc4e84e9d4/x-pack/plugin/esql/qa/testFixtures/src/main/resources/employees.csv">sample data</a> and <a href="https://github.com/elastic/elasticsearch/blob/main/x-pack/plugin/esql/qa/testFixtures/src/main/resources/mapping-default.json">mappings</a>. The easiest way to load this dataset is to <a href="https://gist.github.com/pquentin/7cf29a5932cf52b293699dd994b1a276">run these two Elasticsearch API requests</a> in the <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana Console</a>.</p><h3>Converting dataset to a Pandas DataFrame object</h3><p>OK, with that out of the way, let's convert the full <code>employees</code> dataset to a Pandas DataFrame object using the ES|QL Arrow export:</p>from elasticsearch import Elasticsearch
import pandas as pd

client = Elasticsearch(
    "https://[host].elastic-cloud.com",
    api_key="...",
)

response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | LIMIT 500
    """,
    format="arrow",
)
df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>Even though this dataset only contains 100 records, we use a <code>LIMIT</code> command to avoid ES|QL warning us about potentially missing records. This prints the following dataframe:</p>    avg_worked_seconds           birth_date  ...  salary still_hired
0            268728049  1953-09-02 00:00:00  ...   57305        True
1            328922887  1964-06-02 00:00:00  ...   56371        True
2            200296405  1959-12-03 00:00:00  ...   61805       False
3            311267831  1954-05-01 00:00:00  ...   36174        True
4            244294991  1955-01-21 00:00:00  ...   63528        True
..                 ...                  ...  ...     ...         ...
95           204381503  1954-09-16 00:00:00  ...   43889       False
96           206258084  1952-02-27 00:00:00  ...   71165       False
97           272392146  1961-09-23 00:00:00  ...   44817       False
98           377713748  1956-05-25 00:00:00  ...   73578        True
99           223910853  1953-04-21 00:00:00  ...   68431        True

[100 rows x 17 columns]
<p>OK, so what actually happened here?</p><ul><li><p>Given <code>format="arrow"</code>, Elasticsearch returns binary Arrow streaming data</p></li><li><p>The Elasticsearch Python client looks at the Content-Type header and creates a <a href="https://arrow.apache.org/docs/python/index.html">PyArrow object</a></p></li><li><p>Finally, PyArrow's <a href="https://arrow.apache.org/docs/python/pandas.html">Pandas integration</a> converts the PyArrow object to a Pandas dataframe.</p></li></ul><p>Note that the <code>types_mapper=pd.ArrowDtype</code> parameter asks Pandas to use a PyArrow backend instead of a NumPy backend, since the source data is PyArrow. While this backend is not enabled by default for compatibility reasons, it <a href="https://datapythonista.me/blog/pandas-20-and-the-arrow-revolution-part-i">has many advantages</a>: it handles missing values, is faster, more interopable and supports more types. (This is not a <a href="https://arrow.apache.org/docs/python/pandas.html#memory-usage-and-zero-copy">zero copy conversion</a>, however.)</p><p>For this example to work, the Pandas and PyArrow optional dependencies need to be installed. If you want to use another dataframe library such as Polars instead, you don't need Pandas and can directly use <a href="https://docs.pola.rs/api/python/stable/reference/api/polars.from_arrow.html"><code>polars.from_arrow</code></a> to create a Polars DataFrame from the PyArrow table returned by the Elasticsearch client.</p><p>One limitation is that Elasticsearch does not currently handle multi-valued fields, which is why we had to drop the <code>is_rehired</code>, <code>job_positions</code> and <code>salary_change</code> columns. This limitation will be lifted in a future version of Elasticsearch.</p><p>Anyway, you now have a Pandas dataframe that you can use to analyze your data further. But you can also continue massaging the data using ES|QL, which is particularly useful when queries return more than 10,000 rows, the current maximum number of rows that ES|QL queries can return.</p><h3>More complex queries</h3><p>In the next example, we're counting how many employees are speaking a given language by using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-stats-by"><code>STATS ... BY</code></a> (not unlike <code>GROUP BY</code> in SQL). And then we sort the result with the <code>languages</code> column using <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-sort"><code>SORT</code></a>:</p>response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | STATS count = COUNT(emp_no) BY languages
    | SORT languages
    | LIMIT 500
    """,
    format="arrow",
)

df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>Unlike with CSV, we did not have to specify any types, as Arrow data already includes types. Here's the result:</p>   count  languages
0     15          1
1     19          2
2     17          3
3     18          4
4     21          5
5     10       &lt;NA&gt;
<p>21 employees speak 5 languages, wow! And 10 employees did not declare any spoken language. The missing value is denoted by <code>&lt;NA&gt;</code>, which is consistently used for missing data with the PyArrow backend. If we had used the NumPy backend instead, this column would have been converted to floats and the missing value would have been a confusing <code>NaN</code>, as <a href="https://pandas.pydata.org/docs/user_guide/missing_data.html">NumPy integers don't have any sentinel value for missing data</a>.</p><h3>Queries with parameters</h3><p>Finally, suppose that you want to expand the query from the previous section to only consider employees that speak N or more languages, with N being a variable parameter. For this we can use <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-rest.html#esql-rest-params">ES|QL's built-in support for parameters</a>, which eliminates the risk of an injection attack associated with manually assembling queries with variable parts:</p>response = client.esql.query(
    query="""
    FROM employees
    | DROP is_rehired,job_positions,salary_change*
    | STATS count = COUNT(emp_no) BY languages
    | WHERE languages &gt;= (?)
    | SORT languages
    | LIMIT 500
    """,
    format="arrow",
    params=[3],
)

df = response.to_pandas(types_mapper=pd.ArrowDtype)
print(df)
<p>which prints the following:</p>   count  languages
0     17          3
1     18          4
2     21          5
<h2>Conclusion</h2><p>As we saw, ES|QL's native Arrow support makes working with Pandas and other DataFrame libraries even nicer than using CSV and it will continue to improve over time, with the multi-value support coming in a future version of Elasticsearch.</p><h2>Additional resources</h2><p>If you want to learn more about ES|QL, the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">ES|QL documentation</a> is the best place to start. You can also check out <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/Boston-Celtics-Demo/celtics-esql-demo.ipynb">this other Python example using Boston Celtics data</a>. To know more about the Python Elasticsearch client itself, you can <a href="https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html">refer to the documentation</a>, ask a question <a href="https://discuss.elastic.co/tag/language-clients">on Discuss with the language-clients tag</a> or <a href="https://github.com/elastic/elasticsearch-py">open a new issue</a> if you found a bug or have a feature request. Thank you!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-pandas-native-dataframes-python</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-pandas-native-dataframes-python</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Quentin Pradet]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb1808f6b0c1b0ed3/6a17d7bcec0f89c6c35a644e/1b32822c3bf2ad216b21d819c5795f080b6e6cbf-500x500.png" length="0" type="image/png"/>
    <pubDate>Thu, 05 Sep 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[An Elasticsearch Query Language (ES|QL) analysis: Millionaire odds vs. hit by a bus]]></title>
    <description><![CDATA[Use Elasticsearch Query Language (ES|QL) to run statistical analysis on demographic data index in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch Query Language (ES|QL) is designed for fast, efficient querying of large datasets. It has a straightforward syntax which will allow you to write complex queries easily, with a pipe based language, reducing the learning curve. We're going to use ES|QL to run statistical analysis and compare different odds.</p><p>If you are reading this, you probably want to know how rich you can get before actually reaching the same odds of being hit by a bus. I can't blame you, I want to know too. Let's work out the odds so that we can make sure we win the lottery rather than get in an accident!</p><p>What we are going to see in this blog is figuring out the probability of being hit by a bus and the probability of achieving wealth. We'll then compare both and understand until what point your chances of getting rich are higher, and when you should consider getting life insurance.</p><p>So how are we going to do that? This is going to be a mix of magic numbers pulled from different articles online, some synthetics data and the power of ES|QL, the new Elasticsearch Query Language. Let's get started.</p><h2>Data for the ES|QL analysis</h2><h3>The magic number</h3><p>The challenge starts here as the dataset is going to be somewhat challenging to find. We are then going to assume for the sake of the example that ChatGPT is always right. Let’s see what we get for the following question:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7b0ac87235183705/6a17d75e3e03d768544f2ac1/e53cf27540f30f58af23fc26d3d1b93cc7fd5497-1440x229.png" alt="bus-odds" /><p>Cough Cough… That sounds about right, this is going to be our magic number.</p><h3>Generating the wealth data</h3><h4>Prerequisites</h4><p>Before running any of the scripts below, make sure to install the following packages:</p>
elasticsearch==8.14.0
matplotlib
numpy
panda
scipy

<p>Now, there is one more thing we need, a representative dataset with wealth distribution to compute wealth probability. There is definitely some portion of it here and there, but again, for the example we are going to generate a 500K line dataset with the below python script. I am using python 3.11.5 in this example:</p>
import pandas as pd
import numpy as np
import getpass
from elasticsearch import Elasticsearch, helpers

# Input the Elasticsearch host
hosts = input('Enter your Elasticsearch host address : ')

# Securely input the Elasticsearch API key
api_key = getpass.getpass(prompt='Enter your Elasticsearch API Key: ')

# Initialize Elasticsearch client
client = Elasticsearch(
    hosts=hosts,
    api_key=api_key,
)

# Generate synthetic data with a highly skewed distribution
num_records = 500000
np.random.seed(42)  # Ensure reproducibility

# Generate net worth using a highly skewed distribution
ages = np.random.randint(20, 80, num_records)  # Random ages between 20 and 80
incomes = np.random.exponential(scale=10000, size=num_records)  # Exponential distribution for income
# Use a more skewed distribution for net worth with a much larger range
net_worths = np.random.exponential(scale=100000000, size=num_records)  # Extremely skewed net worth

# Scale up the net worths to reach up to $100 billion
net_worths = np.clip(net_worths, 0, 100000000000)

# Create DataFrame
df = pd.DataFrame({
    'id': range(1, num_records + 1),
    'age': ages,
    'income': incomes,
    'net_worth': net_worths,
    'counter': range(1, num_records + 1)  # Add a counter field for pagination
})

# Index the data into Elasticsearch
index_name = 'raw_wealth_data_large'
try:
    if client.indices.exists(index=index_name):
        client.indices.delete(index=index_name)
except exceptions.NotFoundError:
    pass
client.indices.create(index=index_name)


def generator(df):
    for index, row in df.iterrows():
        yield {
            "_index": index_name,
            "_source": row.to_dict()
        }

helpers.bulk(client, generator(df))

print("Data indexed successfully.")
<p>It should take some time to run depending on your configuration since we are injecting 500K documents here!</p><p>FYI, after playing with a couple of versions of the script above and the ESQL query on the synthetic data, it was obvious that the net worth generated across the population was not really representative of the real world. So I decided to use a log-normal distribution (np.random.lognormal) for income to reflect a more realistic spread where most people have lower incomes, and fewer people have very high incomes.</p><p>Net Worth Calculation: Used a combination of random multipliers (np.random.uniform(0.5, 5)) and additional noise (np.random.normal(0, 10000)) to calculate net worth. Added a check to ensure no negative net worth values by using np.maximum(0, net_worths).</p><p>Not only have we generated 500K documents, but we also used the Elasticsearch python client to bulk ingest all these documents in our deployment. Please note that you will find the endpoint to pass in as hosts Cloud ID in the code above.</p><p>For the deployment API key, open Kibana, and generate the key in Stack Management / API Keys:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb38e3a3a44c66b9e/6a17d7606864a40557b685e2/544c32086c18ca3b5e65fa0e5bbf2d60490a7f66-1440x864.png" alt="api-key" /><p>The good news is that if you have a real data set, all you will need to do is to change the above code to read your dataset and write documents with the same data mapping.</p><p>Ok we're getting there! The next step is pouring our wealth distribution.</p><h2>ES|QL wealth analysis</h2><h3>Introducing ES|QL: A powerful tool for data analysis</h3><p>The arrival of Elasticsearch Query Language (ES|QL) is very exciting news for our users. It largely simplifies querying, analyzing, and visualizing data stored in Elasticsearch, making it a powerful tool for all data-driven use cases.</p><p>ES|QL comes with a variety of functions and operators, to perform aggregations, statistical analyses, and data transformations. We won’t address them all in this blog post, however <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql.html">our documentation</a> is very detailed and will help you familiarize with the language and the possibilities.</p><p>To get started with ES|QL today and run the blog post queries, simply <a href="https://www.elastic.co/getting-started?utm_source=github&amp;utm_content=elasticsearch-labs-notebook">start a trial on Elastic Cloud</a>, load the data and run your first ES|QL query.</p><h3>Understanding the wealth distribution with our first query</h3><p>To get familiar with the dataset, head to Discover in Kibana and switch to ES|QL in the dropdown on the left hand side:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5aba101d232dbb13/6a17d762e8fbce48b53a174c/357703b5c0b543daa61fe98354de29e57182469d-1440x585.png" alt="discover" /><p>Let’s fire our first request:</p>from raw_wealth_data_large | keep age, id, income, net_worth | limit 10
<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd9597115bb01b8c3/6a17d7643e03d7488f4f2ac5/898970fd63e9b2811bf24badd72ef57a45912564-1312x1930.png" alt="result set" /><p>As you could expect from our indexing script earlier, we are finding the documents we bulk ingested, notice the simplicity of pulling data from a given dataset with ES|QL where every query starts with the From clause, then your index.</p><p>In the query above given we have 500K lines, we limited the amount of returned documents to 10. To do this, we are passing the output of the first segment of the query via a pipe to the limit command to only get 10 results. Pretty intuitive, right?</p><p>Alright, what would be more interesting is to understand the wealth distribution in our dataset, for this we will leverage one of the 30 functions ES|QL provides, namely percentile.</p><p>This will allow us to understand the relative position of each data point within the distribution of net worth. By calculating the median percentile (50th percentile), we can gauge where an individual’s net worth stands compared to others.</p>
FROM raw_wealth_data_large
| stats p50 = percentile(net_worth, 50) 

<p>Like our first query, we are passing the output of our index to another function, Stats, which combined with the percentile function will output the median net worth:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt35023abe49d381f3/6a17d7654b055d09c5432048/57f65e58941783994d67dee7ee763a4934bd8ca9-1440x640.png" alt="result set" /><p>The median is about 54K, which unfortunately is probably optimistic compared to the real world, but we are not going to solve this here. If we go a little further, we can look at the distribution in more granularity by computing more percentiles:</p>
FROM raw_wealth_data_large
| STATS  p25 = percentile(net_worth, 25)
       , p50 = percentile(net_worth, 50)
       , p75 = percentile(net_worth, 75)
       , p90 = percentile(net_worth, 90)
       , p95 = percentile(net_worth, 95)
       , p96 = percentile(net_worth, 96)
       , p98 = percentile(net_worth, 98)
       , p97 = percentile(net_worth, 97)
       , p99 = percentile(net_worth, 99)
| keep p25, p25, p50, p75, p90, p95, p96, p97, p98, p99

<p>With the below output:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt48a66c799c539a68/6a17d767414c644eaa944fd6/59f1c99d52887e275422aea7d4e0547c8f3ca886-1440x458.png" alt="Percentile result" /><p>The data reveals a significant disparity in wealth distribution, with the majority of wealth being concentrated among the richest individuals. Specifically, the top 5% (95th percentile) possess a disproportionately large portion of the total wealth, with a net worth starting at $852,988.26 and increasing dramatically in the higher percentiles.</p><p>The 99th percentile individuals hold a net worth exceeding $2 million, highlighting the skewed nature of wealth distribution. This indicates that a substantial portion of the population has modest net worth, which is probably what we want for this example.</p><p>Another way to look at this is to augment the previous query and grouping by age to see if there is, (in our synthetic dataset), a relation between wealth and age:</p>
FROM raw_wealth_data_large
| STATS  p25 = percentile(net_worth, 25)
      , p50 = percentile(net_worth, 50)
      , p75 = percentile(net_worth, 75)
      , p90 = percentile(net_worth, 90)
      , p95 = percentile(net_worth, 95)
      , p96 = percentile(net_worth, 96)
      , p98 = percentile(net_worth, 98)
      , p97 = percentile(net_worth, 97)
      , p99 = percentile(net_worth, 99) by age
| keep p25, p25, p50, p75, p90, p95, p96, p97, p98, p99, age
<p>This could be visualized in a Kibana dashboard. Simply:</p><ul><li><p>Navigate to Dashboard</p></li><li><p>Add a new ES|QL visualization</p></li><li><p>Copy and paste our query</p></li><li><p>Move the age field to the horizontal axis in the visualization configuration</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5e72df525e4fe575/6a17d769b1e113339979f0d3/f47a62215862c5981d2642128c2e41ac707a7476-1066x1864.png" alt="Create ESQL visualization" /><p>Which will output:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2de89c1c00eb23be/6a17d76afbc5f807ff491908/59e17103d0233fa9d79dd65e510b776b96041489-1440x854.png" alt="Visualization output" /><p>The above suggests that the data generator randomized wealth uniformly across the population age, there is no specific trend pattern we can really see.</p><h4>Median Absolute Deviation (MAD)</h4><p>We calculate the median absolute deviation (MAD) to measure the variability of net worth in a robust manner, less influenced by outliers.</p>
FROM raw_wealth_data_large
| stats median_net_worth = MEDIAN(net_worth), mad_net_worth = MEDIAN_ABSOLUTE_DEVIATION(net_worth)
| keep median_net_worth, mad_net_worth

<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc1488254b38e203e/6a17d76c2f4a5c2a81fa8766/535103bff9200ff4c2566418fedf8654de30fc13-1440x335.png" alt="Visualization output" /><p>With a median net worth of 44,205.44, we can infer the typical range of Net Worth: Most individuals’ net worth falls within a range of 9,581.78 to $97,992.66.</p><h3>The statistical showdown between Net Worth and Bus Collision</h3><p>Alright, this is the moment to understand how rich we can get, based on our dataset, before getting hit by a bus. To do that, we are going to leverage ES|QL to pull our entire dataset in chunks and load it into a pandas dataframe to build a net worth probability distribution. Finally, we will determine where the ends meet between the net worth and bus collision probabilities.</p><p>The entire Python <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/esql-millionaire/millionaire.ipynb">notebook is available here</a>. I also recommend you read <a href="https://www.elastic.co/search-labs/blog/esql-pandas-dataframes-python">this blog post</a> which walks you through using ES|QL with pandas dataframes.</p><h4>Helper functions</h4><p>As you can see in the previously referred blog post, we introduced support for ES|QL since version 8.12 of the Elasticsearch python client. Thus our notebook first defines the below functions:</p>
from io import StringIO

# Function to execute ESQL query and fetch data in chunks
def execute_esql_query(query):
    response = client.esql.query(query=query, format="csv")
    return pd.read_csv(StringIO(response.body))

# Function to fetch paginated data using the counter field
def fetch_paginated_data(index, num_records, size=10000):
    all_data = pd.DataFrame()
    for start in range(1, num_records + 1, size):
        end = start + size - 1
        query = f"""
        FROM {index}
        | WHERE counter &gt;= {start} AND counter &lt;= {end}
        | limit {size}
        """
        data_chunk = execute_esql_query(query)
        all_data = pd.concat([all_data, data_chunk], ignore_index=True)
    return all_data

<p>The first function is straightforward and executes an ES|QL query, the second is fetching the entire dataset from our index. Notice the trick in there that I am using a counter built-in to a field in my index to paginate through the data. This is workaround I am using while our engineering team is working on <a href="https://github.com/elastic/elasticsearch/issues/100000">the support for pagination in ES|QL</a>.</p><p>Next, knowing that we have 500K documents in our index, we simply call these function to load the data in a data frame:</p>
# Fetch all data using pagination and ES|QL
num_records = 500000
all_data_df = fetch_paginated_data(index_name, num_records)
print(f"Total Data Retrieved: {len(all_data_df)} records")

<h4>Fit Pareto distribution</h4><p>Next, we fit our data to a Pareto distribution, which is often used to model wealth distribution because it reflects the reality that a small percentage of the population controls most of the wealth. By fitting our data to this distribution, we can more accurately represent the probabilities of different net worth levels.</p>from scipy.stats import pareto



# Fit a Pareto distribution to the data
shape, loc, scale = pareto.fit(all_data_df['net_worth'], floc=0)

# Calculate the probability density for each net worth
all_data_df['net_worth_probability'] = pareto.pdf(all_data_df['net_worth'], shape, loc=loc, scale=scale)

# Normalize the probabilities to sum to 1
all_data_df['net_worth_probability'] /= all_data_df['net_worth_probability'].sum()

print("Data with Net Worth Probability:")
print(all_data_df.head())

<p>We can visualize the pareto distribution with the code below: ``</p>
import matplotlib.pyplot as plt
from scipy.stats import pareto

# Assuming all_data_df contains the fetched net worth data from Elasticsearch
# Fit a Pareto distribution to the data
shape, loc, scale = pareto.fit(all_data_df['net_worth'], floc=0)

# Plot the Net Worth Probability Distribution
plt.figure(figsize=(10, 6))

# Plot histogram of empirical net worth data
plt.hist(all_data_df['net_worth'], bins=100, density=True, alpha=0.6, color='g', label='Empirical Data')

# Plot fitted Pareto distribution
xmin, xmax = plt.xlim()
x = np.linspace(xmin, xmax, 100)
p = pareto.pdf(x, shape, loc=loc, scale=scale)
plt.plot(x, p, 'k', linewidth=2, label='Fitted Pareto Distribution')

# Show the plot
plt.xlabel('Net Worth')
plt.y bnblabel('Probability')
plt.title('Net Worth Probability Distribution')
plt.legend()
plt.grid(True)
plt.show()

<img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6c446c9de49ec808/6a17d76d2f4a5c686bfa876a/31a715f9d4881c6662c67fe72178635b5836a033-1440x942.png" alt="Pareto" /><h4>Breaking point</h4><p>Finally, with the calculated probability, we determine the target net worth corresponding to the bus hit probability and visualize it. Remember, we use the magic number ChatGPT gave us for the probability of getting hit by a bus:</p>
# Find the Net Worth Corresponding to the Bus Hit Probability
target_probability = 0.0000181
cumulative_probability = all_data_df['net_worth_probability'].cumsum()
target_net_worth_df = all_data_df[cumulative_probability &gt;= target_probability].head(1)
target_net_worth = target_net_worth_df['net_worth'].iloc[0]
print(f"Net Worth with Probability &gt;= {target_probability}: {target_net_worth}")

# Plot the Net Worth Probability Distribution
plt.figure(figsize=(10, 6))
plt.hist(all_data_df['net_worth'], bins=100, density=True, alpha=0.6, color='g', label='Empirical Data')
xmin, xmax = plt.xlim()
x = np.linspace(xmin, xmax, 100)
p = pareto.pdf(x, shape, loc=loc, scale=scale)
plt.plot(x, p, 'k', linewidth=2, label='Fitted Pareto Distribution')
plt.axhline(y=target_probability, color='r', linestyle='--', label='Bus Hit Probability')
plt.axvline(x=target_net_worth, color='g', linestyle='--', label=f'Net Worth = {target_net_worth:.2f}')
plt.xlabel('Net Worth')
plt.ylabel('Probability')
plt.title('Net Worth Probability Distribution')
plt.legend()
plt.grid(True)
plt.show()

<h2>Conclusion</h2><p>Based on our synthetic dataset, this chart vividly illustrates that the probability of amassing a net worth of approximately $12.5 million is as rare as the chance of being hit by a bus. For the fun of it, let’s ask ChatGPT what the probability is:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt224d185dc880bf21/6a17d76f7f6f1581edc09989/077e13f5ebce5019d00b7374ad6fd22dbcf7fe0b-1440x925.png" alt="Probaility Distribution" /><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7d0ab89f76b8b2e2/6a17d77063baff5bf1741ac3/4aaa32cd60d59770137ae5a3fb582675e606bbd8-1440x210.png" alt="Net worth" /><p>Okay… $439 million? I think ChatGPT might be hallucinating again.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-query-language-esql-statistical-analysis</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-query-language-esql-statistical-analysis</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Baha Azarmi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1acb1d84387a6310/6a17d7726df7314a250a0d48/274867ef7971390c5d1d4f535c76e50a9f4a8224-1206x1522.png" length="0" type="image/png"/>
    <pubDate>Tue, 20 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch revisited: Building a chatbot using RAG]]></title>
    <description><![CDATA[Learn how to create a chatbot using ChatGPT and Elasticsearch, utilizing all of the newest RAG features.]]></description>
    <content:encoded><![CDATA[<p>Follow up to the blog <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a>.</p><p>In this blog, you will learn how to:</p><ul><li><p>Create an Elasticsearch Serverless project</p></li><li><p>Create an Inference Endpoint to generate embeddings with ELSER</p></li><li><p>Use a Semantic Text field for auto-chunking and calling the Inference Endpoint</p></li><li><p>Use the Open Crawler to crawl blogs</p></li><li><p>Connect to an LLM using Elastic’s Playground to test prompts and context settings for a RAG chat application.</p></li></ul><p>If you want to jump right into the code, you can view the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb">accompanying Jupyter Notebook here</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2779150d12271ef1/6a1711e460084bdf023c4688/e0e3d205d5b4cb58c4b4b5f22aab57c8ef659ed6-1440x807.png" alt="The Dude Abides" /><h2>ChatGPT and Elasticsearch (April 2023)</h2><p>A lot has changed since I wrote the initial <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a>. Most people were just playing around with ChatGPT, if they had tried it at all. And every booth at every tech conference didn’t feature the letters “AI” (whether it is a useful fit or not).</p><h2>Updates in Elasticsearch (August 2024)</h2><p>Since then, Elastic has embraced being a full featured vector database and is putting a lot of engineering effort into making it the best vector database option for anyone building a search application. So as not to spend several pages talking about all the enhancements to Elasticsearch, here is a non-exhaustive list in no particular order:</p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/introducing-elser-v2-part-1">ELSER - The Elastic Learned Sparse Encoder</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">Elastic Serverless Service</a> was built and is in public beta</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-azure-openai-completion-support">Elasticsearch open Inference API</a> </p><ul><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-amazon-bedrock-support">Embeddings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-openai-completion-support">Chat completion</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-cohere-rerank">Semantic rerankers</a></p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">Semantic_text type</a> - Simplify semantic search</p><ul><li><p>Automatic chunking</p></li></ul></li><li><p><a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground</a> - Visually experiment with RAG application building in Elasticsearch</p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elasticsearch-retrievers">Retrievers</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/elastic-open-crawler-release">Open web crawler</a></p></li></ul><p>With all that change and more, the original blog needs a rewrite. So let’s get started.</p><h2>Updated flow: ChatGPT, Elasticsearch &amp; RAG</h2><p>The plan for this updated flow will be:</p><ol><li><p>Setup  </p><ol><li><p>Create a new Elasticsearch serverless search project</p></li><li><p>Create an embedding inference API using ELSER</p></li><li><p>Configure an index template with a <code>semantic_text</code> field</p></li><li><p>Create a new LLM connector</p></li><li><p>Configure a chat completion inference service using our LLM connector</p></li></ol></li><li><p>Ingest and Test</p><ol><li><p>Crawl the Elastic Labs sites (Search, Observability, Security) with the Elastic Open Web Crawler.</p></li><li><p>Use Playground to test prompts using our indexed Labs content</p></li></ol></li><li><p>Configure and deploy our App </p><ol><li><p>Export the generated code from Playground to an application using FastAPI as the backend and React as the front end.</p></li><li><p>Run it locally</p></li><li><p>Optionally deploy our chatbot to Google Cloud Run</p></li></ol></li></ol><h2>Setup</h2><h3>Elasticsearch Serverless Project</h3><p>We will be using an Elastic serverless project for our chatbot. Serverless removes much of the complexity of running an Elasticsearch cluster and lets you focus on actually using and gaining value from your data. Read more about the <a href="https://www.elastic.co/search-labs/blog/building-elastic-cloud-serverless">architecture of Serverless here</a>.</p><p>If you don’t have an Elastic Cloud account, you can create a free two-week trial at <a href="https://cloud.elastic.co/registration">elastic.co</a> (Serverless pricing <a href="https://www.elastic.co/pricing/serverless-search">available here</a>). If you already have one, you can simply log in.</p><p>Once logged in, you will need to <a href="https://cloud.elastic.co/account/keys">create a cloud API key</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt03e19072d48a28bb/6a1711e5dc55def695e00f03/d8121ed3d0fb4bbd5927a78aee20619589106df8-1300x1920.png" alt="alt_text" /><p><strong>NOTE: In the steps below, I will show the relevant parts of Python code. For the sake of brevity, I’m not going to show complete code that will import required libraries, wait for steps to complete, catch errors, etc.</strong></p><p><strong>For more robust code you can run, please see the </strong><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb"><strong>accompanying Jypyter notebook</strong></a><strong>!</strong></p><h3>Create Serverless Project</h3><p>We will use our newly created API key to perform the next setup steps.</p><p>First off, create a new Elasticsearch project.</p>url = "https://api.elastic-cloud.com/api/v1/serverless/projects/elasticsearch" 

project_data = {
    "name": "The RAG Really Tied the App Together",
    "region_id": "aws-us-east-1",
    "optimized_for": "vector"
}

auth_header = f"ApiKey {api_key}"  # seeing what a comment lokos like with pound
headers = {
    "Content-Type": "application/json",
    "Authorization": auth_header
}

es_project = requests.post(url, json=project_data, headers=headers)  :four:
<ul><li><p><code>url</code> - This is the standard Serverless endpoint for Elastic Cloud</p></li><li><p><code>project_data</code> - Your Elasticsearch Serverless project settings </p><ul><li><p><code>name</code> - Name we want for the project</p></li><li><p><code>region_id</code> - Region to deploy</p></li><li><p><code>optimized_for</code> - Configuration type - We are using <code>vector</code> which isn’t strictly required for the ELSER model but can be suitable if you select a dense vector model such as e5.</p></li></ul></li></ul><h3>Create Elasticsearch Python client</h3><p>One nice thing about creating a programmatic project is that you will get back the connection information and credentials you need to interact with it!</p>es = Elasticsearch(es_project_keys['endpoints']['elasticsearch'],
                   basic_auth=(es_project_keys['credentials']['username'],
                              es_project_keys['credentials']['password']
                              )
                   )
<h3>ELSER Embedding API</h3><p>Once the project is created, which usually takes less than a few minutes, we can prepare it to handle our labs’ data.</p><p>The first step is to configure the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/put-inference-api.html#inference-example-elser">inference API for embedding</a>. We will be using the <a href="https://www.elastic.co/search-labs/blog/introducing-elser-v2-part-2">Elastic Learned Sparse Encoder</a> (ELSER).</p><ul><li><p>Command to create the inference endpoint</p></li><li><p>Specify this endpoint will be for generating sparse embeddings</p></li></ul>model_config = {
    "service": "elser",
    "service_settings": {
        "num_allocations": 8,
        "num_threads": 1
    }
}

inference_id = "my-elser-model"

create_endpoint = es.inference.put_model(
    inference_id=inference_id,
    task_type="sparse_embedding",
    body=model_config
)
<ul><li><p><code>model_config</code> - Settings we want to use for deploying our semantic reranking model </p><ul><li><p><code>service</code> - Use the pre-defined <code>elser</code> inference service</p></li><li><p><code>service_settings.num_allocations</code> - <a href="https://www.elastic.co/guide/en/machine-learning/current/ml-nlp-deploy-model.html">Deploy the model</a> with 8 allocations</p></li><li><p><code>service_settings.num_threads</code> - Deploy with one thread per allocation</p></li></ul></li><li><p><code>inference_id</code> - The name you want to give to you inference endpoint</p></li><li><p><code>task_type</code>- Specifies this endpoint will be for generating sparse embeddings</p></li></ul><p>This single command will trigger Elasticsearch to perform a couple of tasks:</p><ol><li><p>It will download the ELSER model.</p></li><li><p>It will deploy (start) the ELSER model with eight allocations and one thread per allocation.</p></li><li><p>It will create an inference API we use in our field mapping in the next step.</p></li></ol><h3>Index Mapping</h3><p>With our ELSER API created, we will create our index template.</p>template_body = {
    "index_patterns": ["elastic-labs*"],
    "template": {
        "mappings": {
            "properties": {
                "body": {
                    "type": "text",
                    "copy_to": "semantic_body"
                },
                "semantic_body": {
                    "type": "semantic_text",
                    "inference_id": "my-elser-model"
                },
                "headings": {
                    "type": "text"
                },
                "id": {
                    "type": "keyword"
                },
                "meta_description": {
                    "type": "text"
                },
                "title": {
                    "type": "text"
                }
            }
        }
    }
}

template_resp = es.indices.put_index_template(  :eight:
    name="labs_template",
    body=template_body
)
<ul><li><p><code>index_patterns</code> - The pattern of indices we want this template to apply to.</p></li><li><p><code>body</code> - The main content of a web page the crawler collects will be written to</p><ul><li><p><code>type</code> - It is a text field</p></li><li><p><code>copy_to</code> - We need to copy that text to our semantic text field for semantic processing</p></li></ul></li><li><p><code>semantic_body</code> is our <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">semantic text field</a> </p><ul><li><p>This field will automatically handle chunking of long text and generating embeddings which we will later use for semantic search</p></li><li><p><code>inference_id</code> specifies the name of the inference endpoint we created above, allowing us to generate embeddings from our ELSER model</p></li></ul></li><li><p><code>headings</code> - Heading tags from the html</p></li><li><p><code>id</code> - crawl id for this document</p></li><li><p><code>meta_description</code> - value of the description meta tag from the html</p></li><li><p><code>title</code> is the title of the web page the content is from</p></li></ul><p>Other fields will be indexed but auto-mapped. The ones we are focused on pre-defining in the template will not need to be both keyword and text type, which is defined automatically otherwise.</p><p>Most importantly, for this guide, we must define our <code>semantic_text</code> field and set a source field to copy from with <code>copy_to</code>. In this case, we are interested in performing semantic search on the body of the text, which the crawler indexes into the <code>body</code>.</p><h2>Crawl All the Labs!</h2><p>We can now install and configure the crawler to crawl the Elastic * Labs. We will loosely follow the excellent guide from the <a href="https://www.elastic.co/search-labs/blog/elastic-open-crawler-release#how-do-i-use-it">Open Crawler released for tech-preview</a> Search Labs blog.</p><p>The steps below will use docker and run on a MacBook Pro. To run this with a different setup, consult the <a href="https://github.com/elastic/crawler?tab=readme-ov-file#elastic-open-web-crawler">Open Crawler Github readme</a>.</p><h3>Clone the repo</h3><p>
Open the command line tool of your choice. I’ll be using Iterm2. Clone the <a href="https://github.com/elastic/crawler">crawler repo</a> to your machine.</p>~/repos
❯ git clone git@github.com:elastic/crawler.git
Cloning into 'crawler'...
remote: Enumerating objects: 1944, done.
remote: Counting objects: 100% (418/418), done.
remote: Compressing objects: 100% (243/243), done.
remote: Total 1944 (delta 237), reused 238 (delta 170), pack-reused 1526
Receiving objects: 100% (1944/1944), 84.85 MiB | 31.32 MiB/s, done.
Resolving deltas: 100% (727/727), done.
<h3>Build the crawler container</h3><p>Run the following command to build and run the crawler.</p>docker build -t crawler-image . &amp;&amp; docker run -i -d --name crawler crawler-image
~/repos
 ❯ cd crawler
~/repos/crawler main
 ❯ docker build -t crawler-image . &amp;&amp; docker run -i -d --name crawler crawler-image

[+] Building 66.9s (6/10)                                                                                                                                                                docker:desktop-linux
 =&gt; [internal] load build definition from Dockerfile					0.0s
 =&gt; =&gt; transferring dockerfile: 333B							0.0s
 =&gt; [internal] load .dockerignore							0.0s
 =&gt; =&gt; transferring context: 2B								0.0s
 =&gt; [internal] load metadata for docker.io/library/jruby:9.4.7.0-jdk21		1.7s
 =&gt; [auth] library/jruby:pull token for registry-1.docker.io			0.0s
...
...
 =&gt; [5/5] RUN make clean install								50.7s
 =&gt; exporting to image									0.9s
 =&gt; =&gt; exporting layers									0.9s
 =&gt; =&gt; writing image sha256:6b3f4000a121e76aba76fdbbf11b53f53a3fabba61c0b7cf3fdcdb21e244f1d8	0.0s
 =&gt; =&gt; naming to docker.io/library/crawler-image					0.0s
cc6c16941de04355c050ef5f5fd0041ee7f3505b8cf8448c7223f0d2e80b5498
<h3>Configure the crawler</h3><p>Create a new YAML in your favorite editor (vim):</p>~/repos/crawler main
 ❯ vim config/elastic-labs.yml
<p>We want to crawl all the documents on the three labs’ sites, but since blogs and tutorials on those sites tend to link out to other parts of elastic.co, we need to set a couple of runs to restrict the scope. We will allow crawling the three paths for our site and then deny anything else.</p><p>Paste the following in the file and save</p>domains:
  - url: https://www.elastic.co
    seed_urls:
      - https://www.elastic.co/search-labs
      - https://www.elastic.co/observability-labs
      - https://www.elastic.co/security-labs
    crawl_rules:
      - policy: allow
        type: begins
        pattern: /search-labs
      - policy: allow
        type: begins
        pattern: /observability-labs
      - policy: allow
        type: begins
        pattern: /security-labs
      - policy:deny
        type: regex
        pattern: .*/author/.*
      - policy: deny
        type: regex
        pattern: .*

output_sink: elasticsearch
output_index: elastic-labs
max_crawl_depth: 2

elasticsearch:
  host: "https://&lt;your_serverless_project&gt;.es.&lt;region&gt;.aws.elastic.cloud"
  port: "443"
  api_key: "&lt;API Key generated above&gt;"
<p>Copy the configuration into the Docker container:</p>~/repos/crawler main ⇣
 ❯ docker cp config/elastic-labs.yml crawler:/app/config/elastic-labs.yml

Successfully copied 2.05kB to crawler:/app/config/elastic-labs.yml
<h3>Validate the domain</h3><p>Ensure the config file has no issues by running:</p> ❯ docker exec -it crawler bin/crawler validate config/elastic-labs.yml
Domain https://www.elastic.co is valid
<h3>Start the crawler</h3><p>When you first run the crawler, processing all the articles on the three lab sites may take several minutes.</p>docker exec -it crawler bin/crawler crawl config/elastic-labs.yml
~/repos/crawler/config main ⇣
 ❯ docker exec -it crawler bin/crawler crawl config/elastic-labs.yml
[crawl:6692c3b584f98612e3a465ce] [primary] Initialized an in-memory URL queue for up to 10000 URLs
[crawl:6692c3b584f98612e3a465ce] [primary] ES connections will be authorized with configured API key
[crawl:6692c3b584f98612e3a465ce] [primary] ES connections will use SSL without ca_fingerprint
[crawl:6692c3b584f98612e3a465ce] [primary] Elasticsearch sink initialized for index [elastic-labs] with pipeline [ent-search-generic-ingestion]
[crawl:6692c3b584f98612e3a465ce] [primary] Starting the crawl with up to 10 parallel thread(s)...
[crawl:6692c3b584f98612e3a465ce] [primary] Crawl status: queue_size=11, pages_visited=1, urls_allowed=12, urls_denied={}, crawl_duration_msec=847, crawling_time_msec=635.0, avg_response_time_msec=635.0, active_threads=1, http_client={:max_connections=&gt;100, :used_connections=&gt;1}, status_codes={"200"=&gt;1}
<h3>Confirm articles have been indexed</h3><p>We will confirm two ways.</p><p>First, we will look at a sample document to ensure that ELSER embeddings have been generated. We just want to look at any doc so we can search without any arguments:</p>GET elastic-labs/_search
<p>Ensure you get results and then check that the field <code>body</code> contains text and <code>semantic_body.inference.chunks.0.embeddings</code> contains tokens.</p>    "hits": [
      {
        "_index": "elastic-labs",
...
        "_source": {
          "body": "Tutorials Integrations Blog Start Free Trial Contact Sales Open navigation menu Overview ...
          "semantic_body": {
            "inference": {
              "inference_id": "my-elser-model",
              "model_settings": {
                "task_type": "sparse_embedding"
              },
              "chunks": [
                {
                  "text": "Tutorials Integrations Blog Start Free Trial Contact Sales Open navigation menu Overview ...
                  "embeddings": {
                    "##her": 2.1016746,
                    "elastic": 2.084594,
                    "##ai": 1.6336359,
                    "dock": 1.5765089,
                    ...
<p>We can check we are gathering data from each of the three sites with a <code>terms</code> aggregation:</p>GET elastic-labs/_search
{
  "size": 0,
  "aggs": {
    "url_path_dir1": {
      "terms": {
        "field": "url_path_dir1.keyword"
      }
    }
  }
}
<p>You should see results that start with one of our three site paths.</p>      "buckets": [
        {
          "key": "security-labs",
          "doc_count": 37
        },
        {
          "key": "observability-labs",
          "doc_count": 30
        },
        {
          "key": "search-labs",
          "doc_count": 6
        }
      ]
<h2>To the Playground!</h2><p>With our data ingested, chunked, and inference, we can start working on the backend application code that will interact with the LLM for our RAG app.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd1ceed13b358b1bf/6a1711e767045b096445c2fd/abd9cb1460436f0e658f654f76ab90828892a671-494x144.png" alt="alt_text" /><h3>LLM Connection</h3><p>We need to configure a connection for Playground to make API calls to an LLM. As of this writing, Playground supports chat completion connections to OpenAI, AWS Bedrock, and Google Gemini. More connections are planned, so check the docs for the latest list.</p><p>When you first enter the Playground UI, click on “Connect to an LLM”</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8338faedbc15c2a5/6a1711e9961e69ce8ac4d021/ff5dbf52272a53ffe1c97136cf6bc02e0b05ff45-1146x872.png" alt="alt_text" /><p>Since I used OpenAI for the original blog, we’ll stick with that. The great thing about the Playground is that you can switch connections to a different service, and the Playground code will generate code specifically to that service’s API specification. You only need to select which one you want to use today.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt472f08d397464176/6a1711eb4a531b73db36aa9b/cf27f69cd578b936d78476bdf8ee5c387e725061-1440x480.png" alt="alt_text" /><p>In this step, you must fill out the fields depending on which LLM you wish to use. As mentioned above, since Playground will abstract away the API differences, you can use whichever supported LLM service works for you, and the rest of the steps in this guide will work the same.</p><p>If you don’t have an Azure OpenAI account or OpenAI API account, you can get one <a href="https://platform.openai.com/signup/">here</a> (OpenAI now requires a $5 minimum to fund the API account).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdd63e7d871549ab9/6a1711ed1949f72e3fe7ab52/1ac508303f4cc9d9427ae039f354b8ae0ac4473d-1370x1642.png" alt="alt_text" /><p>Once you have completed that, hit “Save,” and you will get confirmation that the connector has been added. After that, you just need to select the indices we will use in our app. You can select multiple, but since all our crawler data is going into <code>elastic-labs,</code> you can choose that one.</p><p>Click “Add data sources” and you can start using Playground!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9a1b97d9677c2dd4/6a1711ee0e2e496c2a41a266/365855fbca95613171777e9171d2c3dd65b11694-1128x840.png" alt="alt_text" /><p>Select the “restaurant_reviews” index created earlier.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4cdedc79bcf7e160/6a1711f01949f787f3e7ab56/4355652e648e3e69915fad0afcade2a1a55ab1f7-740x524.png" alt="alt_text" /><h2>Playing in the Playground</h2><p>After adding your data source you will be in the Playground UI.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb8d1b52a4bffced1/6a1711f12b835f39adf4b329/6ad316fb6dfcd815d1f66844a2b23e02f8cf0826-1440x874.png" alt="alt_text" /><p>To keep getting started as simple as possible, we will stick with all the default settings other than the prompt. However, for more details on Playground components and how to use them, check out the <a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground: Experiment with RAG applications with Elasticsearch in minutes</a> blog and the <a href="https://www.elastic.co/guide/en/kibana/current/playground.html">Playground documentation</a>.</p><p>Experimenting with different settings to fit your particular data and application needs is an important part of setting up a RAG-backed application.</p><p>The defaults we will be using are:</p><ul><li><p>Querying the <code>semantic_body</code> chunks</p></li><li><p>Using the three nearest semantic chunks as context to pass to the LLM</p></li></ul><h3>Creating a more detailed prompt</h3><p>The default prompt in Playground is simply a placeholder. Prompt engineering continues to develop as LLMs become more capable. Exploring the ever-changing world of prompt engineering is a blog, but there are a few basic concepts to remember when creating a system prompt:</p><ul><li><p>Be detailed when describing the app or service the LLM response is part of. This includes what data will be provided and who will consume the responses.</p></li><li><p>Provide example questions and responses. This technique, called <em>few-shot-prompting</em>, helps the LLM structure its responses.</p></li><li><p>Clearly state how the LLM should behave.</p></li><li><p>Specify the Desired Output Format.</p></li><li><p>Test and Iterate on Prompts.</p></li></ul><p>With this in mind, we can create a more detailed system prompt:</p>You are a helpful and knowledgeable assistant designed to assist users in querying information related to Search, Observability, and Security. Your primary goal is to provide clear, concise, and accurate responses based on semantically relevant documents retrieved using Elasticsearch.

Guidelines:

Audience:
Assume the user could be of any experience level but lean towards a technical slant in your explanations.
Avoid overly complex jargon unless it is common in the context of Elasticsearch, Search, Observability, or Security.

Response Structure:
Clarity: Responses should be clear and concise, avoiding unnecessary verbosity.
Conciseness: Provide information in the most direct way possible, using bullet points when appropriate.

Formatting: Use Markdown formatting for:
Bullet points to organize information
Code blocks for any code snippets, configurations, or commands
Relevance: Ensure the information provided is directly relevant to the user's query, prioritizing accuracy.

Content:
Technical Depth: Offer sufficient technical depth while remaining accessible. Tailor the complexity based on the user's apparent knowledge level inferred from their query.

Examples: Where appropriate, provide examples or scenarios to clarify concepts or illustrate use cases.
Documentation Links: When applicable, suggest additional resources or documentation from Elastic.co that can further assist the user.

Tone and Style:
Maintain a professional yet approachable tone.
Encourage curiosity by being supportive and patient with all user queries, regardless of complexity.

Example Queries:
"How can I optimize my Elasticsearch cluster for large-scale data?"
"What are the best practices for implementing observability in a microservices architecture?"
"How can I secure sensitive data in Elasticsearch?"
<p>Feel free to to test out different prompts and context settings to see what results you feel are best for your particular data. For more examples on advanced techiques, check out the <a href="https://www.elastic.co/search-labs/blog/advanced-rag-techniques-part-2#prompts">Prompt section on the two part blog Advanced RAG Techniques</a>. Again, see the <a href="https://www.elastic.co/search-labs/blog/rag-playground-introduction">Playground blog post</a> for more details on the various settings you can tweak.</p><h2>Export the Code</h2><p>Behind the scenes, Playground generates all the backend chat code we need to perform semantic search, parse the relevant contextual fields, and make a chat completion call to the LLM. No coding work from us required!</p><p>In the upper right corner click on the “View Code” button to expand the code flyout</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf523ed7f26673f46/6a1711f35091687af1e1bbee/aca43cd5554f5a35cc4a336557b0497675c044a2-962x406.png" alt="alt_text" /><p>You will see the generated python code with all the settings your configured as well as the the functions to make a semantic call to Elasticsearch, parse the results, built the complete prompt, make the call to the LLM, and parse those results.</p><p>Click the copy icon to copy the code.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt841e18f13ddd4145/6a1711f514b270564ce3c6f9/ff6a3a012c9a2f5f760efe78f7b663ae6261ec52-1440x1449.png" alt="alt_text" /><p>You can now incorporate the code into your own chat application!</p><h2>Wrapup</h2><p>A lot has changed since the first iteration of this blog over a year ago, and we covered a lot in this blog. You started from a cloud API key, created an Elasticsearch Serverless project, generated a cloud API key, configured the Open Web Crawler, crawled three Elastic Lab sites, chunked the long text, generated embeddings, tested out the optimal chat settings for a RAG application, and exported the code!</p><p><em>Where’s the UI, Vestal?</em></p><p>Be on the lookout for part two where we will integrate the playground code into a python backend with a React frontend. We will also look at deploying the full chat application.</p><p>For a complete set of code for everything above, see the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/rag-ties-the-app-together/ChatGPT_and_Elasticsearch__The_RAG_Really_Tied_the_App_Together.ipynb">accompanying Jypyter notebook</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2779150d12271ef1/6a1711e460084bdf023c4688/e0e3d205d5b4cb58c4b4b5f22aab57c8ef659ed6-1440x807.png" length="0" type="image/png"/>
    <pubDate>Mon, 19 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Vector embeddings made simple with the Elasticsearch-DSL client for Python]]></title>
    <description><![CDATA[Learn how to ingest and search dense vectors in Python using the Elasticsearch-DSL client.]]></description>
    <content:encoded><![CDATA[<p>In this article we'll take a look at the <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> client for Python, with a focus on how it simplifies the task of building a vector search solution.</p><p>The <a href="https://github.com/miguelgrinberg/quotes">code</a> that accompanies this article implements a database of famous quotes. It includes a back end written in Python with the <a href="https://fastapi.tiangolo.com/">FastAPI</a> web framework, and a front end written in <a href="https://www.typescriptlang.org/">TypeScript</a> and <a href="https://react.dev/">React</a>. Regarding vector search, this application demonstrates how to:</p><ul><li><p>run a local Elasticsearch service using Docker,</p></li><li><p>bulk-ingest a large number of documents efficiently,</p></li><li><p>generate vector embeddings for documents as they are ingested,</p></li><li><p>leverage the power of a GPU to accelerate the generation of vector embeddings through parallelization,</p></li><li><p>run vector search queries using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#approximate-knn">approximate kNN algorithm</a>,</p></li><li><p>aggregate results from vector search,</p></li><li><p>compare vector search results against those resulting from a standard <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match</a> (BM25) query.</p></li></ul><p>Below you can see a screenshot of the application. In this article you will find a detailed explanation of how the ingest and search features work. You then have the option to install and run the code on your own computer to experiment and learn!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" alt="Application screenshot" /><h2>What is the Elasticsearch-DSL client for Python?</h2><p>Sometimes called the "high-level" Python client, <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> offers idiomatic (or "Pythonic") access to your Elasticsearch database, in contrast with the official (or "low-level") Python client, which provides direct access to the complete range of Elasticsearch features and endpoints.</p><p>When using Elasticsearch-DSL, the structure (or "mappings") of Elasticsearch indices are defined as classes, with a syntax that is similar to that of Python <a href="https://docs.python.org/3/library/dataclasses.html">dataclasses</a>. The documents stored in these indices are represented by instances of these classes. All the transformations that are necessary to map between Python objects and Elasticsearch documents are automatically and transparently carried out, resulting in application code that is simple and idiomatic.</p><p>To add Elasticsearch-DSL to your Python project, you can install it with <code>pip</code>:</p>pip install elasticsearch-dsl
<p>If your project is asynchronous, then there are additional dependencies that need to be installed, so in that case use the following command instead:</p>pip install "elasticsearch-dsl[async]"
<h2>Index definition</h2><p>As stated above, with Elasticsearch-DSL the structure of an Elasticsearch index is defined as a Python class. The example application featured in this article uses a dataset of famous quotes that have the following fields:</p><ul><li><p><code>quote</code>: the text of the quote, as a string</p></li><li><p><code>author</code>: the name of the author, as a string</p></li><li><p><code>tags</code>: a list of tag names that apply to the quote, each a string</p></li></ul><p>As part of this application we are going to add one additional field, the vector embedding that we will use to search for quotes:</p><ul><li><p><code>embedding</code>: a list of floating point numbers representing a vector embedding for the quote</p></li></ul><p>Let's write an initial document class to describe our famous quotes index:</p>import elasticsearch_dsl as dsl

class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str
    tags: list[str]
    embedding: list[float]

    class Index:
        name = 'quotes'
<p>The <code>AsyncDocument</code> class that is used as a base class for our <code>QuoteDoc</code> class implements all the functionality to connect the class to an Elasticsearch index. The choice of an asynchronous document base class was made because this examples uses the FastAPI web framework, which is also asynchronous. For projects that do not use asynchronous Python, the <code>Document</code> base class must be used when declaring document classes.</p><p>The <code>name</code> attribute given in the <code>Index</code> inner class defines the name of the Elasticsearch index that will be used with documents of this class.</p><p>If you have used Python dataclasses before, you likely find the way fields are defined very familiar, with each field being given a Python type hint. These Python types are mapped to the closest Elasticsearch type, so for example, in the case of <code>str</code>, the corresponding field in the Elasticsearch index will be given the type <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/text.html#text-field-type"><code>text</code></a>, the standard type that is used for text that needs to be indexed for full-text search, while <code>float</code> is mapped to the equally named <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/number.html"><code>float</code></a> on the Elasticsearch side.</p><p>While it can be useful to leave the <code>quote</code> field as is so that we can use it for both vector and full-text searches, the <code>author</code> and <code>tags</code> fields do not really need all the extra work associated with full-text search. The best Elasticsearch type for these fields is <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/keyword.html#keyword-field-type"><code>keyword</code></a>, which just stores the text, without doing any indexing. Likewise, the <code>embedding</code> field is not just a simple list of floating point numbers, we are going to use it for vector search, which is a behavior associated with the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html"><code>dense_vector</code></a> type in Elasticsearch.</p><p>To assign a type override to a field, we add an assignment with the <code>mapped_field()</code> function, as shown in the improved version of the <code>QuoteDoc</code> class that follows:</p>class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'
<p>As you can see in this updated version, the <code>elasticsearch_dsl</code> package includes classes such as <code>Keyword</code> and <code>DenseVector</code> to represent all the native Elasticsearch field types.</p><p>Did you notice the <code>init=False</code> argument given in this new definition of the <code>embedding</code> field? If you are familiar with Python dataclasses you may recognize <code>init</code> as one of the options available in the dataclasses <a href="https://docs.python.org/3/library/dataclasses.html#dataclasses.field"><code>field()</code></a> function, used to indicate that the given attribute should be omitted from the constructor for instances of the class. The behavior is the same here, which means that when creating an instance of <code>QuoteDoc</code>, this argument should not be given.</p><p>How will the vector embeddings be generated if they will not be passed down to the document constructor? Elasticsearch-DSL always calls the <code>clean()</code> method in all documents before serializing them and sending them to Elasticsearch. This method is a convenience entry point where the application can add any custom field processing logic. For example, fields that are optional or auto-generated can be added in this method. Here is the final version of the <code>QuoteDoc</code> document class, including the logic that generates the embeddings:</p>from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'

    def clean(self):
        if not self.embedding:
            self.embedding = model.encode(self.quote).tolist()
<p>For this example we are going to use embeddings from a <a href="https://sbert.net/">SentenceTransformers</a> model. These embeddings are easy to generate locally and being open source and free they are convenient to use when experimenting. The <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2">all-MiniLM-L6-v2</a> model is a great general purpose embedding model for English text. There are many other models that are also compatible with the SentenceTransformers framework, so feel free to use a different one if you prefer.</p><p>The <code>clean()</code> method can be used for more advanced use cases as well. For example, it is common when working with large bodies of text to split the text into smaller chunks, and then generate embeddings for each chunk. Elasticsearch accommodates this use case through nested objects. If you want to see an advanced example that implements this type of solution, check out the <a href="https://github.com/elastic/elasticsearch-dsl-py/blob/main/examples/vectors.py">vectors</a> example in the Elasticsearch-DSL repository.</p><h2>Document ingestion</h2><p>With the structure of the index in place, we can now create the index. This is done with the <code>init()</code> class method:</p>async def ingest_quotes():
    await QuoteDoc.init()
<p>In many cases it is useful to delete a previously existing index to make sure an ingest process begins from a clean starting point. This can be done using the <code>_index</code> class attribute, which provides access to the Elasticsearch index, along with its <code>exists()</code> and <code>delete()</code> methods:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()
<p>The example dataset used by the example application is a collection of almost 37,000 famous quotes. It comes as a CSV file with the <code>quote</code>, <code>author</code> and <code>tags</code> columns. The tags are given as a comma-separated string. The dataset is available for <a href="https://raw.githubusercontent.com/miguelgrinberg/quotes/main/backend/quotes.csv">download</a> from the example GitHub repository.</p><p>To ingest the data contained in this dataset, Python's <code>csv</code> module can be used:</p>import csv

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()
<p>The <code>csv.DictReader</code> class creates a CSV file importer that returns a dictionary for each row in the data file. For each row, we create a <code>QuoteDoc</code> instance and pass the <code>quote</code>, <code>author</code> and <code>tags</code> in the constructor. For the tags, the string that is read from the CSV file has to be split into a list, which is how it will be stored in the Elasticsearch index.</p><p>To write a document to the index, the <code>save()</code> method is invoked. This method will call the document's <code>clean()</code> method, which in turn will generate the vector embedding for the quote.</p><h3>Starting an Elasticsearch instance</h3><p>Before the above ingest script can be executed, you need to have access to a running instance of Elasticsearch. By far the easiest (and also 100% free) way to do this is with a <a href="https://www.docker.com/">Docker</a> container.</p><p>To start a single-node Elasticsearch service on your computer first make sure you have Docker running, and then execute the following command:</p>docker run -p 127.0.0.1:9200:9200 -d --name elasticsearch \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  -e "xpack.license.self_generated.type=basic" \
  -v "./data:/usr/share/elasticsearch/data" \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.0
<p>To make sure you are running the latest and greatest version, open the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/es-release-notes.html">release notes</a> page to find out what is the current version, then replace the version number in the last line of the above command.</p><p>The <code>-v</code> option in the command above sets up a mapping between a directory named <code>data</code> in your local system and the data directory in the Elasticsearch container. All the data files used by Elasticsearch will be saved in this directory, so that in case you need to restart your container you do not lose any data. If you prefer to not store the data files in your computer, then you can remove the <code>-v</code> line and the data will be stored ephemerally in the container.</p><p>Note that deploying Elasticsearch using this method is only adequate for local experimentation. If you intend to deploy Elasticsearch on a production server, consider using our <a href="https://www.elastic.co/blog/getting-started-with-the-elastic-stack-and-docker-compose">Elasticsearch on Docker Compose</a> or <a href="https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-deploy-eck.html">Elasticsearch on Kubernetes</a> guides.</p><h3>Connecting to Elasticsearch</h3><p>The ingestion script needs to know how to connect to Elasticsearch. If you are running a Docker container as demonstrated in the previous section, add the following line between the imports and the definition of the <code>QuoteDoc</code> class:</p>dsl.async_connections.create_connection(hosts=['http://localhost:9200'])
<p>To complete the script, the <code>ingest_quotes()</code> function should be called. Add the following snippet at the bottom of your source file:</p>if __name__ == '__main__':
    asyncio.run(ingest_quotes())
<p>The <code>asyncio.run()</code> function will launch the asynchronous application. If your application is not asynchronous, then you would just call the ingest function directly.</p><p>For your convenience, below you can find the complete code for the script up to this point. You can save this file as <em>search.py</em>. You can find an example of this file <a href="https://github.com/miguelgrinberg/quotes/blob/main/backend/search.py">here</a>.</p>import asyncio
import csv
import elasticsearch_dsl as dsl
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
dsl.async_connections.create_connection(hosts=['http://localhost:9200'], serializer=OrjsonSerializer())


class QuoteDoc(dsl.AsyncDocument):
    quote: str
    author: str = dsl.mapped_field(dsl.Keyword())
    tags: list[str] = dsl.mapped_field(dsl.Keyword())
    embedding: list[float] = dsl.mapped_field(dsl.DenseVector(), init=False)

    class Index:
        name = 'quotes'

    def clean(self):
        if not self.embedding:
            self.embedding = model.encode(self.quote).tolist()

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()

if __name__ == '__main__':
    asyncio.run(ingest_quotes())
<p>Create a virtual environment for your project using the tool of your choice, and then install the dependencies on it:</p>pip install "elasticsearch-dsl[async]" sentence-transformers
<p>Make sure you have the <a href="https://raw.githubusercontent.com/miguelgrinberg/quotes/main/backend/quotes.csv">quotes.csv</a> file in the current directory, and then start the ingest by running the script:</p>python search.py
<p>The script does not print anything, so it will run for a while adding the quotes from the CSV file into your Elasticsearch index. The file has about 37,000 quotes, so expect the process to run for several minutes.</p><p>Luckily you do not need to wait that long. If you start the script and no error appears, that is confirmation that everything is working. You can press Ctrl-C to stop it and continue reading to learn about ingest performance.</p><h3>Performance tuning part 1: bulk processing</h3><p>If your dataset is small, then the above ingest solution will work just fine, and it has the benefit that it is simple to code and easy to understand.</p><p>For larger ingest jobs, however, it is necessary to sacrifice code clarity and pay attention to performance, so let's see what optimizations can be done in this application.</p><p>First of all, to evaluate performance we need to be able to measure the performance of the existing solution. Below is the updated <code>ingest_quotes()</code> function, which now calls <code>ingest_progress()</code> every 100 ingested documents to show how many documents have been ingested, along with an average document per second.</p>from time import time

# ...

def ingest_progress(count, start):
    elapsed = time() - start
    print(f'\rIngested {count} quotes. ({count / elapsed:.0f}/sec)', end='')

async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    with open('quotes.csv') as f:
        reader = csv.DictReader(f)
        count = 0
        start = time()
        for row in reader:
            q = QuoteDoc(quote=row['quote'], author=row['author'],
                         tags=row['tags'].split(','))
            await q.save()
            count += 1
            if count % 100 == 0:
                ingest_progress(count, start)
        ingest_progress(count, start)

# ...
<p>This version of the ingest is nicer than the previous one because it prints regular status updates. If you let the script run for a while you may see an output similar to the one below:</p>❯ python search.py
Ingested 4900 quotes. (97/sec)
<p>The data file has close to 37,000 quotes, so now you can have a good idea of how long the ingest will take. Assuming the average of 97 ingested documents per second holds throughout the entire ingest job, it should take less than 7 minutes to ingest the entire dataset. You can press Ctrl-C to stop this ingest process, there is no need to let it run to completion yet.</p><p>Elasticsearch offers a very flexible bulk ingest feature, which is made available in the Elasticsearch-DSL package's <code>bulk()</code> method. Instead of saving each document, the entire import loop can be moved into a generator function which is given to the <code>bulk()</code> method as an argument:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    async def get_next_quote():
        with open('quotes.csv') as f:
            reader = csv.DictReader(f)
            count = 0
            start = time()
            for row in reader:
                q = QuoteDoc(quote=row['quote'], author=row['author'],
                             tags=row['tags'].split(','))
                yield q
                count += 1
                if count % 100 == 0:
                    ingest_progress(count, start)
            ingest_progress(count, start)

    await QuoteDoc.bulk(get_next_quote())
<p>Here the <code>get_next_quote()</code> inner generator function yields <code>QuoteDoc</code> instances. The <code>QuoteDoc.bulk()</code> method will run the generator and issue batch updates to Elasticsearch. With this change, you can expect to see a small speed improvement:</p>❯ python s.py
Ingested 5500 quotes. (108/sec)
<p>For another small improvement, the JSON serializer used by the Elasticsearch client can be changed to the <a href="https://pypi.org/project/orjson/">orjson</a> library, which performs better than Python's own:</p>from elasticsearch import OrjsonSerializer
# ...

dsl.async_connections.create_connection(hosts=['http://localhost:9200'],
                                        serializer=OrjsonSerializer())

# ...
<p>This should lead to another small performance improvement:</p>❯ python s.py
Ingested 5100 quotes. (111/sec)
<h3>Performance tuning part 2: GPU accelerated embeddings</h3><p>You have seen in the previous section that we have obtained some modest performance improvements by processing ingest requests in bulk. But while ingestion requests are now being grouped, the embeddings continue to be generated one by one in the <code>clean()</code> method of the <code>QuoteDoc</code> class.</p><p>Is there a way to optimize embedding generation? The SentenceTransformers model uses PyTorch, which in turn uses a GPU if one is available. But the embeddings are generated individually, which does not lead to an optimal utilization of the GPU hardware. GPUs are very good at parallelization, so we can reorganize the ingest function to generate embeddings in batches. And once again the price we pay for this comes in increased code complexity.</p><p>So we are going to stop using the <code>clean()</code> method to generate document embeddings, and instead we are going to accumulate the <code>QuoteDoc</code> instances in a list, and once we reach a good number we'll generate embeddings for all of them in a single operation.</p><p>Let's start by writing a helper function that generates embeddings for a list of <code>QuoteDoc</code> instances:</p>def embed_quotes(quotes):
    embeddings = model.encode([q.quote for q in quotes])
    for q, e in zip(quotes, embeddings):
        q.embedding = e.tolist()
<p>Note how now the <code>model.encode()</code> method is given a list of quotes to embed instead of a single one. When the input argument is a list, the model generates an embedding for each list element. The method accepts an optional <a href="https://sbert.net/docs/package_reference/sentence_transformer/SentenceTransformer.html#sentence_transformers.SentenceTransformer.encode"><code>batch_size</code></a> argument (not used in the example above) that defaults to 32 that can be used to control the size of each batch of samples that are sent to the model for computation. Depending on the GPU hardware you may find that different values of this argument help tune performance to the best possible. Once the embeddings are generated, they are assigned to each quote using a for-loop.</p><p>Now the ingest function can be refactored to accumulate quotes and use the helper function to generate embeddings:</p>async def ingest_quotes():
    if await QuoteDoc._index.exists():
        await QuoteDoc._index.delete()
    await QuoteDoc.init()

    async def get_next_quote():
        quotes = []
        with open('quotes.csv') as f:
            reader = csv.DictReader(f)
            count = 0
            start = time()
            for row in reader:
                q = QuoteDoc(quote=row['quote'], author=row['author'],
                             tags=row['tags'].split(','))
                quotes.append(q)
                if len(quotes) == 512:
                    embed_quotes(quotes)
                    for q in quotes:
                        yield q
                    count += len(quotes)
                    ingest_progress(count, start)
                    quotes = []
            if len(quotes) &gt; 0:
                embed_quotes(quotes)
                for q in quotes:
                    yield q
            ingest_progress(count, start)
<p>In this version of <code>ingest_quotes()</code>, each <code>QuoteDoc</code> instance is added to the <code>quotes</code> list, and when 512 elements have accumulated the <code>embed_quotes()</code> function added above is used to generate the embeddings more efficiently. Once the objects have their embeddings, they are yielded, so that the <code>bulk()</code> method from Elasticsearch-DSL can add them to the index as before.</p><p>What is the significance of the 512 number? There isn't any. We know that the model uses a batch size of 32, so it makes sense to accumulate at least that many documents. Starting from 32, you can try if larger powers of 2 provide better performance. With the hardware available to me, I've found 512 to give the best performance.</p><p>Here is an example run using batched embeddings:</p>❯ python search.py
Ingested 36864 quotes. (481/sec)
<p>And now the ingestion process runs much faster, with the entire dataset ingested in about 1 minutes and 16 seconds.</p><p>If you decide to try to optimize your ingest, you are encouraged to try different options and see what works best with your hardware.</p><h2>Querying the index</h2><p>If you are following along, by now you have an Elasticsearch index called <code>quotes</code> that is populated with about 37K famous quotes, each with a searchable vector embedding. Now it is time to learn how to query this index.</p><p>When using Elasticsearch-DSL, the document classes return a search object from their <code>search()</code> method:</p>s = QuoteDoc.search()
<p>The search object has a large number of methods that map to the query options in the Elasticsearch <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html">query DSL</a>.</p><p>The simplest query that can be issued is the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html">match all</a> query, which returns all the elements. With the class-based approach used by Elasticsearch-DSL, this is how to run the query:</p>s = QuoteDoc.search()
s = s.query(dsl.query.MatchAll())
async for q in s:
    print(q.quote)
<p>This would obviously print a listing of the entire list of quotes stored in the index, up to 10,000, which is the maximum number of results Elasticsearch returns by default.</p><p>In many cases it is useful to request a subset of the results. The search object uses Python style slicing for this. Here is how to request the first 25 results only:</p>async for q in s[:25]:
    print(q.quote)
<p>Here is how to request the second page of results, at 25 results per page:</p>async for q in s[25:50]:
    print(q.quote)
<p>Elasticsearch offers approximate and exact vector search queries, also called <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">k-nearest neighbor (kNN) queries</a>. To run a vector search query with the approximate k-nearest neighbor algorithm, the <code>Knn</code> query should be used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
<p>The <code>Knn</code> query class accepts the field that stores the embeddings and a search vector as arguments. In the above snippet the variable <code>q</code> has the search text entered by the user.</p><p>If instead you prefer to run a regular full-text search, the <code>Match</code> query class is used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Match(quote=q))
<h3>Filters</h3><p>One of the most important benefits of using Elasticsearch as a vector database is that it is a robust database system, and all the options you can expect to have from a database nicely integrates with your vector search queries.</p><p>A great example of this is <em>filters</em>. The famous quotes database stores a list of tags for each quote, so it is only natural to have the option to restrict a query to quotes that have a specific tag.</p><p>Given a list of tag filters stored in a <code>tags</code> variable, the following snippet configures a search object to only return results that include the given tags using a "terms" filter:</p>for tag in tags:
    s = s.filter(dsl.query.Terms(tags=[tag]))
<h3>Aggregations</h3><p>Another example of a useful database function that is fully integrated with vector search is <em>aggregations</em>. Given a query, Elasticsearch can aggregate the tags and provide the counts of quotes per tag.</p><p>The next snippet shows how to add a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">Terms</a> aggregation to an existing query, which will return the 100 most referenced tags in the results:</p>s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
<p>Recall that the <code>tags</code> field was declared with the <code>Keyword()</code> type, which means that the tags will be stored as is on the index, without any processing. This is required by the Terms aggregation, which will count the occurrences of each tag in the results.</p><h3>A complete query example</h3><p>You have seen a few isolated query examples. In this section you can see how they can all be integrated into a function that performs a query in the example application.</p><p>The <code>search_quotes()</code> function shown below accepts a query string <code>q</code>, a list of filters <code>tags</code> and a <code>use_knn</code> flag to choose between kNN or full-text search query. It also accepts <code>start</code> and <code>size</code> pagination arguments.</p><p>The function decides which of the three queries you've seen above to issue depending on the input arguments. If <code>q</code> is empty, then it selects a "match all" query, and in any other case it selects a kNN or match query depending on the <code>use_knn</code> flag, which the user can control from a checkbox in the application's user interface.</p><p>The function returns three results as a tuple:</p><ul><li><p>a list of <code>QuoteDoc</code> instances that are the search results,</p></li><li><p>the tag aggregations as a list of tuples, each with tag name and document count,</p></li><li><p>the total number of results, which is useful to show in paginated queries</p></li></ul><p>Here is the complete code of this function:</p>async def search_quotes(q, tags, use_knn=True, start=0, size=25):
    s = QuoteDoc.search()
    if q == '':
        s = s.query(dsl.query.MatchAll())
    elif use_knn:
        s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
    else:
        s = s.query(dsl.query.Match(quote=q))
    for tag in tags:
        s = s.filter(dsl.query.Terms(tags=[tag]))
    s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
    r = await s[start:start + size].execute()
    tags = [(tag.key, tag.doc_count) for tag in r.aggs.tags.buckets]
    return r.hits, tags, r['hits'].total.value
<p>To be able to access both the search results and the aggregation results, we now issue the request explicitly through the <code>execute()</code> method and store the response is stored in <code>r</code>. The <code>hits</code> attribute of the response object contains the actual search results, and the <code>aggs</code> attribute provides access to the aggregations. The format in which the aggregation results is provided is described in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">terms aggregation documentation</a>.</p><h2>Conclusion</h2><p>The complete quotes example is available in a <a href="https://github.com/miguelgrinberg/quotes">GitHub repository</a> that you can install and run on your computer. Follow the instructions on the <code>README.md</code> file to set it up.</p><p>You are welcome to use this example to experiment with vector embeddings and Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" length="0" type="image/png"/>
    <pubDate>Fri, 16 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Elasticsearch geospatial search with ES|QL]]></title>
    <description><![CDATA[Geospatial search in Elasticsearch Query Language (ES|QL). Elasticsearch has powerful geospatial search features, which are now coming to ES|QL for dramatically improved ease of use and OGC familiarity.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch has had powerful <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/geospatial-analysis.html">geospatial search and analytics capabilities</a> for many years, but the API was quite different from what typical GIS users were used to. In the past year we've <a href="https://www.elastic.co/search-labs/blog/esql-piped-query-language-goes-ga">added the ES|QL query language</a>, a piped query language as easy, or even easier, than SQL. It's particularly suited to the search, security, and observability use cases Elastic excels at. We're also adding support for geospatial search and analytics within ES|QL, making it far easier to use, especially for users coming from SQL or <a href="https://en.wikipedia.org/wiki/Geographic_information_system">GIS</a> communities.</p><p>Elasticsearch 8.12 and 8.13 brought basic support for geospatial types to ES|QL. This was dramatically enhanced with the addition of geospatial search capabilities in 8.14. More importantly, this support was designed to conform closely to the <a href="https://en.wikipedia.org/wiki/Simple_Features">Simple Feature Access</a> standard from the <a href="https://en.wikipedia.org/wiki/Open_Geospatial_Consortium">Open Geospatial Consortium (OGC)</a> used by other spatial databases like PostGIS, making it much easier to use for GIS experts familiar with these standards.</p><p>In this blog, we'll show you how to use ES|QL to perform geospatial searches, and how it compares to the SQL and Query DSL equivalents. We'll also show you how to use ES|QL to perform spatial joins, and how to visualize the results in Kibana Maps. Note that all the features described here are in "technical preview", and we'd love to hear your feedback on how we can improve them.</p><h2>Searching for geospatial data</h2><p>Let's start with an example query:</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>This performs a search for any city boundary polygons that intersect with a rectangular search polygon around the Sanya Phoenix International Airport (SYX).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3897df6bed5d6061/6a17d7c2abe0f29eccdfe861/e48bac8f246c8842f2ea97ddd54910045262aeb1-1440x808.png" alt="ESQL Geospatial Search" /><p>In a sample dataset of airports, cities and city boundaries, this search finds the intersecting polygon and returns the desired fields from the matching document:</p><p>abbrev</p><p>airport</p><p>region</p><p>city</p><p>city_location</p><p>SYX</p><p>Sanya Phoenix Int'l</p><p>天涯区</p><p>Sanya</p><p>POINT(109.5036 18.2533)</p><p>That was easy! Now compare this to the classic Elasticsearch Query DSL for the same query:</p>GET /airport_city_boundaries/_search
{
  "_source": ["abbrev", "airport", "region", "city", "city_location"],
  "query": {
    "geo_shape": {
      "city_boundary": {
        "shape": {
          "type": "polygon",
          "coordinates" : [[
            [109.4, 18.1],
            [109.6, 18.1],
            [109.6, 18.3],
            [109.4, 18.3],
            [109.4, 18.1]
          ]]
        }
      }
    }
  }
}
<p>Both queries are reasonably clear in their intent, but the ES|QL query closely resembles SQL. The same query in PostGIS looks like this:</p>SELECT abbrev, airport, region, city, city_location
FROM airport_city_boundaries
WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
);
<p>Look back at the ES|QL example. So similar, right?</p>FROM airport_city_boundaries
| WHERE ST_INTERSECTS(
      city_boundary,
      "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
  )
| KEEP abbrev, airport, region, city, city_location
<p>We've found that existing users of the Elasticsearch API find ES|QL much easier to use. We now expect that existing SQL users, particularly Spatial SQL users, will find that ES|QL feels very familiar to what they are used to seeing.</p><h4>Why not SQL?</h4><p>What about Elasticsearch SQL? It has been around for a while and has some geospatial features. However, Elasticsearch SQL was written as a wrapper on top of the original Query API, which meant only queries that could be transpiled down to the original API were supported. ES|QL does not have this limitation. Being a completely new stack allows for many optimizations that were not possible in SQL. Our benchmarks show ES|QL is <a href="https://elasticsearch-benchmarks.elastic.co/#tracks/esql/nightly/default/6M">very often faster than the Query API</a>, particularly with aggregations!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt18bda964c8b24e36/6a17d7c3e3179155d22d568a/b8b6c2b2e45850d832805ed1e71e522f4955f53c-1440x813.png" alt="polygon-intersection-benchmark" /><h2>Differences to SQL</h2><p>Clearly, from the previous example, ES|QL is somewhat similar to SQL, but there are some important differences. For example, ES|QL is a piped query language, starting with a source command like FROM and then chaining all subsequent commands together with the pipe | character. This makes it very easy to understand how each command receives a table of data and performs some action on that table, such as filtering with <code>WHERE</code>, adding columns with <code>EVAL</code>, or performing aggregations with <code>STATS</code>. Rather than starting with <code>SELECT</code> to define the final output columns, there can be one or more <code>KEEP</code> commands, with the last one specifying the final output results. This structure simplifies reasoning about the query.</p><p>Focusing in on the <code>WHERE</code> command in the above example, we can see it looks quite similar to the PostGIS example:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    "POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))"::geo_shape
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    'SRID=4326;POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'::geometry
)
<p>Aside from the difference in string quotation characters, the biggest difference is in how we type-cast the string to a spatial type. In PostGIS, we use the <code>::geometry</code> suffix, while in ES|QL, we use the <code>::geo_shape</code> suffix. This is because ES|QL runs within Elasticsearch, and the type-casting operator <code>::</code> can be used to convert a string to any of the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-limitations.html#_supported_types">supported ES|QL types</a>, in this case, a <code>geo_shape</code>. Additionally, the <code>geo_shape</code> and <code>geo_point</code> types in Elasticsearch imply the spatial coordinate system known as WGS84, more commonly referred to using the SRID number 4326. In PostGIS, this needs to be explicit, hence the use of the <code>SRID=4326;</code> prefix to the WKT string. If that prefix is removed, the SRID will be set to 0, which is more like the Elasticsearch types <code>cartesian_point</code> and <code>cartesian_shape</code>, which are not tied to any specific coordinate system.</p><p>Both ES|QL and PostGIS provide type conversion function syntax as well:</p><p><em>ES|QL</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    TO_GEOSHAPE("POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))")
)
<p><em>PostGIS</em></p>WHERE ST_INTERSECTS(
    city_boundary,
    ST_SetSRID(
      ST_GeomFromText('POLYGON((109.4 18.1, 109.6 18.1, 109.6 18.3, 109.4 18.3, 109.4 18.1))'),
      4326
    )
)
<h2>OGC functions</h2><p>Elasticsearch 8.14 introduces the following four OGC spatial search functions:</p><p>ES|QL</p><p>PostGIS</p><p>Description</p><p>ST_INTERSECTS</p><p>ST_Intersects</p><p>Returns true if two geometries intersect, and false otherwise.</p><p>ST_DISJOINT</p><p>ST_Disjoint</p><p>Returns true if two geometries do not intersect, and false otherwise. The inverse of ST_INTERSECTS.</p><p>ST_CONTAINS</p><p>ST_Contains</p><p>Returns true if one geometry contains another, and false otherwise.</p><p>ST_WITHIN</p><p>ST_Within</p><p>Returns true if one geometry is within another, and false otherwise. The inverse of ST_CONTAINS.</p><p>These function behave similarly to their PostGIS counterparts, and are used in the same way. For example, <code>ST_INTERSECTS</code> returns true if two geometries intersect and false otherwise. If you follow the documentation links in the above table, you might notice that all the ES|QL examples are within a <code>WHERE</code> clause after a <code>FROM</code> clause, while all the PostGIS examples are using literal geometries. In fact, both platforms support using the functions in any part of the query where they make sense.</p><p>The first example in the PostGIS documentation for <code>ST_INTERSECTS</code> is:</p>SELECT ST_Intersects(
    'POINT(0 0)'::geometry,
    'LINESTRING ( 2 0, 0 2 )'::geometry
);
<p>The ES|QL equivalent of this would be:</p>ROW ST_INTERSECTS(
    "POINT(0 0)"::geo_point,
    "LINESTRING ( 2 0, 0 2 )"::geo_shape
)
<p>Note how we did not specify the SRID in the PostGIS example. This is because in PostGIS when using the <code>geometry</code> type, all calculations are done on a planar coordinate system, and so if both geometries have the same SRID, it does not matter what the SRID is. In Elasticsearch, this is also true for most functions, however, there are exceptions where <code>geo_shape</code> and <code>geo_point</code> use spherical calculations, as we'll see in the next blog about spatial distance search.</p><h2>ES|QL versatility</h2><p>So, we've seen examples above for using spatial functions in <code>WHERE</code> clauses, and in <code>ROW</code> commands. Where else would they make sense? One very useful place is in the <code>EVAL</code> command. This command allows you to evaluate an expression and return the result. For example, let's determine if the centroids of all airports grouped by their country names are within a boundary outlining the country:</p>FROM airports
| EVAL in_uk = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL in_iceland = ST_INTERSECTS(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| EVAL within_uk = ST_WITHIN(location, TO_GEOSHAPE("POLYGON((1.2305 60.8449, -1.582 61.6899, -10.7227 58.4017, -7.1191 55.3291, -7.9102 54.2139, -5.4492 54.0078, -5.2734 52.3756, -7.8223 49.6676, -5.0977 49.2678, 0.9668 50.5134, 2.5488 52.1065, 2.6367 54.0078, -0.9668 56.4625, 1.2305 60.8449))"))
| EVAL within_iceland = ST_WITHIN(location, TO_GEOSHAPE("POLYGON ((-25.4883 65.5312, -23.4668 66.7746, -18.4131 67.4749, -13.0957 66.2669, -12.3926 64.4159, -20.1270 62.7346, -24.7852 63.3718, -25.4883 65.5312))"))
| STATS centroid = ST_CENTROID_AGG(location), count=COUNT() BY in_uk, in_iceland, within_uk, within_iceland
| SORT count ASC
<p>The results are expected, the centroid of UK airports are within the UK boundary, and not within the Iceland boundary, and vice versa:</p><p>centroid</p><p>count</p><p>in_uk</p><p>in_iceland</p><p>within_uk</p><p>within_iceland</p><p>POINT (-21.946634463965893 64.13187285885215)</p><p>1</p><p>false</p><p>true</p><p>false</p><p>true</p><p>POINT (-2.597342072712148 54.33551226578214)</p><p>17</p><p>true</p><p>false</p><p>true</p><p>false</p><p>POINT (0.04453958108176276 23.74658354606057)</p><p>873</p><p>false</p><p>false</p><p>false</p><p>false</p><p>In fact, these functions can be used in any part of the query where their signature makes sense. They all take two arguments, which are either a literal spatial object or a field of a spatial type, and they all return a boolean value. One important consideration is that the coordinate reference system (CRS) of the geometries must match, or an error will be returned. This means you cannot mix <code>geo_shape</code> and <code>cartesian_shape</code> types in the same function call. You can, however, mix <code>geo_point</code> and <code>geo_shape</code> types, as the <code>geo_point</code> type is a special case of the <code>geo_shape</code> type, and both share the same coordinate reference system. The documentation for each of the functions defined above lists the supported type combinations.</p><p>Additionally, either argument can be a spatial literal or a field, in either order. You can even specify two fields, two literals, a field and a literal, or a literal and a field. The only requirement is that the types are compatible. For example, this query compares two fields in the same index:</p>FROM airport_city_boundaries
| EVAL in_city = ST_INTERSECTS(city_location, city_boundary)
| STATS count=COUNT(*) BY in_city
| SORT count ASC
| EVAL cardinality = CASE(count &lt; 10, "very few", count &lt; 100, "few", "many")
| KEEP cardinality, count, in_city
<p>The query basically asks if the city location is within the city boundary, which should generally be true, but there are always exceptions:</p><p>cardinality</p><p>count</p><p>in_city</p><p>few</p><p>29</p><p>false</p><p>many</p><p>740</p><p>true</p><p>A far more interesting question would be whether the airport location is within the boundary of the city that the airport serves. However, the airport location resides in a different index than the one containing the city boundaries. This requires a method to effectively query and correlate data from these two separate indexes.</p><h2>Spatial joins</h2><p>ES|QL does not support <code>JOIN</code> commands, but you can achieve a special case of a join using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"><code>ENRICH</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-commands.html#esql-enrich"> command</a>, which behaves similarly to a 'left join' in SQL. This command operates akin to a 'left join' in SQL, allowing you to enrich results from one index with data from another index based on a spatial relationship between the two datasets.</p><p>For example, let's enrich the results from a table of airports with additional information about the city they serve by finding the city boundary that contains the airport location, and then perform some statistics on the results:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
| MV_EXPAND city_boundary
| EVAL boundary_wkt_length = LENGTH(TO_STRING(city_boundary))
| STATS centroid = ST_CENTROID_AGG(location), count = COUNT(city_location), min_wkt = MIN(boundary_wkt_length), max_wkt = MAX(boundary_wkt_length) BY region
| SORT count DESC
| LIMIT 5
<p>This returns the top 5 regions with the most airports, along with the centroid of all the airports that have matching regions, and the range in length of the WKT representation of the city boundaries within those regions:</p><p>centroid</p><p>count</p><p>min_wkt</p><p>max_wkt</p><p>region</p><p>POINT (-32.56093470960719 32.598117914802714)</p><p>90</p><p>207</p><p>207</p><p>null</p><p>POINT (-73.94515332765877 40.70366442203522)</p><p>9</p><p>438</p><p>438</p><p>City of New York</p><p>POINT (-83.10398317873478 42.300230911932886)</p><p>9</p><p>473</p><p>473</p><p>Detroit</p><p>POINT (-156.3020245861262 20.176383580081165)</p><p>5</p><p>307</p><p>803</p><p>Hawaii</p><p>POINT (-73.88902732171118 45.57078813901171)</p><p>4</p><p>837</p><p>837</p><p>Montréal</p><p>So, what really happened here? Where did the supposed <code>JOIN</code> occur? The crux of the query lies in the <code>ENRICH</code> command:</p>FROM airports
| ENRICH city_boundaries ON city_location WITH airport, region, city_boundary
<p>This command instructs Elasticsearch to enrich the results retrieved from the <code>airports</code> index, and perform an <code>intersects</code> join between the <code>city_location</code> field of the original index, and the <code>city_boundary</code> field of the <code>airport_city_boundaries</code> index, which we used in a few examples earlier. But some of this information is not clearly visible in this query. What we do see is the name of an enrich policy <code>city_boundaries</code>, and the missing information is encapsulated within that policy definition.</p>{
  "geo_match": {
    "indices": "airport_city_boundaries",
    "match_field": "city_boundary",
    "enrich_fields": ["city", "airport", "region", "city_boundary"]
  }
}
<p>Here we can see that it will perform a <code>geo_match</code> query (<code>intersects</code> is the default), the field to match against is <code>city_boundary</code>, and the <code>enrich_fields</code> are the fields we want to add to the original document. One of those fields, the <code>region</code> was actually used as the grouping key for the <code>STATS</code> command, something we could not have done without this 'left join' capability. For more information on enrich policies, see the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/enrich-setup.html">enrich documentation</a>. While reading those documents, you will notice that they describe using the enrich indexes for enriching data at index time, by configuring ingest pipelines. This is not required for ES|QL, as the <code>ENRICH</code> command works at query time. It is sufficient to prepare the enrich index with the necessary data and enrich policy, and then use the <code>ENRICH</code> command in your ES|QL queries.</p><p>You may also notice that the most commonly found region was <code>null</code>. What could this imply? Recall that I likened this command to a 'left join' in SQL, meaning if no matching city boundary is found for an airport, the airport is still returned but with <code>null</code> values for the fields from the <code>airport_city_boundaries</code> index. It turns out there were 89 airports that found no matching <code>city_boundary</code>, and one airport with a match where the <code>region</code> field was <code>null</code>. This lead to a count of 90 airports with no <code>region</code> in the results. Another interesting detail is the need for the <code>MV_EXPAND</code> command. This is necessary because the <code>ENRICH</code> command may return multiple results for each input row, and <code>MV_EXPAND</code> helps to separate these results into multiple rows, one for each outcome. This also clarifies why "Hawaii" shows different <code>min_wkt</code> and <code>max_wkt</code> results: there were multiple regions with the same name but different boundaries.</p><h2>Kibana Maps</h2><p>Kibana has added support for Spatial ES|QL in the Maps application. This means that you can now use ES|QL to search for geospatial data in Elasticsearch, and visualize the results on a map.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt91922eb4ef43d290/6a16f7161949f737bfe7a7b5/bd78470bd8a4bc60f0db7006bd804b8fe87e2fea-1440x683.png" alt="Kibana Layers ES|QL" /><p>There is a new layer option in the add layers menu, called "ES|QL". Like all of the geospatial features described so far, this is in "technical preview". Selecting this option allows you to add a layer to the map based on the results of an ES|QL query. For example, you could add a layer to the map that shows all the airports in the world.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5185ef7461d7e81d/6a16f718839dfa1559dcfcb1/1dd28d3d0509f92d26b0bb5320a2925f7a54c5d9-1440x736.png" alt="Kibana ES|QL - Airports" /><p>Or you could add a layer that shows the polygons from the <code>airport_city_boundaries</code> index, or even better, how about that complex <code>ENRICH</code> query above that generates statistics for how many airports are in each region?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt51ee3543bb811d98/6a16f71a8b73cbbe63189df1/679a0a401faa613c7cedddd07c64f614ac2b7144-1440x727.png" alt="Kibana ES|QL - Region Statistics" /><h2>What's next</h2><p>You might have noticed in two of the examples above we squeezed in yet another spatial function <code>ST_CENTROID_AGG</code>. This is an aggregating function used in the <code>STATS</code> command, and the first of many spatial analytics features we plan to add to ES|QL. We'll blog about it when we've got more to show!</p><p>Before that, we want to tell you more about a particularly exciting feature we've worked on: the ability to perform spatial distance searches, one of the most used spatial search features of Elasticsearch. Can you imagine what the syntax for distance searches might look like? Perhaps similar to an OGC function? Stay tuned for the next blog in this series to find out!</p><p>Spoiler alert: Elasticsearch 8.15 has just been released, and spatial distance search with ES|QL is included!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/esql-geospatial-search-part-one</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[ES|QL]]></category>
    <dc:creator><![CDATA[Craig Taverner]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd05627be20e89dfb/6a17d7c6414c640256944fdb/de886289dcb56494920875303b622b030b9b810f-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Mon, 12 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Building multilingual RAG with Elastic and Mistral]]></title>
    <description><![CDATA[Building a multilingual RAG application using Elastic and Mixtral 8x22B model]]></description>
    <content:encoded><![CDATA[<p><a href="https://mistral.ai/news/mixtral-8x22b">Mixtral 8x22B</a> is the most performant open model, and one of its most powerful features is fluency in many languages; including English, Spanish, French, Italian, and German.</p><p>Imagine a multinational company with support tickets and solutions in different languages and wants to take advantage of that knowledge across divisions. Currently, knowledge is limited to the language the agent speaks. Let's fix that!</p><p>In this article, I’m going to show you how to test Mixtral’s language capabilities, by creating a multilingual RAG system.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4116efa3368e0387/6a17117a1949f76a59e7ab36/27ba7e0cdf3d484b5c9e697702b9a63bff49b82b-1440x868.png" alt="Building multilingual RAG with Elastic and Mistral diagram" /><p><em>You can follow the notebook to reproduce this article's example </em><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/building-multilingual-rag-with-elastic-and-mistral/building_multilingual_rag_with_elastic_and_mistral.ipynb"><em>here</em></a></p><h3>Steps</h3><ol><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#creating-endpoints">Creating embeddings endpoint</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#creating-mappings">Creating mappings</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#indexing-data">Indexing data</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral#asking-questions">Asking questions</a></p></li></ol><h2>Creating embeddings endpoint</h2><p>Our support tickets for this example will come in English, Spanish, and German. The Mistral embeddings model is not multilingual, but we can generate <a href="https://www.elastic.co/search-labs/blog/multilingual-vector-search-e5-embedding-model">multilingual embeddings</a> using the e5 model, so we can index text on different languages and manage it as a single source, giving us a much richer context.</p><p>To create e5 multilingual embeddings you can use Kibana:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0aadfb7eeddd9754/6a17117c6234e00fc6db1ae8/a691763d2976a23d7d82177b6a7e8ad31051b913-800x549.gif" alt="Creating a multilingual endpoint with Kibana" /><p>Or the _inference API:</p>PUT _inference/text_embedding/multilingual-embeddings
 {
    "service": "elasticsearch",
    "service_settings": {
        "model_id": ".multilingual-e5-small",
        "num_allocations": 1 ,
        "num_threads": 1
    }
}
<h2>Creating Mappings</h2><p>For the mappings we will use <a href="https://www.elastic.co/search-labs/blog/semantic-search-simplified-semantic-text">semantic_text</a> mapping type, which is one of my favorite features. It handles the process of chunking the data, generating embeddings, and querying embeddings for you!</p>PUT multilingual-mistral
{
  "mappings": {
    "properties": {
      "super_body": {
        "type": "semantic_text",
        "inference_id": "multilingual-embeddings"
      }
    }
  }
}
<p>We call the text field <code>super_body</code> because with a single mapping type it will handle chunks and embeddings.</p><h2>Indexing data</h2><p>We will index a couple of support tickets with problems and solutions in two languages, and then ask a question about problems within many documents in a third.</p><p>The following documents will be added to the index:</p><p></p><p>1. English Support Ticket: Calendar Sync Issue</p><p></p><p><em>Support Ticket #EN1234</em> <strong>Subject</strong>: Calendar sync not working with Google Calendar</p><p><strong>Description</strong>: I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying "Unable to connect to external calendar service."</p><p><strong>Resolution</strong>: The issue was resolved by following these steps:</p><ol><li><p>Go to Settings &gt; Integrations</p></li></ol><p></p><ol><li><p>Disconnect the Google Calendar integration</p></li></ol><p></p><ol><li><p>Clear browser cache and cookies</p></li></ol><p></p><ol><li><p>Reconnect the Google Calendar integration</p></li></ol><p></p><ol><li><p>Authorize the app again in Google's security settings</p></li></ol><p>The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.</p><p></p><p>2. German Support Ticket: File Upload Problem</p><p></p><p><em>Support-Ticket #DE5678</em> <strong>Betreff</strong>: Datei-Upload funktioniert nicht</p><p><strong>Beschreibung</strong>: Ich kann keine Dateien mehr in meine Projekte hochladen. Jedes Mal, wenn ich es versuche, bleibt der Ladebalken bei 99% stehen und dann erscheint eine Fehlermeldung.</p><p><strong>Lösung</strong>: Das Problem wurde durch folgende Schritte gelöst:</p><ol><li><p>Überprüfen Sie die Dateigröße. Die maximale Uploadgröße beträgt 100 MB.</p></li></ol><p></p><ol><li><p>Deaktivieren Sie vorübergehend den Virenschutz oder die Firewall.</p></li></ol><p></p><ol><li><p>Versuchen Sie, die Datei im Inkognito-Modus hochzuladen.</p></li></ol><p></p><ol><li><p>Wenn das nicht funktioniert, leeren Sie den Browser-Cache und die Cookies.</p></li></ol><p></p><ol><li><p>Als letzten Ausweg, versuchen Sie einen anderen Browser zu verwenden.</p></li></ol><p>In den meisten Fällen lag das Problem an zu großen Dateien oder an Interferenzen durch Sicherheitssoftware. Nach Anwendung dieser Schritte sollte der Upload funktionieren.</p><p></p><p>3. Marketing Campaign Ideas (noise)</p><p></p><p><em>Q3 Marketing Campaign Ideas</em></p><ol><li><p>Social media contest: "Share Your Productivity Hack"</p><ul><li><p>Users share tips using our software, best entry wins a premium subscription.</p></li></ul></li></ol><p></p><ol><li><p>Webinar series: "Mastering Project Management"</p><ul><li><p>Invite industry experts to share insights using our tool.</p></li></ul></li></ol><p></p><ol><li><p>Email campaign: "Unlock Hidden Features"</p><ul><li><p>Series of emails highlighting lesser-known but powerful features.</p></li></ul></li></ol><p></p><ol><li><p>Partner with a productivity podcast for sponsored content.</p></li></ol><p></p><ol><li><p>Create a "Project Management Memes" social media account for lighter, shareable content.</p></li></ol><p></p><p>4. Mitarbeiter des Monats (noise)</p><p></p><p><em>Mitarbeiter des Monats: Juli 2023</em></p><p>Wir freuen uns, bekannt zu geben, dass Sarah Schmidt zur Mitarbeiterin des Monats Juli gewählt wurde!</p><p>Sarah hat außergewöhnliche Leistungen in folgenden Bereichen gezeigt:</p><ul><li><p>Kundenbetreuung: Sarah hat durchschnittlich 95% positive Bewertungen erhalten.</p></li></ul><p></p><ul><li><p>Teamarbeit: Sie hat maßgeblich zur Verbesserung unseres internen Wissensmanagementsystems beigetragen.</p></li></ul><p></p><ul><li><p>Innovation: Sarah hat eine neue Methode zur Priorisierung von Support-Tickets vorgeschlagen, die unsere Reaktionszeiten um 20% verbessert hat.</p></li></ul><p>Bitte gratulieren Sie Sarah zu dieser wohlverdienten Anerkennung!</p><p>This is how a document will look like inside Elasticsearch:</p>{
    "took": 9,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 0.9155389,
        "hits": [
            {
                "_index": "multilingual-mistral",
                "_id": "1",
                "_score": 0.9155389,
                "_source": {
                    "super_body": {
                        "text": "\n        _Support Ticket #EN1234_\n        **Subject**: Calendar sync not working with Google Calendar\n\n        **Description**:\n        I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying \"Unable to connect to external calendar service.\"\n\n        **Resolution**:\n        The issue was resolved by following these steps:\n        1. Go to Settings &gt; Integrations\n        2. Disconnect the Google Calendar integration\n        3. Clear browser cache and cookies\n        4. Reconnect the Google Calendar integration\n        5. Authorize the app again in Google's security settings\n\n        The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.\n    ",
                        "inference": {
                            "inference_id": "multilingual-embeddings",
                            "model_settings": {
                                "task_type": "text_embedding",
                                "dimensions": 384,
                                "similarity": "cosine",
                                "element_type": "float"
                            },
                            "chunks": [
                                {
                                    "text": "passage: \n        _Support Ticket #EN1234_\n        **Subject**: Calendar sync not working with Google Calendar\n\n        **Description**:\n        I'm having trouble syncing my project deadlines with Google Calendar. Whenever I try to sync, I get an error message saying \"Unable to connect to external calendar service.\"\n\n        **Resolution**:\n        The issue was resolved by following these steps:\n        1. Go to Settings &gt; Integrations\n        2. Disconnect the Google Calendar integration\n        3. Clear browser cache and cookies\n        4. Reconnect the Google Calendar integration\n        5. Authorize the app again in Google's security settings\n\n        The sync should now work correctly. If problems persist, ensure that third-party cookies are enabled in your browser settings.",
                                    "embeddings": [
                                        0.0059651174,
                                        0.0016363655,
                                        -0.064753555,
                                        0.0093298275,
                                        0.05689768,
                                        -0.049640983,
                                        0.02504726,
                                        0.0048340675,
                                        0.08093895,
                                        ...
                                    ]
                                }
                            ]
                        }
                    }
                }
            }
        ]
    }
}
<h2>Asking questions</h2><p>Now, we are going to ask a question in Spanish:</p>Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error.<p>The expectation is retrieving documents #1 and #2, then sending them to the LLM as additional context, and finally, getting an answer in Spanish.</p><h4>Retrieving documents</h4><p>To retrieve the relevant documents, we can use this nice and short query that will run a search on the embeddings, and return the support tickets most relevant to the question.</p>GET multilingual-mistral/_search
{
   "size": 2,
   "_source": {
    "excludes": ["*embeddings", "*chunks"]
   },
  "query": {
    "semantic": {
      "field": "super_body",
      "query": "Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error."
    }
  }
}
<p><em>Notes about the parameters set:</em> <code>size: 2</code> Because we know we want the top 2 documents. <code>excludes</code> For clarity in the response. Documents are short so each one will be one chunk long.</p><h4>Answering the question</h4><p>Now we can call the Mistral completion API using the Python library to answer the question.</p>from mistralai.client import MistralClient
from mistralai.models.chat_completion import ChatMessage

api_key = os.environ["MISTRAL_API_KEY"]
model = "open-mixtral-8x22b"

client = MistralClient(api_key=api_key)

system_message = "You are a helpful multilingual agent that helps users with their problems. You have access to a knowledge base of different languages and you must answer in the same language the question was asked."

user_message = """
## Question:

Hola, estoy teniendo problemas para ocupar su aplicación, estoy teniendo problemas para sincronizar mi calendario, y encima al intentar subir un archivo me da error. 

## Related knowledge:

Support Ticket #EN1234 Subject: Calendar sync not working with Google Calendar...
(the rest of the content of the document)

\n

Support-Ticket #DE5678 Betreff: Datei-Upload funktioniert nicht... 
(the rest of the content of the document)

ANSWER:

"""

messages = [
    ChatMessage(role="system", content=system_message),
    ChatMessage(role="user", content=user_message)
]

chat_response = client.chat(
    model=model,
    messages=messages,
)

print(chat_response.choices[0].message.content) 
<p>The answer is in perfect Spanish and on point!</p><p></p><p>Show answer</p><p></p><p>Hola, me alegra que te hayas comunicado con nosotros. Parece que hay dos problemas distintos.</p><p>En cuanto a la sincronización del calendario, puedes seguir estos pasos para resolver el problema:</p><ol><li><p>Ve a Configuración &gt; Integraciones</p></li></ol><p></p><ol><li><p>Desconecta la integración del Calendario de Google</p></li></ol><p></p><ol><li><p>Borra la caché y las cookies del navegador</p></li></ol><p></p><ol><li><p>Vuelve a conectar la integración del Calendario de Google</p></li></ol><p></p><ol><li><p>Autoriza de nuevo la aplicación en la configuración de seguridad de Google</p></li></ol><p>Si sigues teniendo problemas, asegúrate de que las cookies de terceros están habilitadas en la configuración de tu navegador.</p><p>En cuanto al problema de subir un archivo, hay varias cosas que puedes probar:</p><ol><li><p>Comprueba el tamaño del archivo. El tamaño máximo de carga es de 100 MB.</p></li></ol><p></p><ol><li><p>Desactiva temporalmente el antivirus o el cortafuegos.</p></li></ol><p></p><ol><li><p>Intenta cargar el archivo en modo incógnito.</p></li></ol><p></p><ol><li><p>Si eso no funciona, borra la caché y las cookies del navegador.</p></li></ol><p></p><ol><li><p>Como último recurso, prueba a usar un navegador diferente.</p></li></ol><p>En la mayoría de los casos, el problema se debe a archivos demasiado grandes o a interferencias causadas por software de seguridad. Al seguir estos pasos, deberías poder cargar el archivo correctamente.</p><p>¡Espero que esto te ayude a resolver tus problemas! Si tienes alguna otra pregunta, no dudes en preguntar.</p><h2>Conclusion</h2><p>Mixtral 8x22B is a powerful model that enables us to leverage data sources in different languages, being able to answer, understand, and translate in many languages. This ability– together with multilingual embeddings– allows you to have multilingual support both in the data retrieval and the answer generation stages, removing language barriers entirely.</p><p><em>If you are interested on reproducing the examples of this article, you can find the Python Notebook with the requests </em><a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/building-multilingual-rag-with-elastic-and-mistral/building_multilingual_rag_with_elastic_and_mistral.ipynb"><em>here</em></a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/building-multilingual-rag-with-elastic-and-mistral</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Gustavo Llermaly]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9cf558f36ced44dc/6a17117dd7c022520cde65a2/7dd63f367670175590e30927ef432ff93e166c84-1440x809.png" length="0" type="image/png"/>
    <pubDate>Fri, 02 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Evaluating search relevance part 1 - The BEIR benchmark]]></title>
    <description><![CDATA[Learn to evaluate your search system in the context of better understanding the BEIR benchmark, with tips &amp; techniques to improve your search evaluation processes.]]></description>
    <content:encoded><![CDATA[<p>This is the first in a series of blog posts discussing how to think about evaluating your own search systems in the context of better understanding the BEIR benchmark. We will introduce specific tips and techniques to improve your search evaluation processes in the context of better understanding BEIR. We will also introduce common gotchas which make evaluation less reliable. Finally, we note that LLMs provide a powerful new tool in the search engineers' arsenal and we will show by example how one can use them to help evaluate search.</p><h2>Understanding the BEIR benchmark in search relevance evaluation</h2><p>To improve any system you need to be able to measure how well it is doing. In the context of search <a href="https://arxiv.org/abs/2104.08663">BEIR</a> (or equivalently the Retrieval section of the <a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB</a> leaderboard) is considered the “holy grail” for the information retrieval community and there is no surprise in that. It’s a very well-structured benchmark with varied datasets across different tasks. More specifically, the following areas are covered:</p><ul><li><p>Argument retrieval (ArguAna, Touche2020)</p></li><li><p>Open-domain QA (HotpotQA, Natural Questions, FiQA)</p></li><li><p>Passage retrieval (MSMARCO)</p></li><li><p>Duplicate question retrieval (Quora, CQADupstack)</p></li><li><p>Fact-checking (FEVER, Climate-FEVER, Scifact)</p></li><li><p>Biomedical information retrieval (TREC-COVID, NFCorpus, BioASQ)</p></li><li><p>Entity retrieval (DBPedia)</p></li><li><p>Citation prediction (SCIDOCS)</p></li></ul><p>It provides a single statistic, nDCG@10, related to how well a system matches the most relevant documents for each task example in the top results it returns. For a search system that a human interacts with relevance of top results is critical. However, there are many nuances to evaluating search that a single summary statistic misses.</p><h2>Structure of a BEIR dataset</h2><p>Each benchmark has three artefacts:</p><ul><li><p>the corpus or documents to retrieve</p></li><li><p>the queries</p></li><li><p>the relevance judgements for the queries (aka <code>qrels</code>).</p></li></ul><p>Relevance judgments are provided as a score which is zero or greater. Non-zero scores indicate that the document is somewhat related to the query.</p><p>Dataset</p><p>Corpus size</p><p>#Queries in the test set</p><p>#qrels positively labeled</p><p>#qrels equal to zero</p><p>#duplicates in the corpus</p><p>Arguana</p><p>8,674</p><p>1,406</p><p>1,406</p><p>0</p><p>96</p><p>Climate-FEVER</p><p>5,416,593</p><p>1,535</p><p>4,681</p><p>0</p><p>0</p><p>DBPedia</p><p>4,635,922</p><p>400</p><p>15,286</p><p>28,229</p><p>0</p><p>FEVER</p><p>5,416,568</p><p>6,666</p><p>7,937</p><p>0</p><p>0</p><p>FiQA-2018</p><p>57,638</p><p>648</p><p>1,706</p><p>0</p><p>0</p><p>HotpotQA</p><p>5,233,329</p><p>7,405</p><p>14,810</p><p>0</p><p>0</p><p>Natural Questions</p><p>2,681,468</p><p>3,452</p><p>4,021</p><p>0</p><p>16,781</p><p>NFCorpus</p><p>3,633</p><p>323</p><p>12,334</p><p>0</p><p>80</p><p>Quora</p><p>522,931</p><p>10,000</p><p>15,675</p><p>0</p><p>1,092</p><p>SCIDOCS</p><p>25,657</p><p>1,000</p><p>4,928</p><p>25,000</p><p>2</p><p>Scifact</p><p>5,183</p><p>300</p><p>339</p><p>0</p><p>0</p><p>Touche2020</p><p>382,545</p><p>49</p><p>932</p><p>1,982</p><p>5,357</p><p>TREC-COVID</p><p>171,332</p><p>50</p><p>24,763</p><p>41,663</p><p>0</p><p>MSMARCO</p><p>8,841,823</p><p>6,980</p><p>7,437</p><p>0</p><p>324</p><p>CQADupstack (sum)</p><p>457,199</p><p>13,145</p><p>23,703</p><p>0</p><p>0</p><p><strong>Table 1</strong>: Dataset statistics. The numbers were calculated on the test portion of the datasets (<code>dev</code> for <code>MSMARCO</code>).</p><p><strong>Table 1</strong> presents some statistics for the datasets that comprise the <code>BEIR</code> benchmark such as the number of documents in the corpus, the number of queries in the test dataset and the number of positive/negative (query, doc) pairs in the <code>qrels</code> file. From a quick a look in the data we can immediately infer the following:</p><ul><li><p>Most of the datasets do not contain any negative relationships in the <code>qrels</code> file, i.e. zero scores, which would explicitly denote documents as irrelevant to the given query.</p></li><li><p>The average number of document relationships per query (<code>#qrels</code> / <code>#queries</code>) varies from 1.0 in the case of <code>ArguAna</code> to 493.5 (<code>TREC-COVID</code>) but with a value <code>&lt;</code>5 for the majority of the cases.</p></li><li><p>Some datasets suffer from duplicate documents in the corpus which in some cases may lead to incorrect evaluation i.e. when a document is considered relevant to a query but its duplicate is not. For example, in <code>ArguAna</code> we have identified 96 cases of duplicate doc pairs with only one doc per pair being marked as relevant to a query. By “expanding” the initial qrels list to also include the duplicates we have observed a relative increase of ~1% in the <code>nDCG@10</code> score on average.</p></li></ul>{
  "_id": "test-economy-epiasghbf-pro02b",
  "title": "economic policy international africa society gender house believes feminisation",
  "text": "Again employment needs to be contextualised with …",
  "metadata": {}
}
{
  "_id": "test-society-epiasghbf-pro02b",
  "title": "economic policy international africa society gender house believes feminisation",
  "text": "Again employment needs to be contextualised with …",
  "metadata": {}
}
<p><strong>Example of duplicate pairs in ArguAna. In the qrels file only the first appears to be relevant (as counter-argument) to query (“test-economy-epiasghbf-pro02a”)</strong></p><p>When comparing models on the MTEB leaderboard it is tempting to focus on average retrieval quality. This is a good proxy to the overall quality of the model, but it doesn't necessarily tell you how it will perform for you. Since results are reported per data set, it is worth understanding how closely the different data sets relate to your search task and rescore models using only the most relevant ones. If you want to dig deeper, you can additionally check for topic overlap with the various data set corpuses. Stratifying quality measures by topic gives a much finer-grained assessment of their specific strengths and weaknesses.</p><p>One important note here is that when a document is not marked in the <code>qrels</code> file then by default it is considered irrelevant to the query. We dive a little further into this area and collect some evidence to shed more light on the following question: “How often is an evaluator presented with (query, document) pairs for which there is no ground truth information?". The reason that this is important is that when only shallow markup is available (and thus not every relevant document is labeled as such) one Information Retrieval system can be judged worse than another just because it “chooses” to surface different relevant (but unmarked) documents. This is a common gotcha in creating high quality evaluation sets, particularly for large datasets. To be feasible manual labelling usually focuses on top results returned by the current system, so potentially misses relevant documents in its blind spots. Therefore, it is usually preferable to focus more resources on fuller mark up of fewer queries than broad shallow markup.</p><h2>Leveraging the BEIR benchmark for search relevance evaluation</h2><p>To initiate our analysis we implement the following scenario (see the <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/evaluating-search-relevance-part-1/retrieve-and-rerank.ipynb">notebook</a>):</p><ol><li><p>First, we load the corpus of each dataset into an Elasticsearch index.</p></li><li><p>For each query in the test set we retrieve the top-100 documents with BM25.</p></li><li><p>We rerank, the retrieved documents using a variety of SOTA reranking models.</p></li><li><p>Finally, we report the “judge rate” for the top-10 documents coming from steps 2 (after retrieval) and 3 (after reranking). In other words, we calculate the average percentage of the top-10 documents that have a score in the <code>qrels</code> file.</p></li></ol><p>The list of reranking of models we used is the following:</p><ul><li><p><a href="https://docs.cohere.com/reference/rerank">Cohere's</a> <code>rerank-english-v2.0</code> and <code>rerank-english-v3.0</code></p></li><li><p><a href="https://huggingface.co/BAAI/bge-reranker-base">BGE-base</a></p></li><li><p><a href="https://huggingface.co/mixedbread-ai/mxbai-rerank-xsmall-v1">mxbai-rerank-xsmall-v1</a></p></li><li><p><a href="https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2">MiniLM-L-6-v2</a></p></li></ul><p></p><p>Retrieval</p><p>Reranking</p><p></p><p></p><p></p><p></p><p>Dataset</p><p>BM25 (%)</p><p>Cohere Rerank v2 (%)</p><p>Cohere Rerank v3 (%)</p><p>BGE-base (%)</p><p>mxbai-rerank-xsmall-v1 (%)</p><p>MiniLM-L-6-v2 (%)</p><p>Arguana</p><p>7.54</p><p>4.87</p><p>7.87</p><p>4.52</p><p>4.53</p><p>6.84</p><p>Climate-FEVER</p><p>5.75</p><p>6.24</p><p>8.15</p><p>9.36</p><p>7.79</p><p>7.58</p><p>DBPedia</p><p>61.18</p><p>60.78</p><p>64.15</p><p>63.9</p><p>63.5</p><p>67.62</p><p>FEVER</p><p>8.89</p><p>9.97</p><p>10.08</p><p>10.19</p><p>9.88</p><p>9.88</p><p>FiQa-2018</p><p>7.02</p><p>11.02</p><p>10.77</p><p>8.43</p><p>9.1</p><p>9.44</p><p>HotpotQA</p><p>12.59</p><p>14.5</p><p>14.76</p><p>15.1</p><p>14.02</p><p>14.42</p><p>Natural Questions</p><p>5.94</p><p>8.84</p><p>8.71</p><p>8.37</p><p>8.14</p><p>8.34</p><p>NFCorpus</p><p>31.67</p><p>32.9</p><p>33.91</p><p>30.63</p><p>32.77</p><p>32.45</p><p>Quora</p><p>12.2</p><p>10.46</p><p>13.04</p><p>11.26</p><p>12.58</p><p>12.78</p><p>SCIDOCS</p><p>8.62</p><p>9.41</p><p>9.71</p><p>8.04</p><p>8.79</p><p>8.52</p><p>Scifact</p><p>9.07</p><p>9.57</p><p>9.77</p><p>9.3</p><p>9.1</p><p>9.17</p><p>Touche2020</p><p>38.78</p><p>30.41</p><p>32.24</p><p>33.06</p><p>37.96</p><p>33.67</p><p>TREC-COVID</p><p>92.4</p><p>98.4</p><p>98.2</p><p>93.8</p><p>99.6</p><p>97.4</p><p>MSMARCO</p><p>3.97</p><p>6.00</p><p>6.03</p><p>6.07</p><p>5.47</p><p>6.11</p><p>CQADupstack (avg.)</p><p>5.47</p><p>6.32</p><p>6.87</p><p>5.89</p><p>6.22</p><p>6.16</p><p><strong>Table 2</strong>: Judge rate per (dataset, reranker) pairs calculated on the top-10 retrieved/reranked documents</p><p>From <strong>Table 2</strong>, with the exception of <code>TREC-COVID</code> (&gt;90% coverage), <code>DBPedia</code> (~65%), <code>Touche2020</code> and <code>nfcorpus</code> (~35%), we see that the majority of the datasets have a labeling rate between 5% and a little more than 10% after retrieval or reranking. This doesn’t mean that all these unmarked documents are relevant but there might be a subset of them -especially those placed in the top positions- that could be positive.</p><p>With the arrival of general purpose instruction tuned language models, we have a new powerful tool which can potentially automate judging relevance. These methods are typically far too computationally expensive to be used online for search, but here we are concerned with offline evaluation. In the following we use them to explore the evidence that some of the BEIR datasets suffer from shallow markup.</p><p>In order to further investigate this hypothesis we decided to focus on MSMARCO and select a subset of 100 queries along with the top-5 reranked (with Cohere v2) documents which are currently not marked as relevant. We followed two different paths of evaluation: First, we used a carefully tuned prompt (more on this in a later post) to prime the recently released <a href="https://huggingface.co/microsoft/Phi-3-mini-4k-instruct">Phi-3-mini-4k</a> model to predict the relevance (or not) of a document to the query. In parallel, these cases were also manually labeled in order to also assess the agreement rate between the LLM output and human judgment. Overall, we can draw the following two conclusions:</p><ul><li><p>The agreement rate between the LLM responses and human judgments was close to 80% which seems good enough as a starting point in that direction.</p></li><li><p>In 57.6% of the cases (based on human judgment) the returned documents were found to be actually relevant to the query. To state this in a different way: For 100 queries we have 107 documents judged to be relevant, but at least 0.576 x 5 x 100 = 288 extra documents which are actually relevant!</p></li></ul><p>Here, some examples drawn from the <code>MSMARCO</code>/<code>dev</code> dataset which contain the query, the annotated positive document (from <code>qrels</code>) and a false negative document due to incomplete markup:</p><p>Example 1:</p>{
  "query":
    {
        "_id": 155234,
        "text": "do bigger tires affect gas mileage"
    },
  "positive_doc":
    {
        "_id": 502713,
        "text": "Tire Width versus Gas Mileage. Tire width is one of the only tire size factors that can influence gas mileage in a positive way. For example, a narrow tire will have less wind resistance, rolling resistance, and weight; thus increasing gas mileage.",
    },
    "negative_doc":
    {
        "_id": 7073658,
        "text": "Tire Size and Width Influences Gas Mileage. There are two things to consider when thinking about tires and their effect on gas mileage; one is wind resistance, and the other is rolling resistance. When a car is driving at higher speeds, it experiences higher wind resistance; this means lower fuel economy."
    }
}
<p>Example 2:</p>{
  "query":
    {
        "_id": 300674,
        "text": "how many years did william bradford serve as governor of plymouth colony?"
    },
  "positive_doc":
    {
        "_id": 7067032,
        "text": "http://en.wikipedia.org/wiki/William_Bradford_(Plymouth_Colony_governor) William Bradford (c.1590 \u00e2\u0080\u0093 1657) was an English Separatist leader in Leiden, Holland and in Plymouth Colony was a signatory to the Mayflower Compact. He served as Plymouth Colony Governor five times covering about thirty years between 1621 and 1657."
    },
    "negative_doc":
    {
        "_id": 2495763,
        "text": "William Bradford was the governor of Plymouth Colony for 30 years. The colony was founded by people called Puritans. They were some of the first people from England to settle in what is now the United States. Bradford helped make Plymouth the first lasting colony in New England."
    }
}
<p>Manually evaluating specific queries like this is a generally useful technique for understanding search quality that complements quantitive measures like nDCG@10. If you have a representative set of queries you always run when you make changes to search, it gives you important qualitative information about how performance changes, which is invisible in the statistics. For example, it gives you much more insight into the false results your search returns: it can help you spot obvious howlers in retrieved results, classes of related mistakes, such as misinterpreting domain-specific terminology, and so on.</p><p>Our result is in agreement with relevant research around <code>MSMARCO</code> evaluation. For example, <a href="https://arxiv.org/pdf/2109.00062">Arabzadeh et al.</a> follow a similar procedure where they employ crowdsourced workers to make preference judgments: among other things, they show that in many cases the documents returned by the reranking modules are preferred compared to the documents in the MSMARCO <code>qrels</code> file. Another piece of evidence comes from the authors of the <a href="https://arxiv.org/pdf/2010.08191">RocketQA</a> reranker who report that more than 70% of the reranked documents were found relevant after manual inspection.</p><p> Update - September 9th: After a careful re-evaluation of the dataset we identified 15 more cases of relevant documents, increasing their total number from 273 to 288</p><h2>Main takeaways &amp; next steps</h2><ul><li><p>The pursuit for better ground truth is never-ending as it is very crucial for benchmarking and model comparison. LLMs can assist in some evaluation areas if used with caution and tuned with proper instructions</p></li><li><p>More generally, given that benchmarks will never be perfect, it might be preferable to switch from a pure score comparison to more robust techniques capturing statistically significant differences. The work of <a href="https://arxiv.org/pdf/2109.00062">Arabzadeh et al.</a> provides a nice of example of this where based on their findings they build 95% confidence intervals indicating significant (or not) differences between the various runs. In the accompanying <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/evaluating-search-relevance-part-1/retrieve-and-rerank.ipynb">notebook</a> we provide an implementation of confidence intervals using <a href="https://en.wikipedia.org/wiki/Bootstrapping_(statistics)">bootstrapping</a>.</p></li><li><p>From the end-user perspective it’s useful to think about task alignment when reading benchmark results. For example, for an AI engineer who builds a RAG pipeline and knows that the most typical use case involves assembling multiple pieces of information from different sources, then it would be more meaningful to assess the performance of their retrieval model on multi-hop QA datasets like HotpotQA instead of the global average across the whole BEIR benchmark</p></li></ul><p>In the <a href="https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-2">next blog post</a> we will dive deeper into the use of Phi-3 as LLM judge and the journey of tuning it to predict relevance.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-1</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/evaluating-search-relevance-part-1</guid>
    <category><![CDATA[ML Research]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Thanos Papaoikonomou,Thomas Veasey]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9d9912c8d4187096/6a1704f5b0367d30e672bc17/54a6e5197f5721b36fc65f27387d29803ed35589-721x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 16 Jul 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Intelligent RAG data chunking: Fetch surrounding chunks]]></title>
    <description><![CDATA[Learn about data chunking in RAG and explore fetch surrounding chunking, a pattern in RAG that uses chunking and Elasticsearch to refine LLM responses.]]></description>
    <content:encoded><![CDATA[<p>In the realm of Retrieval-Augmented Generation (RAG), one persistent challenge is finding the optimal amount of data to feed into a Large Language Model (LLM). Too little data results in insufficient or inaccurate responses, while too much data leads to vague answers. This delicate balance inspired me to develop a <a href="https://ela.st/fetch-surrounding-chunks">notebook</a> focusing on intelligent chunking and leveraging Elasticsearch vector database.</p><p>This blog builds on that notebook and explores fetch surrounding chunking, an emerging pattern in RAG that uses intelligent chunking and Elasticsearch vector database to optimize LLM responses. The approach balances data input to enhance the accuracy and relevance of LLM-generated answers through semantic hybrid search.</p><h2>The motivation: A refined approach to RAG data chunking</h2><p>The primary motivation behind building <a href="https://ela.st/fetch-surrounding-chunks">this notebook</a> was to demonstrate a refined approach to RAG by addressing the challenge of data chunking. Traditional methods often fall short in dynamically adjusting the data size fed to LLMs, either overwhelming the model with too much context or starving it with too little. This notebook aims to strike the right balance, providing just enough information for the LLM to generate precise and contextually relevant responses. However, it must be noted that there is no one-size-fits-all solution.</p><p>This method works especially well with books and similar texts where content flows within longer sections or chapters. However, it may require adaptation for texts structured into shorter, distinct sections, such as research papers or articles, where each segment might cover a different topic. In such cases, additional strategies may be necessary to effectively chunk and retrieve related content.</p><h2>The methodology: Intelligent RAG data chunking</h2><h3>Fetch surrounding chunks</h3><p>The core idea is to partition the source text into manageable chunks, ensuring each chunk contains just the right amount of information. For this demonstration, I used text from "Harry Potter and the Sorcerer's Stone." The text was partitioned into chapters, and each chapter was further divided into smaller chunks. These chunks, along with their dense and sparse (ELSER) vector representations, were indexed in the Elasticsearch vector database.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt6bcc905a1653ca3a/6a1711357d8d670d9970e846/23b210ce29f47f8a872d300ef01fca901d1e80ab-1163x548.png" alt="architecture" /><h3>Assigning numbers to chunks</h3><p>Each chunk within a chapter was assigned a sequential integer, allowing us to identify its position. When a matching chunk is found, the chapter number and chunk number are used to retrieve surrounding chunks, providing additional context for the LLM.</p><h3>Vector database in Elasticsearch</h3><p>These chunks and their vector representations were ingested into an Elasticsearch Cloud instance. Elasticsearch's robust vector search capabilities make it ideal for hosting these chunks, allowing for efficient retrieval of the most relevant chunks based on the semantic content or text match of a user's query.</p><h3>AI search</h3><p>To retrieve the relevant chunks, I employed a hybrid search strategy using dense vector comparisons, sparse vector comparisons, and text search in parallel. This multi-faceted approach ensures that the search results are both semantically rich and contextually accurate. A query is issued to find the matched chunk, which returns the chunk number and chapter. Surrounding chunks for that chapter are then fetched based on the matched chunk.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte879d45ec9558b9e/6a171137b339d50e9776a0be/62d3cb6d9cddcecda359bc1fd808b52cd0f23864-1440x778.png" alt="architecture" /><h2>The RAG pattern</h2><p>When a query is made, the search flow performs the following steps:</p><ol><li><p><strong>Query analysis:</strong> The user's query is translated into dense and sparse vectors to retrieve the most relevant chunks from the Elasticsearch index.</p></li><li><p><strong>Chunk retrieval:</strong> Using the AI search strategy, the system retrieves the top relevant chunks.</p></li><li><p><strong>Contextual expansion:</strong> Adjacent chunks (n-1 and n+1) are also retrieved to provide a more comprehensive context. If the chunk is the last in the chapter, it fetches n-1 and n-2; if it's the first, it fetches n+1 and n+2.</p></li><li><p><strong>LLM response:</strong> These intelligently selected chunks are then fed into the LLM, ensuring it receives the optimal amount of information to generate a precise and contextually relevant response.</p></li></ol><h2>Why intelligent RAG data chunking matters</h2><p>This approach addresses a critical aspect of RAG by optimizing the input data fed to LLMs. By leveraging intelligent chunking and hybrid semantic search, this method enhances the accuracy and relevance of the responses generated by LLMs. It showcases a pattern that can be widely applied in various applications within the RAG space, from customer support to content generation and beyond.</p><h2>Conclusion</h2><p><a href="https://ela.st/fetch-surrounding-chunks">This notebook</a> underscores the importance of intelligent data chunking in the RAG framework and demonstrates how Elasticsearch vector database can be leveraged to achieve optimal results. By ensuring the LLM receives just the right amount of information, this methodology paves the way for more accurate and contextually rich responses, enhancing the overall effectiveness of RAG systems.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/advanced-chunking-fetch-surrounding-chunks</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/advanced-chunking-fetch-surrounding-chunks</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Sunile Manjee]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt17ba5b693b94a883/6a171139acf0880723be9c49/4467ccd71baaae7422b9b5df9a8612eec4af1bd2-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Tue, 11 Jun 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Keeping your Elasticsearch index current with Python and Google Cloud Platform Functions]]></title>
    <description><![CDATA[Keep your Elasticsearch index updated with Python &amp; Google Cloud Functions. Follow these steps to automatically update an index when new data is present.]]></description>
    <content:encoded><![CDATA[<h2>Background</h2><p>An <a href="https://www.elastic.co/blog/what-is-an-elasticsearch-index">index</a> inside Elasticsearch is where you can store your data in documents. While working with an index, the data can quickly grow old if you are working with a dynamic dataset. To avoid this issue, you can create a Python script to update your index and deploy it using <a href="https://cloud.google.com/">Google Cloud Platform's</a> (GCP) <a href="https://cloud.google.com/functions/docs#docs">Cloud Functions </a>and <a href="https://cloud.google.com/scheduler/docs">Cloud Scheduler</a> in order to keep your index up-to-date automatically.</p><p>To keep your index current, you can first set up a Jupyter Notebook to test locally and create a framework of a script that will update your index if new information is present. You can adjust your script to make it more reusable and run it as a Cloud Function. With Cloud Scheduler, you can set the code in your Cloud Function to run on a schedule using a cron-type format.</p><h2>Prerequisites for automating index updates</h2><ul><li><p>This example uses Elasticsearch version 8.12; if you are new, check out our Quick Start on <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started.html">Elasticsearch</a>.</p></li><li><p>Download the latest version of Python if you don't have it installed on your machine. This example utilizes Python 3.12.1.</p></li><li><p><a href="https://api.nasa.gov/">An API key</a> for NASA's APIs.</p></li><li><p>You will use the <a href="https://requests.readthedocs.io/en/latest/">Requests</a> package to connect to a NASA API, <a href="https://pandas.pydata.org/">Pandas</a> to manipulate data, the <a href="https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/getting-started-python.html">Elasticsearch Python Client</a> to load data into an index and keep it up to date, and <a href="https://docs.jupyter.org/en/latest/">Jupyter Notebooks</a> to work with your data interactively while testing. You can run the following line to install these required packages:</p></li></ul>pip3 install requests pandas elasticsearch notebook
<h2>Loading and updating your dataset</h2><p>Before you can run your update script inside of GCP, you will want to upload your data and test the process you will use to keep your script updated. You will first connect to data from an API, save it as a Pandas DataFrame, connect to Elasticsearch, upload the DataFrame into an index, check to see when the index is last updated, and update it if new data is available. You can find the complete code of this section in <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/keeping-your-index-current/local_testing.ipynb">this search labs notebook</a>.</p><h3>Loading your data</h3><p>Let's start testing locally with a Jupyter Notebook to work with your data interactively. To do so, you can run the following in your terminal.</p>jupyter notebook
<p>In the right-hand corner, you can select where it says “New” to create a new Jupyter Notebook.</p><p>First, you will need to import the packages you will be using. You will import all the packages you installed earlier, plus <code>getpass</code> to work with secrets such as API keys and <code>datetime</code> to work with date objects.</p>import requests
from getpass import getpass
import pandas as pd
from datetime import datetime, timedelta
from elasticsearch import Elasticsearch, helpers
<p>The dataset you will use is<a href="https://data.nasa.gov/Space-Science/Asteroids-NeoWs-API/73uw-d9i8/about_data"> Near Earth Object Web Service (NeoWs)</a>, a RESTful web service that provides near-earth Asteroid information. This dataset lets you search for asteroids based on their closest approach date to Earth, look up a specific asteroid, and browse the overall dataset.</p><p>With the following function, you can connect to NASA's NeoWs API, get data from the past week, and convert your response to a JSON object.</p>def connect_to_nasa():
    url = "https://api.nasa.gov/neo/rest/v1/feed"
    nasa_api_key = getpass("NASA API Key: ")
    today = datetime.now()
    params = {
        "api_key": nasa_api_key,
        "start_date": today - timedelta(days=7),
        "end_date": datetime.now(),
    }
    return requests.get(url, params).json()
<p>Now, you can save the results of your API call to a variable called response.</p>response = connect_to_nasa()
<p>To convert the JSON object into a pandas DataFrame, you must normalize the nested objects into one DataFrame and drop the column containing the nested JSON.</p>def create_df(response):
    all_objects = []
    for date, objects in response["near_earth_objects"].items():
        for obj in objects:
            obj["close_approach_date"] = date
            all_objects.append(obj)
    df = pd.json_normalize(all_objects)
    return df.drop("close_approach_data", axis=1)
<p>To call this function and view the first five rows of your dataset, you can run the following:</p>df = create_df(response)
df.head()
<h3>Connecting to Elasticsearch</h3><p>You can access Elasticsearch from the Python Client by providing your Elastic Cloud ID and API key for authentication.</p>def connect_to_elastic():
    elastic_cloud_id = getpass("Elastic Cloud ID: ")
    elastic_api_key = getpass("Elastic API Key: ")
    return Elasticsearch(cloud_id=elastic_cloud_id, api_key=elastic_api_key)
<p>Now, you can save the results of your connection function to a variable called <code>es</code>.</p>es = connect_to_elastic()
<p>An index in Elasticsearch is the main container for your data. You can name your index called <code>asteroid_data_set</code>.</p>index_name = "asteroid_data_set"
es.indices.create(index=index_name)
<p>The result you get back will look like the following:</p>ObjectApiResponse({'acknowledged': True, 'shards_acknowledged': True, 'index': 'asteroids_data'})
<p>Now, you can create a helper function that will allow you to convert your DataFrame to the correct format to upload into your index.</p>def doc_generator(df, index_name):
    for index, document in df.iterrows():
        yield {
            "_index": index_name,
            "_id": f"{document['id']}",
            "_source": document.to_dict(),
        }
<p>Next, you can bulk upload the contents of your DataFrame into Elastic, calling the helper function you just created.</p>helpers.bulk(es, doc_generator(df, index_name))
<p>You should get a result that looks similar to the following, which tells you how many rows you’ve uploaded:</p>(146, [])
<h3>When was the last time you updated your data?</h3><p>Once you've uploaded data into Elastic, you can check the last time your index was updated and format the date so it can work with NASA API.</p>def updated_last(es, index_name):
    query = {
        "size": 0,
        "aggs": {"last_date": {"max": {"field": "close_approach_date"}}},
    }
    response = es.search(index=index_name, body=query)
    last_updated_date_string = response["aggregations"]["last_date"]["value_as_string"]
    datetime_obj = datetime.strptime(last_updated_date_string, "%Y-%m-%dT%H:%M:%S.%fZ")
    return datetime_obj.strftime("%Y-%m-%d")
<p>You can save the date your index was last updated to a variable and print out the date.</p>last_update_date = updated_last(es, index_name)
print(last_update_date)
<h3>Updating your data</h3><p>Now, you can create a function that checks to see if there is any new data since the last time the index was updated and the current date. If the object is valid and the data is not empty, it will update the index and let you know if there is no new data to update or if the DataFrame returns a type of <code>None</code> indicating that there may have been a problem.</p>def update_new_data(df, es, last_update_date, index_name):
    if isinstance(last_update_date, str):
        last_update_date = datetime.strptime(last_update_date, "%Y-%m-%d")

    last_update_date = pd.Timestamp(last_update_date).normalize()

    if not df.empty and "close_approach_date" in df.columns:
        df["close_approach_date"] = pd.to_datetime(df["close_approach_date"])

    today = pd.Timestamp(datetime.now().date()).normalize()

    if df is not None and not df.empty:
        update_range = df.loc[
            (df["close_approach_date"] &gt; last_update_date)
            &amp; (df["close_approach_date"] &lt; today)
        ]
        if not update_range.empty:
            helpers.bulk(es, doc_generator(update_range, index_name))
        else:
            print("No new data to update.")
    else:
        print("The DataFrame is None.")
<p>If the DataFrame is a valid object, it will call the function you wrote and update the index if applicable. It will also print out the date of the index's last update to help you debug if needed. If not, it will tell you there may be a problem.</p>try:
    if df is None:
        raise ValueError("DataFrame is None. There may be a problem.")
    update_new_data(df, es, last_update_date, index_name)
    print(updated_last(es, index_name))
except Exception as e:
    print(f"An error occurred: {e}")
<h2>Keeping your index current</h2><p>Now that you've created a framework for local testing, you are ready to set up an environment where you can run your script daily to check to see if any new data is available and update your index accordingly.</p><h3>Creating a Cloud Function</h3><p>You are now ready to deploy your Cloud Function. To do so, you will want to select the environment as a 2nd gen function, name your function, and select a cloud region. You can also tie it to a Cloud Pub/Sub trigger and choose to create a new topic if you haven't made it already. You can check out the <a href="https://github.com/JessicaGarson/Keeping-Your-Elasticsearch-Index-Current">complete code for this section on GitHub</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd01cb110679df057/6a170b30ab7f084f76db9e9a/5e5548faac7d7ed20166e853db9a81d4ed51d60b-1116x1188.jpg" alt="" /><h3>Creating a Pub/Sub topic</h3><p>When creating a new topic, you can name your topic ID and select the encryption using a Google-managed encryption key.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt93a679b113dc8eee/6a170b321949f751eee7aa3e/5a32c2efaf32f496fa201d3c3e7f97de29b59f66-1114x874.jpg" alt="" /><h3>Setting your Cloud Function's environment variables</h3><p>Under where it says “Runtime environment variables,” you can add in the environment variables for your <code>NASA_API_KEY,</code> <code>ELASTIC_CLOUD_ID</code>, and <code>ELASTIC_API_KEY.</code> You will want to save these as the raw values without single quotes around them. So if you entered a value of <code>'xxxxlsdgzxxxxx'</code> into your terminal earlier, you would want it to be <code>xxxxlsdgzxxxxx</code>.</p><h3>Adjusting your code and adding it to your Cloud Function</h3><p>After you enter your environment variables, you can press the button that says next, which will take you to a code editor. You will want to select the runtime of Python 3.12.1 or match the version of Python you are using. After that, update the entry point to <code>update_index</code>. The entry point serves a similar role to the main function in Python.</p><p>Instead of using <code>getpass</code> to retrieve secrets, you will want to use <code>os</code> to perform a more automated process. An example will look like the following:</p>elastic_cloud_id = os.getenv("ELASTIC_CLOUD_ID")
elastic_api_key = os.getenv("ELASTIC_API_KEY")
<p>You will want to adjust the order of your script to have the function that connects to Elasticsearch first. Afterward, you will want to know when your index was last updated, connect to the NASA API you are using, save it to DataFrame, and load any new data that might be available.</p><p>You may notice a new function at the bottom called <code>update_index</code> that ties your code together. In this function, you define the name of your index, connect to Elastic, figure out the last date the index was updated, connect to the NASA API, save the results into a data frame, and update the index if needed. To indicate the entry point function is a cloud event, you can denote it with the decorator <code>@functions_framework.cloud_event</code>.</p>@functions_framework.cloud_event
def update_index(cloud_event):
    index_name = "asteroid_data_set"
    es = connect_to_elastic()
    last_update_date = updated_last(es, index_name)
    print(last_update_date)
    response = connect_to_nasa(last_update_date)
    df = create_df(response)
    if df is not None:
      update_new_data(df, es, last_update_date, index_name)
      print(updated_last(es, index_name)) 
    else:
      print("No new data was retrieved.")
<p>Here is the full updated code sample:</p>import functions_framework
import requests
import os
import pandas as pd
from datetime import datetime
from elasticsearch import Elasticsearch, helpers


def connect_to_elastic():
    elastic_cloud_id = os.getenv("ELASTIC_CLOUD_ID")
    elastic_api_key = os.getenv("ELASTIC_API_KEY")
    return Elasticsearch(cloud_id=elastic_cloud_id, api_key=elastic_api_key)


def connect_to_nasa(last_update_date):
    url = "https://api.nasa.gov/neo/rest/v1/feed"
    nasa_api_key = os.getenv("NASA_API_KEY")
    params = {
        "api_key": nasa_api_key,
        "start_date": last_update_date,
        "end_date": datetime.now(),
    }
    return requests.get(url, params).json()


def create_df(response):
    all_objects = []
    for date, objects in response["near_earth_objects"].items():
        for obj in objects:
            obj["close_approach_date"] = date
            all_objects.append(obj)
    df = pd.json_normalize(all_objects)
    return df.drop("close_approach_data", axis=1)


def doc_generator(df, index_name):
    for index, document in df.iterrows():
        yield {
            "_index": index_name,
            "_id": f"{document['close_approach_date']}",
            "_source": document.to_dict(),
        }


def updated_last(es, index_name):
    query = {
        "size": 0,
        "aggs": {"last_date": {"max": {"field": "close_approach_date"}}},
    }
    response = es.search(index=index_name, body=query)
    last_updated_date_string = response["aggregations"]["last_date"]["value_as_string"]
    datetime_obj = datetime.strptime(last_updated_date_string, "%Y-%m-%dT%H:%M:%S.%fZ")
    return datetime_obj.strftime("%Y-%m-%d")


def update_new_data(df, es, last_update_date, index_name):
    if isinstance(last_update_date, str):
        last_update_date = datetime.strptime(last_update_date, "%Y-%m-%d")

    last_update_date = pd.Timestamp(last_update_date).normalize()

    if not df.empty and "close_approach_date" in df.columns:
        df["close_approach_date"] = pd.to_datetime(df["close_approach_date"])

    today = pd.Timestamp(datetime.now().date()).normalize()

    if df is not None and not df.empty:
        update_range = df.loc[
            (df["close_approach_date"] &gt; last_update_date)
            &amp; (df["close_approach_date"] &lt; today)
        ]
        print(update_range)
        if not update_range.empty:
            helpers.bulk(es, doc_generator(update_range, index_name))
        else:
            print("No new data to update.")
    else:
        print("The DataFrame is empty or None.")


# Triggered from a message on a Cloud Pub/Sub topic.
@functions_framework.cloud_event
def hello_pubsub(cloud_event):
    index_name = "asteroid_data_set"
    es = connect_to_elastic()
    last_update_date = updated_last(es, index_name)
    print(last_update_date)
    response = connect_to_nasa(last_update_date)
    df = create_df(response)
    try:
        if df is None:
            raise ValueError("DataFrame is None. There may be a problem.")
        update_new_data(df, es, last_update_date, index_name)
        print(updated_last(es, index_name))
    except Exception as e:
        print(f"An error occurred: {e}")
<h3>Adding a requirements.txt file</h3><p>You will also want to define a <code>requirements.txt</code> file with all the specified packages needed to run the code.</p>functions-framework==3.*
requests==2.31.0
elasticsearch==8.12.0
pandas==2.1.4
<h3>Scheduling your Cloud Function</h3><p>In Cloud Scheduler, you can set up your function to run at a regular interval using unix cron format. I have the code set to run every morning at 8 am in my timezone.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt031810158f88fd08/6a170b34a929cf4718ae09d0/a6b9c490a804389ec15366ff96adb9c992cb561a-1160x1014.jpg" alt="" /><p>You will also want to configure the execution to connect to the Pub/Sub topic you created previously. I currently have the message body set to say “hello.”</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte5c6c0061dc955fc/6a170b35b0367d9d5672bd37/b12a4a782976edbf9b6a65992f0d3d73df2d15c0-1116x518.jpg" alt="" /><p>Now that you have set up your Pub/Sub topic and your Cloud Function and set that Cloud Function to run on a schedule, your index should automatically update whenever new data is present.</p><h2>Conclusion</h2><p>Using Python, Google Cloud Platform Functions, and Google Cloud Scheduler you should be able to ensure that your index is updated regularly. You can find the complete code <a href="https://github.com/JessicaGarson/Keeping-Your-Elasticsearch-Index-Current">here</a> and <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/keeping-your-index-current/local_testing.ipynb">the search labs notebook for local testing</a>. We are also running an on-demand webinar with <a href="https://www.elastic.co/virtual-events/architecting-search-apps-on-google-cloud">Google Cloud</a> which might be a good next step if you are looking to build search apps. Let us know if you built anything based on this blog or if you have questions on our <a href="https://discuss.elastic.co/">Discuss forums</a> and <a href="https://communityinviter.com/apps/elasticstack/elastic-community">the community Slack channel</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/keeping-your-elasticsearch-index-current-with-python-and-google-cloud-platform-functions</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/keeping-your-elasticsearch-index-current-with-python-and-google-cloud-platform-functions</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Basics]]></category>
    <dc:creator><![CDATA[Jessica Garson]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt315cf663d39ca57f/6a170b3760084b95c23c4576/b839822139ab0769a7fcf1d62102c984af87bf0d-1440x954.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 13 Mar 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI plagiarism: Plagiarism detection with Elasticsearch]]></title>
    <description><![CDATA[Here's how to check for AI plagiarism using Elasticsearch, focusing on use cases with NLP models and Vector Search.]]></description>
    <content:encoded><![CDATA[<p>Plagiarism can be <strong>direct</strong>, involving the copying of parts or the entire content, or <strong>paraphrased</strong>, where the author's work is rephrased by changing some words or phrases.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb6e139760e56ec98/6a171147dc55de0ad2e00edf/5d7073187fda829438aeec8d3a1194a5bea2ba57-1440x347.png" alt="" /><p>There is a distinction between inspiration and paraphrasing. It is possible to read a content, get inspired, and then explore the idea with your own words, even if you come to a similar conclusion.</p><p>While plagiarism has been a topic of discussion for a long time, the accelerated production and publication of content have kept it relevant and posed an ongoing challenge.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc2af1678cb04506b/6a171149cf4f251c2ab2d257/a0b9a98d729db09dae0a79315c001e6763c12704-1400x1016.png" alt="" /><p>This challenge isn't limited to books, academic research, or judicial documents, where plagiarism checks are frequently conducted. It can also extend to newspapers and even social media.</p><p>With the abundance of information and easy access to publishing, how can plagiarism be effectively checked on a scalable level?</p><p>Universities, government entities, and companies employ diverse tools, but while a straightforward <a href="https://www.elastic.co/search-labs/lexical-and-semantic-search-with-elasticsearch">lexical search</a> can effectively detect direct plagiarism, the primary challenge lies in identifying <strong>paraphrased content.</strong></p><h2>Plagiarism detection with Generative AI</h2><p>A new challenge emerges with Generative AI. Is content generated by AI considered plagiarism when copied?</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8a1ed1b6fa3f1a56/6a17114b4a531bc40736aa69/9345b28d6d27c37469bc38e823c41780b4eabfe5-1440x875.png" alt="" /><p>The <a href="https://openai.com/">OpenAI</a> <a href="https://openai.com/policies/terms-of-use">terms of use</a>, for example, specify that OpenAI will not claim copyright over content generated by the API for users. In this case, individuals using their Generative AI can use the generated content as they prefer without citation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbc2e08ab5b808e38/6a17114dab7f086cbadb9f93/e1f415f69a247666f02ddc81468944920c874cd7-968x814.png" alt="" /><p>However, the acceptance of using Generative AI to improve efficiency remains a topic of discussion.</p><p>In an effort to contribute to plagiarism detection, OpenAI developed a <a href="https://huggingface.co/roberta-base-openai-detector">detection model</a> but later acknowledged that its accuracy is not sufficiently high.</p><p><em>"We believe this is not high enough accuracy for standalone detection and needs to be paired with metadata-based approaches, human judgment, and public education to be more effective."</em></p><p>The challenge persists; however, with the availability of more tools, there are now increased options for detecting plagiarism, even in cases of paraphrased and AI content.</p><h2>Detecting plagiarism with Elasticsearch</h2><p>Recognizing this, in this blog we are exploring one more use case with Natural Language Processing (NLP) models and Vector Search, plagiarism detection, beyond metadata searches.</p><p>This is demonstrated with <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/plagiarism-detection-with-elasticsearch/plagiarism_detection_es.ipynb">Python examples</a>, where we utilize a <a href="https://sbert.net/datasets/emnlp2016-2018.json">dataset</a> from <a href="https://www.sbert.net/">SentenceTransformers</a> containing NLP-related articles. We check the abstracts for plagiarism by performing 'semantic textual similarity' considering 'abstract' embeddings generated with a <a href="https://huggingface.co/sentence-transformers/all-mpnet-base-v2">text embedding model</a> previously imported into Elasticsearch. Additionally, to identify AI-generated content — AI plagiarism, an <a href="https://huggingface.co/roberta-base-openai-detector">NLP model</a> developed by OpenAI was also imported into Elasticsearch.</p><p>The following image illustrates the data flow:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt0464f88d3ed12070/6a17114fab7f084905db9f97/1ad89c98a2f42a497548ca3947749bad54ec1172-1440x880.png" alt="" /><p>During the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html">ingest pipeline</a> with an <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/inference-processor.html">inference processor</a>, the 'abstract' paragraph is mapped to a 768-dimensional vector, the 'abstract_vector.predicted_value'.</p><p>Mapping:</p>"abstract_vector.predicted_value": { # Inference results field
"type": "dense_vector", 
"dims": 768, # model embedding_size
"index": "true", 
"similarity": "dot_product" # When indexing vectors for approximate kNN search, you need to specify the similarity function for comparing the vectors.
<p>The similarity between vector representations is measured using a vector similarity metric, defined using the 'similarity' <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/dense-vector.html#dense-vector-params">parameter</a>.</p><p><a href="https://en.wikipedia.org/wiki/Cosine_similarity">Cosine</a> is the default similarity metric, computed as '(1 + cosine(query, vector)) / 2'. Unless you need to preserve the original vectors and cannot normalize them in advance, the most efficient way to perform cosine similarity is to normalize all vectors to unit length. This helps avoid performing extra vector length computations during the search, instead use 'dot_product'.</p><p>In this same pipeline, another inference processor containing the <a href="https://huggingface.co/roberta-base-openai-detector">text classification model</a> detects whether the content is 'Real' probably written by humans, or 'Fake' probably written by AI, adding the 'openai-detector.predicted_value' to each document.</p><p>Ingest Pipeline:</p>client.ingest.put_pipeline( 
    id="plagiarism-checker-pipeline",
    processors = [
    {
      "inference": { #for ml models - to infer against the data that is being ingested in the pipeline
        "model_id": "roberta-base-openai-detector", #text classification model id
        "target_field": "openai-detector", # Target field for the inference results
        "field_map": { #Maps the document field names to the known field names of the model.
        "abstract": "text_field" # Field matching our configured trained model input. 
        }
      }
    },
    {
      "inference": {
        "model_id": "sentence-transformers__all-mpnet-base-v2", #text embedding model id
        "target_field": "abstract_vector", # Target field for the inference results
        "field_map": {
        "abstract": "text_field" # Field matching our configured trained model input. Typically for NLP models, the field name is text_field.
        }
      }
    }
    
  ]
)
<p>At query time, the same text embedding model is also employed to generate the vector representation of the query 'model_text' in a 'query_vector_builder' object.</p><p>A k-nearest neighbor (kNN) search finds the k nearest vector to the query vector measured by the similarity metric.</p><p>The _score of each document is derived from the similarity, ensuring that a larger score corresponds to a higher ranking. This means that the document is more similar semantically. As a result, we are printing three possibilities: if score &gt; 0.9, we are considering 'high similarity'; if &lt; 0.7, 'low similarity’, otherwise, 'moderate similarity’. You have the flexibility to set different threshold values to determine what level of _score qualifies as plagiarism or not, based on your use case.</p><p>Additionally, text classification is performed to also check for AI-generated elements in the text query.</p><p>Query:</p>from elasticsearch import Elasticsearch
from elasticsearch.client import MlClient

#duplicated text - direct plagiarism test

model_text = 'Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at http://hucvl.github.io/recipeqa.'

response = client.search(index='plagiarism-checker', size=1,
    knn={
        "field": "abstract_vector.predicted_value",
        "k": 9,
        "num_candidates": 974,
        "query_vector_builder": { #The 'all-mpnet-base-v2' model is also employed to generate the vector representation of the query in a 'query_vector_builder' object.
            "text_embedding": {
                "model_id": "sentence-transformers__all-mpnet-base-v2",
                "model_text": model_text
            }
        }
    }
)

for hit in response['hits']['hits']:
    score = hit['_score']
    title = hit['_source']['title']
    abstract = hit['_source']['abstract']
    openai = hit['_source']['openai-detector']['predicted_value']
    url = hit['_source']['url']

    if score &gt; 0.9:
        print(f"\nHigh similarity detected! This might be plagiarism.")
        print(f"\nMost similar document: '{title}'\n\nAbstract: {abstract}\n\nurl: {url}\n\nScore:{score}\n\n")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

    elif score &lt; 0.7:
        print(f"\nLow similarity detected. This might not be plagiarism.")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

    else:
        print(f"\nModerate similarity detected.")
        print(f"\nMost similar document: '{title}'\n\nAbstract: {abstract}\n\nurl: {url}\n\nScore:{score}\n\n")

        if openai == 'Fake':
            print("This document may have been created by AI.\n")

ml_client = MlClient(client)

model_id = 'roberta-base-openai-detector' #open ai text classification model

document = [
    {
        "text_field": model_text
    }
]

ml_response = ml_client.infer_trained_model(model_id=model_id, docs=document)

predicted_value = ml_response['inference_results'][0]['predicted_value']

if predicted_value == 'Fake':
    print("\nNote: The text query you entered may have been generated by AI.\n")
<p>Output:</p>High similarity detected! This might be plagiarism.

Most similar document: 'RecipeQA: A Challenge Dataset for Multimodal Comprehension of Cooking Recipes'

Abstract: Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at[ http://hucvl.github.io/recipeqa](http://hucvl.github.io/recipeqa).

url:[http://aclweb.org/anthology/D18-1166](http://aclweb.org/anthology/D18-1166)

Score:1.0
<p>In this example, after utilizing one of the 'abstract' values from our dataset as the text query 'model_text', plagiarism was identified. The similarity score is 1.0, indicating a high level of similarity — <strong>direct plagiarism</strong>. The vectorized query and document were not recognized as AI-generated content, which was expected.</p><p>Query:</p>#similar text - paraphrase plagiarism test 

model_text = 'Comprehending and deducing information from culinary instructions represents a promising avenue for research aimed at empowering artificial intelligence to decipher step-by-step text. In this study, we present CuisineInquiry, a database for the multifaceted understanding of cooking guidelines. It encompasses a substantial number of informative recipes featuring various elements such as headings, explanations, and a matched assortment of visuals. Utilizing an extensive set of automatically crafted question-answer pairings, we formulate a series of tasks focusing on understanding and logic that necessitate a combined interpretation of visuals and written content. This involves capturing the sequential progression of events and extracting meaning from procedural expertise. Our initial findings suggest that CuisineInquiry is poised to function as a demanding experimental platform.'
<p>Output:</p>High similarity detected! This might be plagiarism.

Most similar document: 'RecipeQA: A Challenge Dataset for Multimodal Comprehension of Cooking Recipes'

Abstract: Understanding and reasoning about cooking recipes is a fruitful research direction towards enabling machines to interpret procedural text. In this work, we introduce RecipeQA, a dataset for multimodal comprehension of cooking recipes. It comprises of approximately 20K instructional recipes with multiple modalities such as titles, descriptions and aligned set of images. With over 36K automatically generated question-answer pairs, we design a set of comprehension and reasoning tasks that require joint understanding of images and text, capturing the temporal flow of events and making sense of procedural knowledge. Our preliminary results indicate that RecipeQA will serve as a challenging test bed and an ideal benchmark for evaluating machine comprehension systems. The data and leaderboard are available at[ http://hucvl.github.io/recipeqa](http://hucvl.github.io/recipeqa).

url:[http://aclweb.org/anthology/D18-1166](http://aclweb.org/anthology/D18-1166)

Score:0.9302529

Note: The text query you entered may have been generated by AI.
<p>By updating the text query 'model_text' with an AI-generated text that conveys the same message while minimizing the repetition of similar words, the detected similarity was still high, but the score was 0.9302529 instead of 1.0 — <strong>paraphrase plagiarism</strong>. It was also expected that this query, which was generated by AI, would be detected.</p><p>Lastly, considering the text query 'model_text' as a text about Elasticsearch, which is not an abstract of one of these documents, the detected similarity was 0.68991005, indicating low similarity according to the considered threshold values.</p><p>Query:</p>#different text - not a plagiarism

model_text = 'Elasticsearch provides near real-time search and analytics for all types of data.'
<p>Output:</p>Low similarity detected. This might not be plagiarism.
<p>Although plagiarism was accurately identified in the text query generated by AI, as well as in cases of paraphrasing and direct copied content, navigating the landscape of plagiarism detection involves acknowledging various aspects.</p><p>In the context of AI-generated content detection, we explored a model that makes a valuable contribution. However, it is crucial to recognize the inherent limitations in standalone detection, necessitating the incorporation of other methods to boost the accuracy.</p><p>The variability introduced by the choice of text embedding models is another consideration. Different models, trained with distinct datasets, result in varying levels of similarity, highlighting the importance of the text embeddings generated.</p><p>Lastly, in these examples, we used the document's abstract. However, plagiarism detection often involves large documents, making it essential to address the challenge of text length. It is common for the text to exceed a model's token limit, requiring segmentation into chunks before building embeddings. A <a href="https://www.elastic.co/guide/en/elasticsearch/reference/8.11/knn-search.html#nested-knn-search">practical approach</a> to handling this involves utilizing nested structures with dense_vector.</p><h2>Conclusion</h2><p>In this blog, we discussed the challenges of detecting plagiarism, particularly in paraphrased and AI-generated content, and how semantic textual similarity and text classification can be used for this purpose.</p><p>By combining these methods, we provided an example of plagiarism detection where we successfully identified AI-generated content, direct and paraphrased plagiarism.</p><p>The primary goal was to establish a filtering system that simplifies detection but human assessment remains essential for validation.</p><p>If you are interested in learning more about semantic textual similarity and NLP, we encourage you to also check out these links:</p><ul><li><p><a href="https://www.elastic.co/what-is/semantic-search">What is semantic search?</a></p></li><li><p><a href="https://www.elastic.co/what-is/natural-language-processing">What is natural language processing (NLP)?</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/lexical-and-semantic-search-with-elasticsearch">Lexical and Semantic Search with Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/search-labs/blog/chunking-via-ingest-pipelines">Chunking Large Documents via Ingest pipelines plus nested vectors equals easy passage search</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-plagiarism-checker-with-elasticsearch</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-plagiarism-checker-with-elasticsearch</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Priscilla Parodi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt68a5bc2434a9b03b/6a1711510e2e49a09641a22a/83e05cd4f81799fbb7b7950ed87600e825ec81e9-1024x1024.png" length="0" type="image/png"/>
    <pubDate>Tue, 19 Dec 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: A plugin to use ChatGPT with your Elastic data]]></title>
    <description><![CDATA[Learn how to implement a plugin and enable ChatGPT users to extend ChatGPT with any content indexed in Elasticsearch, using the Elastic documentation.]]></description>
    <content:encoded><![CDATA[<p>Update: April 16th, 2024</p><p>OpenAI has discontinued the use of plugins in ChatGPT. You can read more about this <a href="https://help.openai.com/en/articles/8988022-winding-down-the-chatgpt-plugins-beta">here</a>. We recommend reading <a href="https://www.elastic.co/search-labs/tutorials/chatbot-tutorial/welcome">this</a> tutorial instead to learn how to build a large language model (LLM) chatbot that uses a pattern known as <a href="https://www.elastic.co/what-is/retrieval-augmented-generation">Retrieval-Augmented Generation</a>. You can also read <a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-creating-custom-gpts-with-elastic-data">this</a> blog to learn how to create custom GPTs with Elastic data.</p><p>You may have read this <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">previous blog post</a> about our journey to connect Elasticsearch’s relevance capabilities with OpenAI question-answering capabilities. The key idea in that post was to illustrate how to use Elastic with OpenAI’s GPT model to build a response and return context-relevant content to users.</p><p>The application that we built can expose a search endpoint and be called by any front-end service. The good news is that now OpenAI has released a private alpha of the future <a href="https://openai.com/blog/chatgpt-plugins">ChatGPT plugin framework</a>.</p><p>In this blog, you will learn how to implement the plugin and extend the use of ChatGPT to any content indexed in Elasticsearch, using the Elastic documentation.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4c29f4ce816ee08d/6a1711cb66c4f932b5f8c143/67a68ec5eee1b81462e0adeef41d5963054ec65e-1440x1239.png" alt="summarize transaction sampling" /><h2>What is a ChatGPT plugin?</h2><p><a href="https://openai.com/blog/chatgpt-plugins">ChatGPT plugins</a> are extensions that are developed to assist the model in completing its knowledge or executing actions.</p><p>For example, we know that the cutover of ChatGPT from a knowledge perspective is September 2021, so any question on recent data won’t be answered. In addition, any question that relates to something too specific beyond the boundaries of what the model has been trained on won’t be answered.</p><p>Plugins can broaden the scope of possible applications and enhance the capabilities of the models, but reciprocally, the plugin's output is augmented by the model itself.</p><p>The official list of plugins currently supported by ChatGPT are listed below. You can expect this list to expand rapidly as more organizations experiment with ChatGPT:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltb5c7f4f71fefa8bb/6a1711ccb339d5202c76a0ee/34e746016e23a8a8b8fded4ecfcf34b6fcaba039-1440x583.png" alt="chatgpt plugins list" /><p>As you scan through the list, you’ll notice that the use cases are slowly revealing themselves here. In the case of Expedia, for example, its plugin is extending ChatGPT to assist in planning travel, making ChatGPT a trip-planning assistant.</p><p>This blog aims to achieve similar objectives for Elastic — to allow ChatGPT to access Elastic’s current knowledge base and assist you with your Elastic projects.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltda2a330ddbd80a0d/6a1711cea6c2b981bce7980e/226eacfcaa5c0f2e3d42f7381e360e81a1d52433-656x634.png" alt="plugin store" /><h2>Architecture</h2><p>We are going to bring a slight modification that has a positive cost impact in the sample code presented in <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">part 1</a> by my colleague <a href="https://www.elastic.co/blog/author/jeff-vestal">Jeff Vestal</a>.</p><p>We will remove the call to OpenAI API, as now ChatGPT will fulfill the role of taking the content from Elasticsearch and digesting it back to the user:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt762330bba798a8b8/6a1711d0839dfa22c0dcfff5/fac71d9933fdd297308bc54ebc471108ef9a4b07-1440x900.png" alt="elastic chatgpt diagram" /><ol><li><p>ChatGPT makes a call to the <code>/search</code> endpoint of the plugin.</p></li></ol><ul><li><p>This decision is based on the plugin “rules” <code>description_for_human</code> (see plugin-manifest below).</p></li></ul><ol><li><p>The plugin code creates a search request that is sent to Elasticsearch.</p></li><li><p>Documentation body and original url are returned to Python.</p></li><li><p>The plugin returns the document body and url, in text form to ChatGPT.</p></li><li><p>ChatGPT uses the information from the plugin to craft its response.</p></li></ol><p>Again, this blog post assumes that you have set up your <a href="https://www.elastic.co/cloud">Elastic Cloud</a> account, <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data#eland">vectorized your content</a>, and have an Elasticsearch cluster filled with data ready to be used. If you haven’t set all that up, see <a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">our previous post</a> for detailed steps to follow.</p><h2>Plugin code</h2><p>OpenAI built a fairly simple-to-handle plugin framework for ChatGPT. It deploys a service that exposes:</p><ul><li><p>The plugin manifest, explaining what the plugin provides to the users <em>and</em> to ChatGPT</p></li><li><p>The plugin openAPI definition, which is the functional description that enables ChatGPT to understand the available APIs The plugin code can be <a href="https://github.com/elastic/ElasticGPT_Plugin/">found here</a>.</p></li></ul><h3>Plugin file structure</h3><p>The screenshot below shows what the structure looks like:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltae1c75244b991b74/6a1711d1ab7f0895addb9fb5/b01242a370046e6bf0bab96edb2366b2fa1f22bf-728x436.png" alt="elasticgpt doc plugin" /><ul><li><p>The plugin manifest is stored in the ai-plugin.json file under the .well-known directory as per OpenAI best practices.</p></li><li><p>The main service code is in app.py.</p></li><li><p>The Dockerfile will be later used to deploy the plugin to Google Cloud Compute.</p></li><li><p>The plugin’s logo (logo.ong) as displayed in the ChatGPT plugin store, here the Elastic logo.</p></li><li><p>The OpenAI description of the plugin.</p></li></ul><h3>Python code</h3><p>For the full code, refer to the <a href="https://github.com/elastic/ElasticGPT_Plugin/">GitHub repository</a>. We are going to look only at the main part of this code:</p>…
@app.get("/search")
…
@app.get("/logo.png")
…
@app.get("/.well-known/ai-plugin.json")
…
@app.get("/openapi.yaml")
…
<p>We took out all the details and kept the main parts here. There are two categories of APIs here:</p><ol><li><p>The one required by OpenAI to build a plugin:</p></li></ol><ul><li><p>/logo.png: retrieve the plugin logo</p></li><li><p>/.well-known/ai-plugin.json: fetches the plugin manifest</p></li><li><p>/openapi.yaml: fetches the plugin OpenAPI description</p></li></ul><ol><li><p>The plugin API:</p></li></ol><ul><li><p>/search is the only one here exposed to ChatGPT that runs the search in Elasticsearch</p></li></ul><h3>Plugin manifest</h3><p>The plugin manifest is what ChatGPT will use to validate the existence (reachable) of the plugin. The definition is the below:</p>{
   "schema_version": "v1",
   "name_for_human": "ElasticGPTDoc_Plugin",
   "name_for_model": "ElasticGPTDoc_Plugin",
   "description_for_human": "Elastic Assistant, you know, for knowledge",
   "description_for_model": "Get most recent elasticsearch docs post 2021 release, anything after release 7.15",
   "auth": {
     "type": "none"
   },
   "api": {
     "type": "openapi",
     "url": "PLUGIN_HOSTNAME/openapi.yaml",
     "is_user_authenticated": false
   },
   "logo_url": "PLUGIN_HOSTNAME/logo.png",
   "contact_email": "info@elastic.co",
   "legal_info_url": "http://www.example.com/legal"
 }
<p>There are a couple of things to point out here:</p><ol><li><p>There are two descriptions:</p></li></ol><ul><li><p>description_for_human - This is what the human sees when installing the plugin in the ChatGPT web UI.</p></li><li><p>description_for_model - Instructions for the model to understand when to use the plugin.</p></li></ul><ol><li><p>There are some placeholders such as PLUGIN_HOSTNAME that are replaced in the Python code.</p></li></ol><h3>OpenAPI definition</h3><p>Our code will only expose a single API endpoint to ChatGPT allowing it to search for Elastic documentation. Here is the description:</p>openapi: 3.0.1
info:
 title: ElasticDocGPT
 description: Retrieve information front the most recent Elastic documentation
 version: 'v1'
servers:
 - url: PLUGIN_HOSTNAME
paths:
 /search:
   get:
     operationId: search
     summary: retrieves the document matching the query
     parameters:
     - in: query
       name: query
       schema:
           type: string
       description: use to filter relevant part of the elasticsearch documentations
     responses:
       "200":
         description: OK


<p>For the definition file, the key points are:</p><ul><li><p>We take the ChatGPT prompt content and pass it as a query to our Elasticsearch cluster.</p></li><li><p>Some placeholders such as PLUGIN_HOSTNAME are replaced in the Python code.</p></li></ul><h2>Deploying the Elastic plugin in Google Cloud Platform (GCP)</h2><p>You have a choice in picking a deployment method to expose your plugin, as well as using a different cloud provider. We use GCP in this blog post — more specifically Google Cloud Run and Google Cloud Build. The first is to expose and run the service, and the second is for continuous integration.</p><h2>Setup</h2><p>This setup assumes your GCP user has the right permissions to:</p><ul><li><p>Build a container image with Google Cloud Build in the Google Container Registry</p></li><li><p>Deploy a container in Google Cloud Run</p></li></ul><p>If not, you will need to update permissions on the <a href="https://console.cloud.google.com/iam-admin/iam">GCP IAM page</a>.</p><p>We are going to use the gcloud CLI to set up our environment. You can find the installation instructions <a href="https://cloud.google.com/sdk/docs/install">here</a>.</p><p>Once installed, run the following command to authenticate:</p>  gcloud auth
<p>Then set the project identifier to your GCP project:</p>
  gcloud config set project PROJECT_ID

<p>You are now ready to build and deploy.</p><h3>Build and deploy</h3><p>The first step is to build the container image using Cloud Build and push it to the Google Container Registry:</p>  gcloud builds submit --tag gcr.io/PROJECT_ID/my-python-app
<p>Replace PROJECT_ID with your GCP project ID and my-python-app with the name you want to give to your container image.</p><p>Export the environment required by the Python code to create the Elasticsearch client:</p>
  export YOUR_CLOUD_ID=VALUE
  export YOUR_CLOUD_PASS=VALUE
  export YOUR_CLOUD_USER=VALUE

<p>Finally, deploy the container image to Cloud Run:</p>
  gcloud run deploy my-python-app \
  --image gcr.io/PROJECT_ID/my-python-app \
  --platform managed \
  --region us-central1 \
  --allow-unauthenticated \
  --set-env-vars  cloud_id=YOUR_CLOUD_ID,cloud_pass=YOUR_CLOUD_PASS,cloud_user=YOUR_CLOUD_USER

<p>You should see your service running in Cloud Run:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4e48a26348ecc676/6a1711d3e8fbcefcfc39fd6d/b8c3ab3e7208e2e8f05ed101fc6fe9ba7582c649-654x424.png" alt="cloud run services" /><p>Note that you can also activate the continuous integration so that any commit in your GitHub repository will trigger a redeploy. On the service details page, click on <strong>Set up continuous deployment</strong>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltd5f75018e65a4bbe/6a1711d50e2e4920ca41a25c/954c1c27fc8fc7d5198f18dc727ab9df1a953a9d-538x102.png" alt="" /><h2>Installing the plugin in ChatGPT</h2><p>Once the plugin is deployed and has a publicly accessible endpoint, it can be installed in ChatGPT. In our case, since this is deployed in Google Cloud Run, you can get the URL here:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8ea1f2fc9b4dba32/6a1711d6acf0880435be9c6b/db3b12bf18c8cf15435a32ffeaf731ef6082e3bf-1404x108.png" alt="elastic doc gpt" /><p>Then in <a href="https://chat.openai.com/chat">ChatGPT</a>, go in the plugin store:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec39261316cb081c/6a1711d8964cea07f808bcd9/3783c95bda4592b93f202ac5bdb498f9a3f04c6a-1440x361.png" alt="plugins alpha" /><p>Choose to do “Develop your own plugin”:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltaa7f16957d702a99/6a1711d9a292997e25d01136/77d07ff08f573ac1f8c07468d135cc373a4b94a6-1440x607.png" alt="develop your own plugin" /><p>Paste the URL you copied from the Google Cloud Run page:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe72a85a4f8b3a30/6a1711db6234e09cd2db1b00/d872c6577df2463558d93a39ebe6ca6197934cf4-1072x604.png" alt="enter your website domain" /><p>Ensure the plugin is found and valid:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt71c1ff30970d5310/6a1711dcd7c0227595de65ca/6ac18505045bedf44511b364aae4934fe80d33a7-1034x568.png" alt="found plugin" /><p>Follow the installation instructions until you see your plugin available in the list:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltec91c3047e89994a/6a1711de4a531b2e2836aa93/7ce491c6d2b096fa917cb50ff8fe805d6d23431d-1252x398.png" alt="plugins alpha elastic" /><h2>Let’s test our plugin!</h2><p>OK, now for the best part! Do remember that ChatGPT decides to delegate when your prompt goes beyond its knowledge. To ensure that happens, just ask a question similar to this example:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt32ecc342b659ffe8/6a1711e00c48570cd001abaa/5fbff0d0197f493e341658291f8cbc154a2dfb8a-1440x1292.png" alt="highlights of latest elastic release" /><p>With the steps provided in this blog, you can create your own plugin and deploy it on a cloud provider or your own hosts. This allows you to start exploring enhancing ChatGPT's knowledge and functionality, enhancing an already amazing tool with specialized and proprietary knowledge.</p><p>You can try all of the capabilities discussed in this blog today! Get started by signing up for a <a href="https://cloud.elastic.co/registration?onboarding_token=vectorsearch&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">free Elastic Cloud trial</a>.</p><p>Here are some other blogs you may find interesting:</p><ul><li><p><a href="https://www.elastic.co/blog/chatgpt-elasticsearch-openai-meets-private-data">ChatGPT and Elasticsearch: OpenAI meets private data</a></p></li><li><p><a href="https://www.elastic.co/blog/monitor-openai-api-gpt-models-opentelemetry-elastic">Monitor OpenAI API and GPT models with OpenTelemetry and Elastic</a></p></li><li><p><a href="https://www.elastic.co/security-labs/exploring-applications-of-chatgpt-to-improve-detection-response-and-understanding">Exploring the Future of Security with ChatGPT</a></p></li></ul><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-plugin-elastic-data</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Baha Azarmi]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltafa5e250e50af311/6a1711e10e2e49950841a262/b42ad0b8550fc9ee532c0d93d2587aecdaf5dd5a-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[ChatGPT and Elasticsearch: OpenAI meets private data]]></title>
    <description><![CDATA[Integrate Elasticsearch's search relevance with ChatGPT's question-answering capability to enhance your domain-specific knowledge base.]]></description>
    <content:encoded><![CDATA[<p><strong>NOTE: This blog has been revisited with an update incorporating new features Elastic has released since this was first published. </strong><a href="https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-rag-enhancements"><strong>Please check out the new blog here!</strong></a></p><p>Combine Elasticsearch's search relevance with OpenAI's ChatGPT's question-answering capabilities to query your data. In this blog, you'll learn how to connect ChatGPT to proprietary data stores using Elasticsearch and build question/answer capabilities for your data.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt01bcddf80e3722d2/6a1711a3cdacbf135a7d2afc/ffbdc3b88620a1f53af18480929c6978d2fcaa44-1440x1187.png" alt="elasticdocs gpt list the steps free trial" /><h2>What is ChatGPT?</h2><p>In recent months, there has been a surge of excitement around ChatGPT, a groundbreaking AI model created by OpenAI. But what exactly is ChatGPT?</p><p>Based on the powerful GPT architecture, ChatGPT is designed to understand and generate human-like responses to text inputs. GPT stands for "Generative Pre-trained Transformer.” The Transformer is a cutting-edge model architecture that has revolutionized the field of natural language processing (NLP). These models are pre-trained on vast amounts of data and are capable of understanding context, generating relevant responses, and even carrying on a conversation. To learn more about the history of transformer models and some NLP basics in the Elastic Stack, be sure to check out the great <a href="https://www.youtube.com/watch?v=SvvbMCwyOnU">talk by Elastic ML Engineer Josh Devins</a>.</p><p>The primary goal of ChatGPT is to facilitate meaningful and engaging interactions between humans and machines. By leveraging the recent advancements in NLP, ChatGPT models can provide a wide range of applications, from chatbots and virtual assistants to content generation, code completion, and much more. These AI-powered tools have rapidly become an invaluable resource in countless industries, helping businesses streamline their processes and enhance their services.</p><h2>Limitations of ChatGPT &amp; how to minimize them</h2><p>Despite the incredible potential of ChatGPT, there are certain limitations that users should be aware of. One notable constraint is the knowledge cutoff date. Currently, ChatGPT is trained on data up to September 2021, meaning it is unaware of events, developments, or changes that have occurred since then. Consequently, users should keep this limitation in mind while relying on ChatGPT for up-to-date information. This can lead to outdated or incorrect responses when discussing rapidly changing areas of knowledge such as software enhancements and capabilities or even world events.</p><p>ChatGPT, while an impressive AI language model, can occasionally hallucinate in its responses, often exacerbated when it lacks access to relevant information. This overconfidence can result in incorrect answers or misleading information being provided to users. It is important to be aware of this limitation and approach the responses generated by ChatGPT with a degree of skepticism, cross-checking and verifying the information when necessary to ensure accuracy and reliability.</p><p>Another limitation of ChatGPT is its lack of knowledge about domain-specific content. While it can generate coherent and contextually relevant responses based on the information it has been trained on, it is unable to access domain-specific data or provide personalized answers that depend on a user's unique knowledge base. For instance, it may not be able to provide insights into an organization’s proprietary software or internal documentation. Users should, therefore, exercise caution when seeking advice or answers on such topics from ChatGPT directly.</p><p>One way to minimize these limitations is by providing ChatGPT access to specific documents relevant to your domain and questions, and enabling ChatGPT’s language understanding capabilities to generate tailored responses.</p><p>This can be accomplished by connecting ChatGPT to a search engine like Elasticsearch.</p><h2>Elasticsearch — you know, for search!</h2><p>Elasticsearch is a scalable data store and vector database designed to deliver relevant document retrieval, ensuring that users can access the information they need quickly and accurately. Elasticsearch’s primary focus is on delivering the most relevant results to users, streamlining the search process, and enhancing user experience.</p><p>Elasticsearch boasts a myriad of features to ensure top-notch search performance, including support for traditional keyword and text-based search (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules-similarity.html">BM25</a>) and an AI-ready vector search with exact match and approximate kNN (<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">k-Nearest Neighbor</a>) search capabilities. These advanced features allow Elasticsearch to retrieve results that are not only relevant but also for queries that have been expressed using natural language. By leveraging traditional, vector, or hybrid search (BM25 + kNN), Elasticsearch can deliver results with unparalleled precision, helping users find the information they need with ease.</p><p>One of the key strengths of Elasticsearch is its robust API, which enables seamless integration with other services to extend and enhance its capabilities. By integrating Elasticsearch with various third-party tools and platforms, users can create powerful and customized search solutions tailored to their specific requirements. This flexibility and extensibility makes Elasticsearch an ideal choice for businesses looking to improve their search capabilities and stay ahead in the competitive digital landscape.</p><p>By working in tandem with advanced AI models like ChatGPT, Elasticsearch can provide the most relevant documents for ChatGPT to use in its response. This synergy between Elasticsearch and ChatGPT ensures that users receive factual, contextually relevant, and up-to-date answers to their queries. In essence, the combination of Elasticsearch's retrieval prowess and ChatGPT's natural language understanding capabilities offers an unparalleled user experience, setting a new standard for information retrieval and AI-powered assistance.</p><h2>How to use ChatGPT with Elasticsearch</h2><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4d63e45e8e71c526/6a1711a5961e696f34c4d013/4c3858ece4620036b838131efd4548b844a1c8ae-1440x951.png" alt="use chatgpt with elasticsearch" /><ol><li><p>Python interface accepts user questions.</p></li></ol><p>Generate a hybrid search request for Elasticsearch</p><ul><li><p>BM25 match on the title field</p></li><li><p>kNN search on the title-vector field</p></li><li><p>Boost kNN search results to align scores</p></li><li><p>Set size=1 to return only the top scored document</p></li></ul><ol><li><p>Search request is sent to Elasticsearch.</p></li><li><p>Documentation body and original url are returned to python.</p></li><li><p>API call is made to OpenAI ChatCompletion.</p></li></ol><ul><li><p>Prompt: "answer this question &lt;question&gt; using only this document &lt;body_content from top search result&gt;"</p></li></ul><ol><li><p>Generated response is returned to python.</p></li><li><p>Python adds on original documentation source url to generated response and prints it to the screen for the user.</p></li></ol><p>The ElasticDoc ChatGPT process utilizes a Python interface to accept user questions and generate a hybrid search request for Elasticsearch, combining BM25 and kNN search approaches to find the most relevant document from the Elasticsearch Docs site, now indexed in Elasticsearch. However, you do not have to use hybrid search or even vector search. Elasticsearch provides the flexibility to use whichever search pattern best fits your needs and provides the most relevant results for your specific data sets.</p><p>After retrieving the top result, the program crafts a prompt for OpenAI's ChatCompletion API, instructing it to answer the user's question using only the information from the selected document. This prompt is key to ensuring the ChatGPT model only uses information from the official documentation, lessening the chance of hallucinations.</p><p>Finally, the program presents the API-generated response and a link to the source documentation to the user, offering a seamless and user-friendly experience that integrates front-end interaction, Elasticsearch querying, and OpenAI API usage for efficient question-answering.</p><p>Note that while we are only returning the top-scored document for simplicity, the best practice would be to return multiple documents to provide more context to ChatGPT. The correct answer could be found in more than one documentation page, or if we were generating vectors for the full body text, those larger bodies of text may need to be chunked up and stored across multiple Elasticsearch documents. By leveraging Elasticsearch's ability to search across numerous vector fields in tandem with traditional search methods, you can significantly enhance your top document recall.</p><h2>Technical setup</h2><p>The technical requirements are fairly minimal, but it takes some steps to put all the pieces together. For this example, we will configure the <a href="https://www.elastic.co/web-crawler">Elasticsearch web crawler</a> to ingest the Elastic documentation and generate vectors for the title on ingest. You can follow along to replicate this setup or use your own data. To follow along we will need:</p><ul><li><p>Elasticsearch cluster</p></li><li><p>Eland Python library</p></li><li><p>OpenAI API account</p></li><li><p>Somewhere to run our python frontend and api backend</p></li></ul><h3>Elastic Cloud setup</h3><p>The steps in this section assume you don’t currently have an Elasticsearch cluster running in Elastic Cloud. If you do you, can skip to the next section.</p><p><strong>Sign up</strong> If you don’t already have an Elasticsearch cluster, you can sign up for a free trial with <a href="https://cloud.elastic.co/registration?onboarding_token=vectorsearch&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">Elastic Cloud</a>.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3af069b6efc24a7b/6a1711a647d49c1eb52d8b0a/1e9fcc7281b87db1024bdd52d97050b680cf654d-920x1086.png" alt="start free trial" /><p><strong>Create deployment</strong> After you sign up, you will be prompted to create your first deployment.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt1b04d11d688b0e04/6a1711a8a292996a4cd01124/9bab0a32b62863103ac57843078b35bae6b3d939-1440x823.png" alt="create first deployment" /><ul><li><p>Create a name for your deployment.</p></li><li><p>You can accept the default cloud provider and region or click Edit Settings and choose another location.</p></li><li><p>Click Create deployment. Shortly a new deployment will be provisioned for you and you will be logged in to Kibana. <strong>Back to the Cloud</strong> We need to do a couple of things back in the Cloud Console before we move on: Click on the Navigation Icon in the upper left and select Manage this deployment.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blte4d09a0d03af921d/6a1711a947d49c15562d8b0e/c4064762d0fe4858de9f92018084dd3d654f8a68-277x449.png" alt="manage this deployment" /><p>Add a machine learning node.</p><ul><li><p>Back in the Cloud Console, click on Edit under your Deployment’s name in the left navigation bar.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt570ab096155f4cd7/6a1711aa28671432b593e42e/cdbf2abe7b7f2efe95c083a5800ce5c8edbac5e0-330x252.png" alt="deployments edit monitoring" /><ul><li><p>Scroll down to the Machine Learning instances box and click +Add Capacity.</p></li><li><p>Under Size per zone, click and select 2 GB RAM.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt79a4423f5ab22324/6a1703aeb339d5901a769e85/e30e63a849b2ba1fdcc58c946a5a482db8ac88d0-1432x292.png" alt="machine learning instances" /><ul><li><p>Scroll down and click on Save.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt75bcca2c5fb12ec1/6a1711ac28671421f193e432/1d3efb1b02888e310c47948966aef3a8fd8879a2-556x176.png" alt="save equivalent api request" /><ul><li><p>In the pop-up summarizing the architecture changes, click Confirm.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3ed7ef67cc298e2a/6a1711ae14b270b607e3c6eb/04e0da7b1378609ae810fabe2ac84f9917b5a69c-384x152.png" alt="cancel confirm" /><ul><li><p>In a few moments, your deployment will now have the ability to run machine learning models!</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt438d9d1a5474c693/6a1711afb0367dae8e72be2a/c5dcac69868ce404bf986ccf7a6e6429b4ad807c-1440x156.png" alt="change summary" /><p>Reset Elasticsearch Deployment User and password:</p><ul><li><p>Click on Security on the left navigation under your deployment’s name.</p></li><li><p>Click on Reset Password and confirm with Reset. (Note: as this is a new cluster nothing should be using this Elastic password.)</p></li><li><p>Download the newly created password for the “elastic” user. (We will use this to load our model from Hugging Face and in our python program.)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt76c75e99c823c09f/6a1711b17d8d67766970e85c/da1dd29d4b3d61ce79921b0bb1a15629b553e689-912x638.png" alt="save deployment credentials" /><p>Copy the Elasticsearch Deployment Cloud ID.</p><ul><li><p>Click on your Deployment name to go to the overview page.</p></li><li><p>On the right-hand side click the copy icon to copy your Cloud ID. (Save this for use later to connect to the Deployment.)</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt352796cac3fbe52b/6a1711b37d8d6725a570e860/f5dcfe766eea84e4a116caf33d8e068119863bba-1440x159.png" alt="applications hardware profile" /><h3>Eland</h3><p>We next need to load an embedding model into Elasticsearch to generate vectors for our blog titles and later for our user’s search questions. We will be using the <a href="https://huggingface.co/sentence-transformers/all-distilroberta-v1">all-distilroberta-v1</a> model trained by SentenceTransformers and hosted on the Hugging Face model hub. This particular model isn’t required for this setup to work. It is good for general use as it was trained on very large data sets covering a wide range of topics. However, with vector search use cases, using a model fine-tuned to your particular data set will usually provide the best relevancy.</p><p>To do this, we will use the <a href="https://github.com/elastic/eland#readme">Eland python library</a> created by Elastic. The library provides a wide range of data science functions, but we will be using it as a bridge to load the model into Elasticsearch from the Hugging Face model hub so it can be deployed on machine learning nodes for inference use.</p><p>Eland can either be run as part of a python script or on the command line. The repo also provides a Docker container for users looking to go that route. Today we will run Eland in a <a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/load_embedding_model.ipynb">small python notebook</a>, which can run in Google’s Colab in the web browser for free.</p><p>Open the <a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/load_embedding_model.ipynb">program link</a> and click the “Open in Colab” button at the top to launch the notebook in colab.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt277c97e73b673f82/6a1711b460084b6b6a3c4680/5a1b9de1ba50f8e48dab6341070194c70b715b61-236x40.png" alt="open in colab" /><p>Set the variable hf_model_id to the model name. This model is set already in the example code but if you want to use a different model or just for future information:</p><ul><li><p>hf_model_id='sentence-transformers/all-distilroberta-v1'</p></li><li><p>Copy model name from Hugging Face. The easiest way to do this is to click the copy icon to the right of the model name.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcbf5b198c21da493/6a1711b514b27074d6e3c6ef/d883b5342323b8bfcbf5a2a29014f48750717b25-1212x270.png" alt="hugging face" /><p>Run the cloud auth section, and you will be prompted to enter:</p><ul><li><p>Cloud ID (you can find this in the Elastic Cloud Console)</p></li><li><p>Elasticsearch Username (easiest will be to use the “Elastic” user created when the deployment was created)</p></li><li><p>Elasticsearch User Password</p></li></ul><p>Run the remaining steps.</p><ul><li><p>This will download the model from Hugging face, chunk it up, and load it into Elasticsearch.</p></li><li><p>Deploy (start) the model onto the machine learning node.</p></li></ul><h3>Elasticsearch index and web crawler</h3><p>Next up we will create a new Elasticsearch index to store our Elastic Documentation, configure the web crawler to automatically crawl and index those docs, as well as use an ingest pipeline to generate vectors for the doc titles.</p><strong>Note that you can use your proprietary data for this step, to create a question/answer experience tailored to your domain.</strong><ul><li><p>Open Kibana from the Cloud Console if you don’t already have it open.</p></li><li><p>In Kibana, Navigate to Enterprise Search -&gt; Overview. Click Create an Elasticsearch Index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltdc51e9f198d426e7/6a1711b74a531b8d0936aa8d/57ae4a7024863162265b67da3f0419bcd7bd6f62-752x180.png" alt="create an elasticsearch index" /><ul><li><p>Using the Web Crawler as the ingestion method, enter elastic-docs as the index name. Then, click Create Index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt65d0058455a44207/6a1711b8b0367da8aa72be2e/51bd68374c03625dfa1e06cc3170e4173b5fb7b6-1440x474.png" alt="select an ingestion method" /><ul><li><p>Click on the “Pipelines” tab.</p></li><li><p>Click Copy and customize in the Ingest Pipeline Box.</p></li><li><p>Click Add Inference Pipeline in the Machine Learning Inference Pipelines box.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltabf90aa59e3e850f/6a1711ba0c4857d1a001ab9c/e3d85ab9e4b6688dc5ea8614f6b2248394177485-1186x436.png" alt="machine learning inference pipelines" /><ul><li><p>Enter the name elastic-docs_title-vector for the New pipeline.</p></li><li><p>Select the trained ML model you loaded in the Eland step above.</p></li><li><p>Select title as the Source field.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc443155a95f5187b/6a1711bb964cea4f6108bcd5/555170b190ea940418f2bf8ba7d45c41d2589a75-1440x818.png" alt="configure add a new pipeline" /><ul><li><p>Click Continue, then click Continue again at the Test stage.</p></li><li><p>Click Create Pipeline at the Review stage.</p></li></ul><p>Update mapping for dense_vector field. (Note: with Elasticsearch version 8.8+, this step should be automatic.)</p><ul><li><p>In the navigation menu, click on Dev Tools. You may have to click Dismiss on the flyout with documentation if this is your first time opening Dev Tools.</p></li><li><p>In Dev Tools in the Console tab, update the mapping for our dense vector target field with the following code. You simply paste it in the code box and click the little arrow to the right of line 1.</p></li></ul>POST search-elastic-docs/_mapping
{
  "properties": {
    "title-vector": {
      "type": "dense_vector",
      "dims": 768,
      "index": true,
      "similarity": "dot_product"
    }
  }
}
<ul><li><p>You should see the following response on the right half of the screen:</p></li></ul>{
  "acknowledged": true
}
<ul><li><p>This will allow us to run kNN search on the title field vectors later on.</p></li></ul><p>Configure web crawler to crawl Elastic Docs site:</p><ul><li><p>Click on the navigation menu one more time and click on Enterprise Search -&gt; Overview.</p></li><li><p>Under Content, click on Indices.</p></li><li><p>Click on search-elastic-docs under Available indices.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt39f78c6403e11420/6a1711bd47d49c67992d8b1a/7b05934f8775f4f602eb258c7bb8c3c1b82bd285-1440x177.png" alt="available indices" /><ul><li><p>Click on the Manage Domains tab.</p></li><li><p>Click “Add domain.”</p></li><li><p>Enter <a href="https://www.elastic.co/guide/en">https://www.elastic.co/guide/en</a>, then click Validate Domain.</p></li><li><p>After the checks run, click Add domain. Then click Crawl rules.</p></li><li><p>Add the following crawl rules one at a time. Start with the bottom and work up. Rules are evaluated according to first match.</p></li></ul><p></p><p></p><p></p><p>Disallow</p><p>Contains</p><p>release-notes</p><p>Allow</p><p>Regex</p><p>/guide/en/.*/current/.*</p><p>Disallow</p><p>Regex</p><p>.*</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt72127923b67d3b5a/6a1711bed7c022c73ade65c4/efd9052ba0038084855988b4fa6888a2a114a974-1440x410.png" alt="crawl rules" /><ul><li><p>With all the rules in place, click Crawl at the top of the page. Then, click Crawl all domains on this index.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltcd9093ca85f8da56/6a1711c0a929cf9114ae0ae1/a50db802ba5cadb3863ffa314a8deee9b64c8dd7-638x380.png" alt="search engines crawl" /><p>Elasticsearch’s web crawler will now start crawling the documentation site, generating vectors for the title field, and indexing the documents and vectors.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blteedc9efa600488e7/6a1711c2c1e8a5aea3f883d9/41eac123618338f127abffe9c957300f4e61fd0a-338x128.png" alt="crawling" /><p>The first crawl will take some time to complete. In the meantime, we can set up the OpenAI API credentials and the Python backend.</p><h2>Connecting with OpenAI API</h2><p>To send documents and questions to ChatGPT, we need an OpenAI API account and key. If you don’t already have an account, you can create a free account and you will be given an initial amount of free credits.</p><ul><li><p>Go to <a href="https://platform.openai.com">https://platform.openai.com</a> and click on Signup. You can go through the process to use an email address and password or login with Google or Microsoft.</p></li></ul><p>Once your account is created, you will need to create an API key:</p><ul><li><p>Click on <a href="https://platform.openai.com/account/api-keys">API Keys</a>.</p></li><li><p>Click Create new secret key.</p></li><li><p>Copy the new key and save it someplace safe as you won’t be able to view the key again.</p></li></ul><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3c4b4def8f822024/6a1711c3a292995b95d0112e/b94891d6199c0c0c9f451eec9c8ac8d250882991-1114x586.png" alt="api key generated" /><h2>Python backend setup</h2><h3>Clone or download the python program</h3><p><a href="https://github.com/jeffvestal/ElasticDocs_GPT/blob/main/elasticdocs_gpt.py">Github Link to code</a></p><ol><li><p>Install required python libraries. We are running the example program in Replit, which has isolated environments. If you are running this on a laptop or VM, best practice is to <a href="https://docs.python.org/3/library/venv.html">set up a virtual ENV for python</a>.</p></li></ol><ul><li><p>Run pip install -r requirements.txt</p></li></ul><ol><li><p>Set authentication and connection environment variables (e.g., if running on the command line: export openai_api=”123456abcdefg789”)</p></li></ol><ul><li><p>openai_api - OpenAI API Key</p></li><li><p>cloud_id - Elastic Cloud Deployment ID</p></li><li><p>cloud_user - Elasticsearch Cluster User</p></li><li><p>cloud_pass - Elasticsearch User Password</p></li></ul><ol><li><p>Run the streamlit program. More info about <a href="https://docs.streamlit.io/library/get-started/installation">streamlit can be found in its docs</a>.</p></li></ol><ul><li><p>Streamlit has its own command to start: streamlit run elasticdocs_gpt.py</p></li></ul><ol><li><p>This will start a web browser and the url will be printed to the command line.</p></li></ol><h2>Sample chat responses</h2><p>With everything ingested and the front end up and running, you can start asking questions about the Elastic Documentations.</p><p>Asking “Show me the API call for an inference processor” now returns an example API call and some information about the configuration settings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltffaa60616a42dc47/6a1711c50e2e49673b41a258/d767639258b64417da444346e191a158620cf134-1440x1448.png" alt="show api call" /><p>Asking for steps to add a new integration to Elastic Agent will return:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf0a7fe68f5fe11c5/6a1711c6a929cf650bae0ae5/5065af9bf9ae5ee5fd2a1c7636d2293746fb241d-1440x1272.png" alt="how add new integration" /><p>As mentioned earlier, one of the risks of allowing ChatGPT to answer questions based purely on data it has been trained on is its tendency to hallucinate incorrect answers. One of the goals of this project is to provide ChatGPT with the data containing the correct information and let it craft an answer.</p><p>So what happens when we give ChatGPT a document that does not contain the correct information? Say, asking it to tell you how to build a boat (which isn’t currently covered by Elastic’s documentation):</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltba5ff5dddb7b8982/6a1711c8b339d58b8d76a0e8/43f80bfefab55261493751672669616cc6d0f54b-1440x548.png" alt="show build boat" /><p>When ChatGPT is unable to find an answer to the question in the document we provided, it falls back on our prompt instruction simply telling the user it is unable to answer the question.</p><h2>Elasticsearch’s robust retrieval + the power of ChatGPT</h2><p>In this example, we've demonstrated how integrating Elasticsearch's robust search retrieval capabilities with cutting-edge advancements in AI-generated responses from GPT models can elevate the user experience to a whole new level.</p><p>The individual components can be tailored to suit your specific requirements and adjusted to provide the best results. While we used the Elastic web crawler to ingest public data, you're not limited to this approach. Feel free to experiment with alternative embedding models, especially those fine-tuned for your domain-specific data.</p><p>You can try all of the capabilities discussed in this blog today! To build your own ElasticDocs GPT experience, sign up for an <a href="https://cloud.elastic.co/registration?onboarding_token=vectorsearch&amp;cta=cloud-registration&amp;tech=trial&amp;plcmt=article%20content&amp;pg=search-labs">Elastic trial account</a>, and then look at this <a href="https://github.com/jeffvestal/ElasticDocs_GPT">sample code repo</a> to get started.</p><p>If you would like ideas to experiment with search relevance, here are two to try out:</p><ul><li><p><a href="https://www.elastic.co/blog/how-to-deploy-nlp-text-embeddings-and-vector-search">[BLOG] Deploy NLP text embeddings and vector search using Elasticsearch</a></p></li><li><p><a href="https://www.elastic.co/blog/implement-image-similarity-search-elastic">[BLOG] Implement image similarity search with Elastic</a></p></li></ul><p><em>In this blog post, we may have used third party generative AI tools, which are owned and operated by their respective owners. Elastic does not have any control over the third party tools and we have no responsibility or liability for their content, operation or use, nor for any loss or damage that may arise from your use of such tools. Please exercise caution when using AI tools with personal, sensitive or confidential information. Any data you submit may be used for AI training or other purposes. There is no guarantee that information you provide will be kept secure or confidential. You should familiarize yourself with the privacy practices and terms of use of any generative AI tools prior to use.</em></p><p><em>Elastic, Elasticsearch and associated marks are trademarks, logos or registered trademarks of Elasticsearch N.V. in the United States and other countries. All other company and product names are trademarks, logos or registered trademarks of their respective owners.</em></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/chatgpt-elasticsearch-openai-meets-private-data</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Jeff Vestal]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4478d2508f563479/6a1711c9a929cf0b9fae0ae9/1d616d244f05328ed677b008941db001d79c86b7-1440x840.png" length="0" type="image/png"/>
    <pubDate>Wed, 21 Jun 2023 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>