<?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[Hybrid Search - 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[Hybrid Search - 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/hybrid-search</link>
    </image>
    <link>https://www.elastic.co/search-labs/blog/category/hybrid-search</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/category/hybrid-search.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Tue, 22 Sep 2026 22:10:09 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Elasticsearch Vector Database: Ship in minutes, scale affordably to hundreds of billions]]></title>
    <description><![CDATA[The hard parts of hybrid retrieval, already done, with optimized defaults, third party and native Jina AI models, and managed GPU inference all out of the box. Build fast, scalable AI apps, not infrastructure.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch is one of the most widely deployed platforms for vector workloads in the world, powering semantic search, retrieval augmented generation (RAG), and recommendations for companies like GitHub, Docusign, Seismic, and many others. Today we're announcing Elasticsearch Vector Database, a new serverless offering optimized for vector based applications. You bring your documents and your queries, and we handle the embeddings and index tuning, along with the infrastructure. Plus, we keep it cheap and scalable. </p><p>For new users, this is the fastest way to get high-quality vector search running. If you already use Elasticsearch, the new offering is vector search on the platform where your data already lives, with no new system to adopt. Elasticsearch Vector Database supports a range of scenarios, from grounding a large language model (LLM), to giving an AI agent retrieval and memory, to serving hundreds of billions of vectors. <a href="https://cloud.elastic.co/registration?onboarding_token=vector">Spin up a new project</a> and get started in minutes.</p><h2>One engine, every vector use case</h2><p>Elasticsearch Vector Database is built for anyone building applications using vectors:</p><ul><li><p><strong>RAG:</strong> Retrieve the right context for your LLM with dense and sparse vector retrieval, or go with hybrid search combining both vector and lexical retrieval. The quality of your generation improves with the quality of your retrieval.</p></li><li><p><strong>AI agents:</strong> Give agents fast, filtered retrieval over documents and conversation memory, with the low latencies that multistep agent loops demand.</p></li><li><p><strong>Semantic search:</strong> Match on meaning, not keywords, with one field type and zero pipeline code.</p></li><li><p><strong>Recommendations and similarity:</strong> Find nearest neighbors across products, images, or whatever content you have, at scale.</p></li></ul><h2>Everything your vector workload needs, optimized out of the box</h2><p>Building a vector-based application means wiring together several separate pieces: setting up and hosting embedding models, indexing your documents through them, storing the vectors efficiently, applying the embedding model to each query, matching against the vector store, and finally, retrieving the documents behind the matches. Elasticsearch Vector Database handles all of it for you, with no additional configuration or setup.</p><h3>Vector indexing with vectordb_document index mode</h3><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector#dense-vector-vectordb-document-mode"><code>vectordb_document</code></a> index mode, a new index configuration purpose-built for vector-first workloads, is on by default, so you get the settings that experts would choose. Here's what it turns on:</p><ul><li><p><strong>bfloat16 by default:</strong> Vectors are stored at half the size of float32 with negligible impact on recall, cutting your disk footprint roughly in half before quantization even enters the picture.</p></li><li><p><strong>Source vectors excluded:</strong> In Elasticsearch, your embeddings already live in the index structures used for search; keeping a second raw copy in <code>_source</code> just inflates storage and slows down fetching results. We exclude the duplicate so responses return faster and you store less.</p></li><li><p><strong>The right files preloaded into cache:</strong> The data structures that vector queries touch first are warmed into memory ahead of time, so your first (and your thousandth) query is lightning fast.</p></li><li><p><strong>Parallel merging:</strong> Merging consolidates segments into better-organized vector structures, which lifts both recall and latency, and running those merges multi-threaded means you get there faster.</p></li></ul><h3>Vector storage, compression, and auto-tuning</h3><ul><li><p>Your vectors are compressed automatically.<a href="https://www.elastic.co/search-labs/blog/better-binary-quantization-lucene-elasticsearch"> Better Binary Quantization (BBQ)</a> shrinks vector memory footprints by up to 32x while preserving recall, and DiskBBQ reduces memory requirements further for large-scale workloads.<a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq"> </a></p></li><li><p>Opt in to<a href="https://www.elastic.co/search-labs/blog/vector-quantization-auto-calibration-diskbbq"> auto-calibration</a>, which tunes each segment's quantization to your data and retunes on every merge as data drifts. When tested across 18 datasets, queries per second (QPS) improved by an average of 16.7%, with recall gains in most of them.</p></li></ul><h3>Embeddings on managed GPU inference</h3><ul><li><p>Generate embeddings with native <a href="https://www.elastic.co/jina-search-models">Jina AI embedding and reranking models</a>, or bring third-party models, all on managed GPUs via <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service (EIS)</a> with no model servers to operate. Or self-host, if you prefer your own.</p></li><li><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><strong><code>semantic_text</code></strong></a> field type automatically handles chunking and embedding, along with querying, the simplest path to semantic search in the market. </p></li></ul><h3>Hybrid search and filtered vector search</h3><ul><li><p><a href="https://www.elastic.co/elasticsearch/hybrid-search">Hybrid search</a> is built in, combining full-text and vector retrieval in a single query. Blend the results with reciprocal rank fusion (RRF) or any other blending mechanism you want. Vector search is usually the hardest part of hybrid search to configure well. With Elasticsearch Vector Database, you have it handled, and your whole hybrid stack gets better. </p></li><li><p>With <a href="https://www.elastic.co/search-labs/blog/filtered-hnsw-knn-search">filtered vector search</a>, apply metadata filters as part of vector retrieval itself and not as an afterthought that wrecks recall.</p></li></ul><h3>Enterprise on day one</h3><p>You also get role-based access control (RBAC), audit logging, and the compliance certifications that pure-play vector databases generally lack.</p><h2>Affordable at scale and predictable</h2><p>Elasticsearch Vector Database is built to stay affordable as you grow: BBQ and DiskBBQ compression that keeps storage linear and memory low means scaling to hundreds of billions of vectors doesn't blow up your bill. And <a href="https://cloud.elastic.co/pricing/serverless?s=vectordb">what you do pay</a> is built from numbers you already know: how much data you store and how much you index, along with how much search capacity you need. Estimate your document count and vector dimensions, plus your query load, and you can work out what you'll pay before you create the project. You can also understand your bill line by line at the end of the month. There are no opaque compute units and no surprise charges for background operations.</p><h2>How to get started with Elasticsearch Vector Database</h2><h3>Create a serverless vector database project</h3><p>Create a new <a href="https://cloud.elastic.co/registration?onboarding_token=vector">serverless Vector Database project in Elastic Cloud</a>. Point your data at the endpoint, and you're ready to index.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt556cbdfba551f248/6aa10b4332b53038406d321a/image1.png" alt="Elastic Cloud Serverless project types: Elasticsearch, Vector Database, Observability and Security" /><h3>Create an index using semantic_text</h3><p>Vector index mode handles the vector configuration. Using <code>semantic_text</code> means that embeddings and chunking setup are managed for you, as is index setup, on managed GPU inference, with no embedding pipeline to build.</p>PUT my-vectors
{
"mappings": {
"properties": {
"description": { "type": "semantic_text" }
    }
  }
}<h3>Ingest documents</h3><p>Index text, and the embeddings are generated for you.</p>POST /my-vectors/_doc
{
  "id": "park_rocky-mountain",
  "title": "Rocky Mountain",
  "description": "Bisected north to south by the Continental Divide, this portion of the Rockies has ecosystems varying from over 150 riparian lakes to montane and subalpine forests to treeless alpine tundra."
}<h3>Run a semantic search query</h3><p>Query the same semantic field you just created:</p>GET /my-vectors/_search
{
  "query": {
    "semantic": {
      "field": "description",
      "query": "a mountain range in the middle of north america"
    }
  }
}<p>And you get results back:</p>{
  "took": 80,
  "hits": {
    "max_score": 0.7792325,
    "hits": [
      {
        "_index": "my-vectors",
        "_score": 0.7792325,
        "_source": {
          "id": "park_rocky-mountain",
          "title": "Rocky Mountain",
          "description": "Bisected north to south by the Continental Divide, ..."
        }
      }
    ]
  }
}<p>Semantic search is just the start. Run fully textual queries or combine both into hybrid queries. You can even craft your own vector queries for full control. Follow the <a href="https://www.elastic.co/docs/solutions/vector-database/vector-full-text-search">semantic search quickstart</a> in the docs for the full instructions.</p><h2>What's next for vector search in Elasticsearch</h2><p>We're already working on the next improvements:</p><ul><li><p><strong>Better multi-tenant handling:</strong> If your data needs to stay separated per tenant, we'll give you a way to do it faster and with less code.</p></li><li><p><strong>Automatic index optimization:</strong> From "brand new index" to "fully optimized," with as little tinkering as possible.</p></li><li><p><strong>Continuous infrastructure improvements:</strong> Ongoing tuning of Vector Database's settings and infrastructure so you're always getting the best throughput and fastest responses.</p></li></ul><h2>Try Elasticsearch Vector Database on Elastic Cloud Serverless</h2><p>Go from an empty project to a hybrid, filtered vector query in minutes, with production-grade defaults doing the tuning for you. Build fast, scalable AI apps, not infrastructure.</p><p>Start on <a href="https://cloud.elastic.co/registration?onboarding_token=vector">Elastic Cloud Serverless</a>, or dive into the <a href="https://www.elastic.co/docs/solutions/vector-database">full documentation </a>and <a href="https://www.elastic.co/docs/api/doc/elastic-cloud-serverless/group/endpoint-vectordb-projects">API reference.</a> You can also access the new offering on<a href="https://aws.amazon.com/marketplace/pp/prodview-voru33wi6xs7k"> AWS Marketplace</a>,<a href="https://console.cloud.google.com/marketplace/product/elastic-prod/elastic-cloud"> Google Cloud Marketplace</a> and<a href="https://portal.azure.com/#view/Microsoft_Azure_Marketplace/GalleryItemDetailsBladeNopdl/id/elastic.ec-azure-vector/"> Microsoft Marketplace</a>.</p><p></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/vector-database-rag-serverless</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/vector-database-rag-serverless</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[Elastic Cloud Serverless]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Dustin Coates]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4def84aae6aff861/6aa10ab1ee57e53d9b05253c/cover.png" length="0" type="image/png"/>
    <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[AI shopping agents: Why context comes before the query]]></title>
    <description><![CDATA[AI shopping agents that guess at your vocabulary make expensive mistakes. Pre-computed catalog context stops the guessing before the first tool call.]]></description>
    <content:encoded><![CDATA[<p>The race is on for retailers to match the evolving expectations of their customers to provide significantly richer online shopping experiences. Customers want to go beyond searching for products; they want interactive, personalized, and proactive guidance powered by AI. The challenge is that, although large language models (LLMs) can be extremely powerful, how do you construct a system that acts like a knowledgeable employee of your store in a fast, accurate, and cost-effective way? We’ll discuss the challenges of building these AI shopping assistants as well as emerging context engineering approaches to optimize how they work.</p><p>AI shopping agents fail not because the model is wrong but because the agent arrives at every conversation not knowing your catalog, vocabulary, or business rules. It has to discover all of this context through tool calls, and that discovery is the cost. Precomputing a structured context layer from signals you already hold (vocabulary, policies, user profiles, session behavior) cuts the exploratory work the agent does before it can answer and makes its behavior governed and predictable.</p><p>In the analogous document-retrieval case, precomputing context reduced input tokens by up to 75% on a controlled benchmark; we expect comparable savings in ecommerce because the exploration pattern is the same, though the exact figure will vary by catalog and query mix. The signals are richer in ecommerce than in almost any other domain, and most retailers already have them. The question is whether or not these signals are assembled in a form that the agent can use before it starts reasoning. Elastic’s broad mix of search capabilities, semantic, hybrid, keyword, filtering, and aggregations make it a compelling choice for not only building your core retrieval tools but also as the vital context engine.</p><h2><strong>Why AI shopping agents fail in production</strong></h2><p>Retailers investing in AI shopping assistants are discovering an uncomfortable gap between what the demos promise and what the first few months in production deliver.</p><p>The assistant takes four seconds to respond. It confidently recommends a product in a size range that doesn't exist for that item. It tells a returning customer about a coat style they bought 18 months ago and returned. It filters by a category name that doesn't match the internal taxonomy and returns zero results. The customer gives up and abandons the chat altogether.</p><p>These aren't model failures. The frontier models powering these agents are capable of extraordinary reasoning when they have the right information in front of them. The problem is that the agent arrives at the conversation knowing nothing about the retailer's catalog, customer, or business rules that govern what should and shouldn't be recommended. It has to learn all of this through the conversation itself, making exploratory tool calls to discover what departments exist, what filter values are valid, and what the brand's policies are on certain product types. Every one of those discovery calls costs latency and tokens before the agent has said a single useful thing to the customer.</p><p>Latency matters in ecommerce in a way it doesn't in many other contexts. Shoppers expect response times measured in seconds, not the minutes that enterprise knowledge-base agents routinely take. It’s well established that slower responses reduce engagement and conversion in online retail. An AI shopping assistant that thinks visibly for five seconds before answering a question about gift ideas isn't a feature; it's friction.</p><p>The fix isn't a faster model or a bigger context window. <strong>The agent's latency and cost problem is a context problem.</strong> This can be solved by carefully computing context before the agent call, not during it.</p><h3><strong>What an AI shopping agent knows before it searches</strong></h3><p>Imagine you’re the agent. You have a search tool connected to the catalog, and this query arrives:</p><p><code>"an outfit for an autumn wedding"</code></p><p>What do you actually do with that?</p><p>Start with what you don't know. An outfit for a man or a woman? Is "autumn" a color, a season, a style of fabric, or just when the wedding happens? And who is this shopper, someone who has bought from you for years, or a stranger? Do they buy expensive designer brands or do they always hunt out a bargain on sale? You have none of these answers. So you do what an agent does when it's working blind: You ask loads of follow-up questions, guess, or fire off a series of exploratory searches to find out what departments and filter values even exist, watching the seconds tick by before you've offered the customer anything at all.</p><p>Hold onto that feeling of working blind. The rest of this article is about what changes when the agent is handed the answers first.</p><h2><strong>Why ecommerce is different from general RAG</strong></h2><p>Most of the published work on reducing agent costs focuses on document retrieval: question-answering over corpora of articles, reports, or knowledge-base entries. A recent experiment from the Elastic team (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) demonstrated that pre-extracting structured facts from documents before the agent call reduced input token consumption by up to 75% and improved answer accuracy from 60% to 92% on a hard factual benchmark. That improvement came in stages, with the largest jump driven by feeding the agent's own wrong answers back into the extraction step rather than by precomputing context alone, which is a distinction we'll return to when we discuss governance.</p><p>Ecommerce applies the same principle to a fundamentally different structure. A product catalog isn't a document corpus. It's a highly structured index of items with strict field semantics, a domain-specific vocabulary of brand names, color codes, and category hierarchies, and a layer of business rules that override pure relevance in specific situations.</p><p>The failure modes that result are distinct from document retrieval augmented generation (RAG) failures:</p><ul><li><p><strong>Vocabulary mismatch:</strong> A customer asks for a "navy jumper." The agent constructs a filter against a field where the canonical value is NAVY and the category is stored as Knitwear &amp; Jumpers. Without a vocabulary mapping, the agent either guesses colors and categories and gets it wrong, or makes multiple exploratory calls to discover what values exist before it can filter correctly.</p></li><li><p><strong>Hallucinated filter values:</strong> Without knowing which filter dimensions are valid for a given query, agents can construct queries against fields that don't exist or with values that return zero results. A filter like category: knitwear looks reasonable; <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code> is what the index actually contains. If the agent doesn’t know, it either hallucinates or has to do a separate tool call to find out, causing another LLM loop, which costs time and tokens.</p></li><li><p><strong>Context-free personalization:</strong> The same query from two different customers, one who typically shops in the premium range and dresses for formal occasions, and one who buys primarily casualwear under £40, should return different results. Without profile context, the agent treats every query identically, which is worse than a well-tuned keyword search because it creates the impression of a personal assistant while delivering generic answers.</p></li></ul><h2><strong>The context layer: What signals it needs</strong></h2><p>The reason ecommerce is particularly well suited to precomputed context is that retailers already hold an unusually rich set of signals. The challenge isn't data availability; it's assembly.</p><p>The signals fall into two groups. Two of them, the catalog vocabulary and the business policies, are the genuinely original work and the heart of this approach. The rest, live facet state, user profiles, and session history, are valuable but closer to table stakes, signals that most teams already understand how to fetch. Here's what most mid-to-large retailers have and what each signal prevents.</p><p>Signal</p><p>What it prevents</p><p>Effort to build</p><p>Catalog vocabulary</p><p>Vocabulary mismatch and hallucinated filter values; the agent guessing at colors, categories, or brand names instead of resolving them to canonical field values</p><p>One-time engineering effort (full-catalog aggregation); incremental to maintain as new categories and brands are added</p><p>Business policies</p><p>Recommendations that ignore legal or trading requirements, for example, missing age verification on alcohol queries or routing that misses a gluten-free range</p><p>Human-authored and governed, not automated; ongoing review as new policy types are added</p><p>Live facet state</p><p>Recommending filters that return zero results or out-of-stock options for the current query</p><p>Runs in parallel with vocabulary and policy lookups; leans on existing catalog and retrieval infrastructure</p><p>User profile</p><p>Making a returning customer restate sizes, budget, or brand preferences they've already given</p><p>Fastest signal to retrieve, a single document lookup by user ID</p><p>Session and purchase history</p><p>Re-recommending an item the customer already dismissed or bought and returned</p><p>Most aspirational layer; depends on customer relationship management (CRM) and analytics integration, best added once the agent is already live</p><p>The reason we say <em>semantic metadata</em> and not just <em>metadata</em> is that we’re trying to match the semantic (meaning) of the intent rather than the exact words. If a user is searching for “teal,” we should be able to understand that this is a color and which colors exist in our products that are semantically similar to teal, even if none of them are actually teal. So, if we search for “teal,” we might want to return:</p><p><code>ProductColours = “aquamarine, turquoise”</code></p><p>Hopefully, you can see how this semantic metadata is bridging the gap between user intent and the agent’s knowledge of the products.</p><h3><strong>Catalog vocabulary: The foundation of context engineering</strong></h3><p>Catalog vocabulary is the layer that does the most work, and it's the one most worth getting right first.</p><p>A vocabulary index maps natural language to the exact field values and category paths used in the product index. It answers questions like: <em>What does "navy" map to?</em> <em>Which categories fall under "knitwear"?</em> <em>Is "Autograph" a brand or a range?</em> <em>What's the correct spelling of a competitor brand the agent might encounter in a query?</em></p><p>What makes this more than a synonym list is how it's queried. The interesting thing a shopper says is rarely an exact field value. They say "something cozy for fall," not <code>colour: NAVY</code> and <code>masterCategoryNames: "Knitwear &amp; Jumpers"</code>. So the vocabulary index needs to resolve fuzzy, natural language intent into precise, exact-match filters, and that requires both kinds of matching at once: semantic search to understand that "cozy" leans toward knitwear and fleece, and exact keyword matching to pin the result to the canonical values the product index actually stores. A metadata index that supports both on the same documents is, in effect, a translation layer between how customers talk and how the catalog is structured.</p><p>This is also where the index earns the description "semantic metadata layer" rather than "lookup table." Each entry is a small natural language description of a facet value or schema concept, so the agent can match against meaning and then read back the exact filter to use. For a typical fashion retailer, this covers hundreds of color values, brand aliases, category synonyms, and size-range conventions. Building the semantic metadata layer from a full-catalog aggregation is a one-time engineering effort; maintaining it is incremental as new categories and brands are added. Without it, an agent encountering an unfamiliar term must either guess or make exploratory tool calls to discover what's there.</p><h3><strong>Business policies in the context layer</strong></h3><p>The second original layer is policy. Some queries carry implicit business requirements that pure relevance cannot handle. A query for "wine gift for a friend" should trigger an age-verification reminder in markets where it's legally required. A query for "gluten-free food gift" should route away from general confectionery toward the specific gluten-free range. A query mentioning "wedding guest outfit" in spring should apply different weighting than the same query in November.</p><p>These are policies, and most retail search teams already write them. They just call them boost rules, merchandising overlays, or synonym configurations. The difference in an agentic context is that instead of being applied silently as query modifications, they're surfaced as readable hints the agent can use when deciding how to frame its answer and which products to surface. The agent doesn't have to infer your trading rules from the catalog; it's handed them.</p><p>The critical point: These policies encode business intent, not just relevance. A policy that routes alcohol queries through an age-appropriate flow isn't a retrieval optimization; it's a trading requirement. That's why this layer must be human-authored and governed, not generated automatically from traffic patterns, a point we return to in the governance section.</p><p>Together, vocabulary and policy are what make the agent behave like it understands your business rather than just your data. The remaining three signals sharpen the experience, but they're more familiar engineering.</p><h3><strong>Facet, profile, and session signals in the context layer</strong></h3><ul><li><p><strong>Live facet state:</strong> Before the agent recommends filters, it should know which filters are available and how many results each returns for this specific query. An agent that suggests "filter by size 8" without knowing that size 8 is out of stock for this query undermines the customer's trust immediately. A facet state query against the product index, run in parallel with the vocabulary and policy lookups, returns the counts, ranges, and available values specific to the current query.</p></li><li><p><strong>User profile:</strong> A persistent profile (sizes, color preferences, budget range, brand affinities) lets a returning customer skip restating what they've already told you. It's typically the fastest signal to retrieve, a single document lookup by user ID.</p></li><li><p><strong>Session and purchase history:</strong> Within a session, the agent should know what the customer has already seen, dismissed, or added to their basket, so it doesn't re-recommend a dismissed item or repeat itself. Longer-term purchase history extends this, and signals like return history are richer still, but using them well depends on data most retailers hold in systems that aren't yet wired into their search path. This is the most aspirational layer and the one best approached last, once the earlier layers are delivering value. There must also be balance when building the initial context not to overinflate the size, which will slow down the first reply and increase token costs. There is, therefore, a careful balance to strike between providing information like purchase history in the initial context or providing it as a tool for the agent to use during conversation, but users will expect that, if they’re logged in, the agent should know what they’ve purchased. The precise optimal context is likely to be specific to each implementation and customer experience and will require careful testing.</p></li></ul><h2><strong>How context engineering works before the LLM call</strong></h2><p>The pattern that makes this work is simple to describe and moderately involved to implement:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt017dd46a26cf7bc6/6a6119f6f81792b87a07f605/e814160fc7e599aa3cd1603be366cc726114948f-1024x559.png" alt="Diagram of context engineering for AI shopping agents: a context layer resolves vocabulary, policy, facets, profile and session signals in parallel before the LLM call" /><p>Without this context build, the agent makes the same discoveries on its own, but through LLM-driven tool calls that each cost a full inference round trip. As a rough rule of thumb, an exploratory tool (for example, GetFilterValues) call tends to land somewhere in the region of 600ms to 1 second of latency in practice; an agent that discovers the vocabulary, checks policy hints, and retrieves facet state through three separate tool calls before it even begins answering can, therefore, add two to three seconds to the response time, and that's before it performs the actual product search. These are order-of-magnitude estimates, not benchmarked figures, and the real numbers depend heavily on the model, the network path, and how the tools are implemented.</p><p>Replacing those discovery calls with a parallel fetch (multiple context-building queries can be done in parallel) that runs before the LLM is invoked removes most of that cost. The context build takes roughly the same wall time as a single LLM tool call, but it replaces three or four of them. The LLM would either need to repeat failed searches or do its own context-building tool calls sequentially to get enough context to be successful. Precomputing context is also deterministic rather than subject to the model's tool-selection choices, which gives the business more control to fine-tune the experience.</p><p>The token reduction follows the same logic: Each exploratory tool call returns raw data the model must process. A preassembled context summary replaces that raw data with structured facts the model can consume in one pass. At the scale of usage possible in public-facing retail websites, this token cost saving can be significant. This work on document search (<a href="https://www.elastic.co/search-labs/blog/pre-computed-context-llm-agent-costs">Cutting agent costs with pre-computed context</a>) showed up to 75% input token reduction on a controlled benchmark. That benchmark was document retrieval rather than ecommerce, and its authors are explicit that the multiplier isn't a fixed number you can expect everywhere. We expect the direction to hold in ecommerce, because the exploration pattern is the same; it just runs against a structured catalog rather than a document corpus. The magnitude is something each team should measure against its own traffic.</p><h3><strong>The AI shopping agent with full context</strong></h3><p>Remember the query that left you guessing: an outfit for an autumn wedding. Run it again, but this time, before you have to think, you’re handed a precomputed context as a short brief:</p><ul><li><p>This shopper’s name is Sarah, female, age 32, and she buys womenswear, size 12.</p></li><li><p>She typically buys your mid-tier ranges.</p></li><li><p>"Autumn" here matches these specific color labels: “rust”, “burgundy”, “forest green”, “camel”.</p></li><li><p>Matching departments: “Womenswear”, “Menswear”.</p></li><li><p>Matching tags: "occasion dresses”, “trouser suits”, “wedding”.</p></li><li><p>She already has a burgundy bag in her basket.</p></li><li><p>House rule for wedding-guest looks: Complete the outfit. Show hats and accessories, not just the dress.</p></li></ul><p>Suddenly, you’re not guessing; you’re styling for this customer. And the interesting part is how those facts combine rather than just stack. The season proposes a whole autumn palette; the burgundy bag already in her basket narrows that palette to the few tones that coordinate with it; the house rule tells you to finish the look with a matching fascinator rather than stopping at the dress. Together, these facts let you answer like someone who knows both this customer and this shop, in a single pass, with nothing invented.</p><p>That short brief is exactly what the signal stack produces: the shopper's profile, the resolved vocabulary, the live basket, and the business policy. These are assembled in parallel and placed in front of the agent before its first move, so the "<em>What do I even do with this?</em>" problem never has to be solved one expensive tool call at a time.</p><h2><strong>Governing the context layer without automated drift</strong></h2><p>One difference between the approach described here and automated knowledge extraction systems is worth addressing directly: In ecommerce, the context index cannot self-update without human review.</p><p>The policies that govern how an agent responds to gift queries, alcohol queries, or queries from customers in certain age brackets aren't just relevance configurations; they're trading decisions with potential legal and brand implications. An automated system that generates new policies from traffic patterns, without review, is a compliance risk before it's a technical asset.</p><p>This is actually the right constraint for most retail organizations, and it aligns with how search teams already work. Merchandisers write boost rules. Search teams maintain synonym configurations. Content teams approve what language appears in automated recommendations. The context policy layer is the same kind of governed configuration; it just serves a different consumer, namely, the agent's reasoning step rather than the query pipeline.</p><p>It's worth noting where this differs from the document-retrieval work referenced earlier. In that experiment, the biggest accuracy gain came from an automated feedback loop that fed the agent's wrong answers straight back into the extractor. That works well for factual question-answering, where "right" and "wrong" are unambiguous. In ecommerce, the equivalent signals still surface automatically, but a human decides what to do with them, because the changes carry trading and compliance weight. The loop is the same shape; the publication step has a person in it.</p><p>The governance loop that works in practice has two tiers:</p><ul><li><p><strong>Automatic signal surfacing:</strong> Zero-result queries, repeated reformulations on the same topic, and sessions that end without a purchase after an agent interaction are all signals that something in the context layer is missing or wrong. These surface automatically as candidates for improving the experience, for example: a vocabulary term that didn't resolve or a policy that didn't fire on a query type it should have covered. To do this, you need a thorough log of conversations, including the reasoning and tool call trace in a platform like Elastic. This allows you to analyze the performance of the agent using both structured tools, for example, percentage increase in thumbs-down conversations and semantically. You could also run an automated review of conversations about "gifts" around December to characterize the thumbs-up/down ratio across an AB test of two agents.</p></li><li><p><strong>Human authorship and review:</strong> The search or merchandising team reviews candidates and authors the appropriate vocabulary entry or policy. Policies go through approval before publication. This typically mirrors the workflow that already exists for synonym changes or boost rule modifications; the tooling is the only new part.</p></li></ul><h2><strong>How to implement context engineering in phases</strong></h2><ul><li><p><strong>Phase 1: Vocabulary layer</strong> (highest leverage, bounded engineering task).</p></li><li><p><strong>Phase 2: Facet state and initial policies</strong> (leans on the same catalog and retrieval primitives).</p></li><li><p><strong>Phase 3: User profiles and session signals</strong> (requires CRM and analytics integration; best added when the agent is active).</p></li><li><p><strong>Phase 4: Governed feedback loop</strong> (shifts to organizational alignment; surfaces gaps for merchandising teams).</p></li></ul><p>The full signal stack described above doesn't need to be built at once, and the order isn’t arbitrary. The highest-leverage starting point is also the lowest in implementation complexity: the vocabulary layer. A semantic metadata index built from a full-catalog aggregation (canonical color values, brand aliases, category paths, field names) is a bounded engineering task, and an agent that can resolve "navy jumper" to color: NAVY, masterCategoryNames: "Knitwear &amp; Jumpers" before its first tool call is materially better than one that discovers this through trial and error. If you build nothing else, build this.</p><p>Facet state and the first set of policies can follow close behind, often in parallel, because they lean on the same catalog and the same retrieval primitives. The later layers, such as user profiles, session signals, and the governed feedback loop, are where the work shifts from search engineering to organizational alignment. To implement CRM system integration, merchandising workflow changes, and the analytics needed to surface gaps can take a significant amount of work. Those layers are more valuable once the agent is already in regular use and generating the traffic signals that make the governed loop worth running. The important feature is that each layer stands on its own, so a retailer gets real value from phase one without committing to phase six.</p><h2><strong>What infrastructure does a context layer need?</strong></h2><p>Precomputing context at the depth described here places specific requirements on the underlying platform. It's worth being explicit about these, because the temptation in early agent builds is to reach for the simplest available tool for each capability.</p><ul><li><p><strong>Semantic search</strong> to match natural language queries against the vocabulary index and surface the right canonical values. Fuzzy keyword matching alone won't resolve ambiguity between similar brand names or color terms.</p></li><li><p><strong>A percolator</strong> to implement the policy layer. A percolator reverses the usual search direction: Instead of matching a query against stored documents, it stores the queries and matches an incoming piece of text (here, the customer's message) against them. That's exactly what policy matching needs, because each policy is essentially a saved pattern that says "When a query looks like this, surface this hint."</p></li><li><p><strong>Real-time aggregations</strong> over the full product catalog to produce accurate facet state at query time. Precomputed facet snapshots go stale quickly in active catalogs; query-time aggregations are the more reliable source.</p></li><li><p><strong>Document retrieval by key</strong> for user profiles: fast, single-document lookups by user ID that must complete within the context build window.</p></li><li><p><strong>Structured and semantic logging and analytics</strong> over query traces and agent interactions, which are the raw material for the governed loop's automatic signal surfacing.</p></li></ul><p>These are standard capabilities of a mature search and analytics platform, rather than six separate systems, and Elasticsearch provides all of them in one place. That matters less as a procurement point than as an architectural one: When semantic matching, percolation, aggregations, profile lookups, and analytics all run against the same catalog in the same cluster, the context layer stays consistent with the search layer by construction. Splitting these across a separate vector store and a separate analytics platform is a legitimate choice, but it adds operational surface and introduces a consistency problem between two systems that are reasoning about the same products. The context infrastructure is simplest to run when it lives where the product data already lives and can be updated without a separate extract, transform, load (ETL) step.</p><h2><strong>Conclusion: Context engineering is a search team's job to own</strong></h2><p>The retailers who run effective AI shopping experiences at scale aren't the ones with the largest models or the most generous token budgets. They're the ones who have done the work to make their catalog, vocabulary, and tpolicies legible to an agent before it starts reasoning.</p><p>The good news is that most of the work is already done. The vocabulary is implicit in the catalog. The policies exist as merchandising rules and compliance guidelines. The user profiles are in the CRM. The session signals are in the analytics stream. The gap isn't data; it's the assembly layer that turns those signals into a structured context the agent can consume before it starts reasoning.</p><p>The search team already owns the vocabulary, policies, and merchandising workflows. The context layer is the right home for work the search team is already doing, in a form that serves the agent as well as the query pipeline. And, because it grows every time a gap is found and filled, it behaves less like a setup cost and more like an asset that compounds.</p><p>To begin building a context layer in Elastic, you can start a <a href="https://www.elastic.co/cloud?utm_campaign=G-TXT-EMEA-UK+CA-Core-EN-Lead_Gen-CloudTrials-BR&amp;utm_content=Brand-Cloud&amp;utm_source=google&amp;utm_medium=cpc&amp;device=c&amp;utm_term=elastic%20cloud%20trial&amp;utm_id=701610000005lJVAAY&amp;gad_source=1&amp;gad_campaignid=22979576770&amp;gbraid=0AAAAADrDgoJnVYpNJwbmfVxoTcZSmr4S8&amp;gclid=CjwKCAjwx7LSBhB3EiwAjcodxPrvWRgCciehjf-6cu_sOb7FxbwDEJiS8Dpl95oQo7D2J61zXLJrgRoCtgQQAvD_BwE">trial of Elastic Cloud</a> or <a href="https://www.elastic.co/docs/deploy-manage/deploy/self-managed/local-development-installation-quickstart">run locally</a>. You should become familiar with configuring <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a>, and if you’re interested in how to build, store, and match search policies at query time, you will enjoy <a href="https://www.elastic.co/search-labs/blog/elasticsearch-percolator-search-governance">this blog</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/ai-shopping-agents-context-engineering</guid>
    <category><![CDATA[Agentic AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Matthew Adams]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt360319cdeac21a74/6a6119f81b1d49b36a6f1823/8aaf734953fe5ae677062bf06aef7352f186657c-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[How to measure and improve Elasticsearch search recall: from 0.43 to 0.75 with hybrid search]]></title>
    <description><![CDATA[Learn how to measure and improve search recall in Elasticsearch by combining BM25 lexical search with Jina AI vector embeddings, using the rank_eval API to validate the improvement with real numbers.]]></description>
    <content:encoded><![CDATA[<p><a href="https://www.elastic.co/docs/solutions/search/full-text">Lexical search</a> using the <a href="https://www.elastic.co/blog/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch">BM25 ranking algorithm</a> is cheap, fast, and very effective for a wide range of queries. But it has a blind spot: queries that don't share tokens with your documents. In this article, you’ll measure exactly where BM25 falls short. We'll use Elasticsearch's <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval">ranking evaluation API</a> (<code>rank_eval</code>) and close that gap by adding <a href="https://www.elastic.co/search-labs/es/blog/jina-embeddings-v3-elastic-inference-service">Jina AI embeddings</a> through <a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">Elastic Inference Service</a> (EIS). You’ll see the recall score go from <code>0.43</code> to <code>0.75</code> and understand why.</p><h2>What is recall?</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall">Recall</a> measures on a scale from <code>0</code> to <code>1</code> how many of the documents that your users actually want appear somewhere in your search results. If a query should surface three products and your search returns only two of them in the top 10, <code>recall@10 = 0.67</code> for that query. It’s a set-based metric: It doesn’t care about the position of the relevant documents within those <em>k</em> results. A relevant document in position 10 counts the same as one in position 1. Having a high recall means that you’re not losing relevant results.</p><p>
</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5ffd147b13705680/6a170a6fe8fbce11a539fc22/b13af2a5d0ca055535d8bfe3dfe4b3d1093ee6da-1457x796.png" alt="Venn diagram illustrating how Recall@10 is calculated by showing the overlap between all relevant documents and the top 10 results retrieved by BM25, resulting in a Recall@10 score of 0.40." /><p>The diagram shows two sets: all relevant documents (left) and what BM25 actually retrieved (top 10, right). Only the intersection counts toward recall, <code>prod_1</code> and <code>prod_2</code> were found, while <code>prod_3</code>, <code>prod_4</code>, and <code>prod_6</code> were missed entirely. Result: <code>Recall@10 = 2/5 = </code><strong><code>0.40</code></strong>.</p><h2>Prerequisites</h2><p>Let's get down to business to better understand how recall works. This demonstration uses Python. You can follow along with it on the companion notebook (<a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/notebook.ipynb">notebook.ipynb</a>), where every code block is a cell ready to run.</p><p>The code provided uses the following:</p><ul><li><p>Elasticsearch 9.3+</p></li><li><p>Python 3.10+</p></li></ul>pip install elasticsearch pandas plotly python-dotenv<ul><li><p>A <code>.env</code> file with your Elasticsearch credentials</p></li></ul>ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-key<h2>The dataset</h2><p>We’ll use a product catalog of 1,000 products, spanning categories such as footwear, electronics, tools, and more.</p><p>Each document has four fields:</p><p>Field</p><p>Type</p><p>`title`</p><p>text</p><p>`description`</p><p>text</p><p>`brand`</p><p>keyword</p><p>`category`</p><p>keyword</p><p>The dataset is loaded from <a href="https://github.com/elastic/elasticsearch-labs/blob/main/supporting-blog-content/relevance-tuning-improving-recall-adding-vectors/dataset.csv"><code>dataset.csv</code></a>.</p><h2>The power and limits of lexical search</h2><p>BM25 is the default ranking algorithm in Elasticsearch and most search engines. It scores documents by how often your query terms appear in them, adjusted for document length and the frequency of those terms across the entire index. You get <a href="https://www.elastic.co/docs/reference/text-analysis/analyzer-reference">analyzers</a> on top: lowercase normalization, stemming, and stopword removal. A query for "running shoes" will match "Running Shoes" and likely "run" as well.</p><p>This works well for a large class of queries:</p><ul><li><p>"running shoes" immediately matches products with those exact tokens in the title.</p></li><li><p>"bluetooth speaker" surfaces portable audio products because the tokens appear verbatim.</p></li></ul><p>The results are deterministic and explainable: A document ranks highly because the query terms appear in it. Debugging relevance is straightforward.</p><h3>Where it breaks</h3><p>Now let’s try these queries against the same catalog:</p><ul><li><p><strong>"skincare routine":</strong> The word "routine" doesn’t appear in any product title. BM25 can partially match on "skincare," but face serums, body oils, and moisturizers are described using terms like "vitamin C," "retinol," or "brightening," none of which overlap with the query. Products that form a complete skincare routine are scattered across the index with no shared token to anchor them.</p></li></ul>ID: B06XX6DS3P, Score: 9.0552, Title: Replenix Retinol Smooth + Tighten Body Lotion - Collagen-Boosting, Regenerating Anti-Aging Body Cream, Reduces Appearance of Stretch Marks, 6.7 oz.

  ID: B08XMPKJ1L, Score: 5.2699, Title: Bio-Oil Skincare Body Oil (Natural) Serum for Scars and Stretchmarks, Face and Body Moisturizer Hydrates Skin, with Organic Jojoba Oil and Vitamin E, For All Skin Types, 6.7 oz

  ID: B01CY764KQ, Score: 5.0057, Title: Nike Up Or Down Men Deodorant - Pack of 2 | Long-Lasting Fragrance, Body Spray Combo for Men | Deodorant for Active Living | Nike Men's Deo Set | Ultimate Odor Protection | Grooming Essentials | Signature Nike Scent | High-Performance Men's Deodorant<ul><li><p><strong>"pet travel accessories":</strong> This is a use-case grouping, not a product category. A dog sling carrier, a pet car seat, and a travel crate are all relevant, but their descriptions talk about portability, safety, and comfort rather than "travel accessories." BM25 matches "pet" broadly but has no signal to distinguish travel-specific products from the rest of the pet catalog.</p></li></ul>ID: B0BVV7BKTW, Score: 7.4371, Title: Large Foldable Travel Duffel Bag with Shoes Compartment

ID: B07TNPHYNV, Score: 6.6455, Title: 40 Pieces Christmas Bronze Jingle Bells Craft Small Bells

ID: B08R8FRW53, Score: 6.6335, Title: CUBY Dog and Cat Sling Carrier
ID: B08QMCQYGM, Score: 6.5259, Title: YTFGGY Whiteboard Pinstripe Tape 6 Rolls 1/8"
ID: B0CP3LQSWM, Score: 6.2994, Title: Portable Dog Water Bottle 32 Oz<p>This is a <strong>recall problem</strong>. The relevant documents exist in your index. BM25 just cannot find them because the user's words and the document's words do not match closely enough.</p><p>Adding synonyms helps for known cases. But you cannot enumerate every way a user might express an intent. That is where vectors come in.</p><h2>Why you should measure recall</h2><p>Before fixing a problem, you need to quantify it.</p><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-recall"><strong>Recall@k</strong></a> measures how many of the documents that your users actually want appear somewhere in your search results. Formally:</p>Recall@k = (relevant documents found in top k) / (total relevant documents)<p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/search-rank-eval#k-precision"><strong>Precision@k</strong></a> measures the top k results and how many are actually relevant:</p>Precision@k = (relevant documents in top k) / k<p>High precision means that the results you do return are good. In ecommerce, missing a relevant product (low recall) is often worse than showing a slightly imperfect result (lower precision), because a hidden product is a lost sale.</p><p>Elasticsearch's <code>rank_eval</code> API lets you measure both systematically. You provide a list of queries, each with a set of rated documents, and Elasticsearch computes the metrics for you across all queries.</p><h2>Setting up the evaluation</h2><p>The <code>rank_eval</code> API needs a <strong>ratings dataset</strong>: a mapping of queries to the documents that are relevant for each one, along with a relevance grade (0 = not relevant, 1 = relevant, 2 = highly relevant).</p><p>In the notebook, this is the <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr#learning-to-rank-judgement-list">judgments list</a>:</p>judgments = [
    # Query 1: "running shoes" BM25 handles well (tokens appear in product titles) 
    {"query_id": "q1", "doc_id": "B09NQJFRW6", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08JMD4LMM", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B08VRJ6F2Q", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07S8NRRWR", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01HD620I8", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B07DX86321", "grade": 2, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B0968YVLQ8", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B093QJ39ZS", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B096FGSC39", "grade": 1, "query": "running shoes"},
    {"query_id": "q1", "doc_id": "B01GVQWVV2", "grade": 1, "query": "running shoes"},

    # Query 2: "skincare routine" intent-based, "routine" never appears in product titles
    {"query_id": "q2", "doc_id": "B08XMPKJ1L", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BN3WQB92", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B0BT7B7P5T", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00NPA2WEY", "grade": 2, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B06XX6DS3P", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B07PDRD1KT", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B074J7869B", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B08JV31QW4", "grade": 1, "query": "skincare routine"},
    {"query_id": "q2", "doc_id": "B00K3TVJMQ", "grade": 1, "query": "skincare routine"},

    # Query 3: "study desk setup" intent-based, products are desks/stands/organizers
    {"query_id": "q3", "doc_id": "B08CS35J2T", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B09B3LFDXJ", "grade": 2, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B07W58LMND", "grade": 1, "query": "study desk setup"},
    {"query_id": "q3", "doc_id": "B0CHYDX91L", "grade": 1, "query": "study desk setup"},

    # Query 4: "pet travel accessories" use-case grouping, products are carriers/crates/seats
    {"query_id": "q4", "doc_id": "B08R8FRW53", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B01MYUYX33", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B003C5RKE4", "grade": 2, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B09GF8GBF6", "grade": 1, "query": "pet travel accessories"},
    {"query_id": "q4", "doc_id": "B0CP3LQSWM", "grade": 1, "query": "pet travel accessories"},
]<p>The mix is intentional: <code>q1</code> is a query that BM25 handles well (exact tokens in product titles), while <code>q2</code>, <code>q3</code>, and <code>q4</code> are intent-based queries where the user's intent is expressed as a concept rather than specific product keywords.</p><h2>Measuring BM25 baseline recall</h2><p>First, set up the Elasticsearch client and index the raw text data:</p>import os
import json
import pandas as pd
import plotly.graph_objects as go
from elasticsearch import Elasticsearch, helpers
from dotenv import load_dotenv

load_dotenv()

es = Elasticsearch(
    os.getenv("ELASTICSEARCH_URL"),
    api_key=os.getenv("ELASTICSEARCH_API_KEY")
)

INDEX_NAME = "ecommerce-products"<p>Now build the <code>rank_eval</code> request for BM25. Each request in the list combines a query with its ratings:</p>judgments_df = pd.DataFrame(judgments)

bm25_requests = []
for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    bm25_requests.append({
        "id": query_id,
        "request": {
            "query": {
                "multi_match": {
                    "query": query_text,
                    "fields": ["title", "description"]
                }
            }
        },
        "ratings": ratings,
    })

bm25_eval = {
    "requests": bm25_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

bm25_result = es.rank_eval(index=INDEX_NAME, body=bm25_eval)
print("BM25 Recall@10:", bm25_result.body["metric_score"])<p>Result:</p>BM25 Recall@10: 0.43<p><code>0.43</code> means that across all four queries, BM25 finds only 43% of the documents it should find. The shortfall is concentrated in the intent-based queries: "skincare routine" misses face serums and body oils because "routine" never appears in product titles, and "pet travel accessories" retrieves off-topic pet products while missing carriers and crates described in terms of portability and safety rather than "travel accessories."</p><p>This is our baseline. Now we have a number to beat.</p><h2>Adding vector search with Jina embeddings</h2><p><a href="https://www.elastic.co/docs/solutions/search/vector"><code>Vector search</code></a> encodes documents and queries as high-dimensional vectors, a type of vector made up of hundreds or thousands of numerical values, each encoding a specific feature of the data it represents. Documents with similar meaning end up close together in vector space, even if they share no words. "Gym equipment" and "dumbbell set" will be nearby because the concepts are related. I chose Elasticsearch as my vector database because it supports hybrid search, giving me both semantic understanding and keyword precision out of the box.</p><p><a href="https://www.elastic.co/docs/explore-analyze/elastic-inference/eis">EIS</a> includes out-of-the-box support for embedding models through its <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">inference API</a>.</p><h3>Step 1: Using Jina embeddings v5 as an inference endpoint</h3>INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"<p>If your cluster has GPU resources (available in Elastic Cloud and Elasticsearch 9.3+), the embeddings are generated on GPU, which is significantly faster than CPU inference and removes the performance trade-off that historically made vectors expensive at scale.</p><p>Why Jina embeddings specifically? <a href="https://www.elastic.co/search-labs/blog/jina-embeddings-v5-text">jina-embeddings-v5-text</a> is a multilingual model (119+ languages) with a 32,000-token context window and support for task-specific <a href="https://arxiv.org/abs/2106.09685">Low-Rank Adaptation (LoRA) adapters</a>. It works well for short product descriptions out of the box. Read more about <code>jina-embeddings-v5-text</code> model <a href="https://huggingface.co/jinaai/jina-embeddings-v5-text-small">here</a>.</p><h3>Step 2: Create the index with a semantic field</h3>index_mappings = {
    "mappings": {
        "properties": {
            "title": {"type": "text", "copy_to": "semantic_field"},
            "description": {"type": "text", "copy_to": "semantic_field"},
            "brand": {"type": "keyword"},
            "category": {"type": "keyword"},
            "semantic_field": {
                "type": "semantic_text",
                "inference_id": INFERENCE_ENDPOINT_ID,
            },
        }
    }
}

if not es.indices.exists(index=INDEX_NAME):
    es.indices.create(index=INDEX_NAME, body=index_mappings)
    print(f"Created index: {INDEX_NAME}")<p>The <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text"><code>semantic_text</code></a> field type is the key here. It’s a higher-level abstraction over <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a>: You point it at an inference endpoint, and Elasticsearch takes care of generating embeddings automatically.</p><p>The <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/copy-to"><code>copy_to</code></a> property on <code>title</code> and <code>description</code> means content from both fields flows into <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_field</code></a> for embedding, so a single vector captures the full product representation.</p><h3>Step 3: Index the products</h3>def bulk_index(products, index_name):
    actions = []
    for product in products:
        doc_id = product.get("_id")
        source = {k: v for k, v in product.items() if k != "_id"}
        action = {"_index": index_name, "_source": source}
        if doc_id:
            action["_id"] = doc_id
        actions.append(action)

    success, failed = helpers.bulk(es, actions, raise_on_error=False)
    if failed:
        for error in failed:
            print(f"Error: {error}")
    else:
        print(f"Successfully indexed {success} documents")

bulk_index(products, INDEX_NAME)<p>At index time, Elasticsearch calls the inference endpoint for each document and stores the resulting embedding in <code>semantic_field</code>. No extra code on your side.</p><h2>Hybrid search: Combining BM25 and vectors with RRF</h2><p>Adding vectors improves recall, but using vectors alone risks losing precision on exact-match queries; "running shoes" should still rank verbatim matches first. Hybrid search retains the lexical component specifically to preserve that precision.</p><p>Hybrid search with <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">Reciprocal Rank Fusion</a> (RRF) keeps the best of both:</p><ul><li><p>BM25 handles exact and near-exact queries with high precision.</p></li><li><p>Semantic search handles intent-based and multilingual queries with high recall.</p></li><li><p>RRF combines the two ranked lists into a single ranking.</p></li></ul><p>The RRF formula assigns each document a score based on its rank in each result list:</p>score = sum(1 / (rank_constant + rank))<p>A document that ranks highly in both lists gets a higher combined score. The <code>rank_constant</code> controls how much weight lower-ranked documents receive.</p>hybrid_requests = []

for query_id, query_text in (
    judgments_df[["query_id", "query"]].drop_duplicates().values
):
    relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
    ratings = [
        {"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
        for _, row in relevant_docs.iterrows()
    ]

    hybrid_requests.append({
        "id": query_id,
        "request": {
            "retriever": {
                "rrf": {
                    "retrievers": [
                        {
                            "standard": {
                                "query": {
                                    "multi_match": {
                                        "query": query_text,
                                        "fields": ["title", "description"],
                                    }
                                }
                            }
                        },
                        {
                            "standard": {
                                "query": {
                                    "match": {
                                        "semantic_field": {"query": query_text}
                                    }
                                }
                            }
                        },
                    ],
                    "rank_window_size": 50,
                    "rank_constant": 5,
                }
            }
        },
        "ratings": ratings,
    })

hybrid_eval = {
    "requests": hybrid_requests,
    "metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}

hybrid_result = es.rank_eval(index=INDEX_NAME, body=hybrid_eval)
print("Hybrid Recall@10:", hybrid_result.body["metric_score"])<p>Result:</p>Hybrid Recall@10: 0.75<p>Hybrid improves substantially over BM25 (<code>0.43</code>) and preserves precision for exact-match queries like "running shoes."</p><h2>Results: Before and after</h2><p>Here’s the full comparison across all three approaches:</p>methods = {
    "BM25 (Lexical)": bm25_requests,
    "Hybrid (BM25 + Vectors)": hybrid_requests,
}

recall_metric = {"recall": {"k": 10, "relevant_rating_threshold": 1}}

comparison_data = []
for method_name, requests in methods.items():
    result = es.rank_eval(
        index=INDEX_NAME,
        body={"requests": requests, "metric": recall_metric}
    )
    comparison_data.append({
        "method": method_name,
        "recall@10": result.body["metric_score"]
    })

comparison_df = pd.DataFrame(comparison_data)
print(comparison_df.to_string(index=False))<p>Result:</p><p>Method</p><p>Recall@10</p><p>BM25 (Lexical)</p><p>0.43</p><p>Hybrid (BM25 + Vectors)</p><p>0.75</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt5a1d72b57056fe64/6a170a71c1e8a56c58f882ab/e49f6c10516b0a48a0ad75962c6590ee07311407-700x500.png" alt="Bar chart comparing Recall@10 between BM25 lexical search and hybrid search combining BM25 with vectors, showing hybrid search achieving significantly higher recall." /><p>Breaking it down by query:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt871347f754c866d0/6a170a73839dfa40abdcfeb4/40e36dcb7b34cbf4649c512bcb60cef60f1778a6-700x500.png" alt="Grouped bar chart comparing Recall@10 between BM25 lexical and hybrid search across four product queries, showing hybrid search consistently outperforming lexical search for each query." /><h2>Conclusion</h2><p>Throughout this post, we saw that BM25 lexical search is reliable when users type exact queries, but it loses recall when they search by intent rather than keywords. Using <code>rank_eval</code>, we established a reproducible baseline to measure that gap with real numbers. From there, we added a <code>semantic_text</code> field powered by Jina embeddings and ran the evaluation again. The result: Hybrid search improved recall from <code>0.43</code> to <code>0.75</code> while preserving precision on exact-match queries, though the actual margin will depend on your query mix.</p><p>The pattern scales beyond this example: Collect judgments from your users' actual queries, run <code>rank_eval</code> as a baseline, add <code>semantic_text</code>, and measure again. You'll know exactly what improved and by how much.</p><h2>Next steps</h2><ul><li><p>Dive deeper into recall and vector search: <a href="https://www.elastic.co/search-labs/blog/recall-vector-search-quantization">Recall and vector search quantization</a> by Jeff Vestal</p></li><li><p>Add reranking for even better precision on the top results</p></li><li><p>Explore <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html">Elasticsearch hybrid search documentation</a></p></li><li><p>Read more about the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"><code>rank_eval</code></a><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html"> API</a></p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-relevance-tuning-improve-recall</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Vector Database]]></category>
    <dc:creator><![CDATA[Jeffrey Rengifo]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt37c9d2971b5a2db3/6a170a75cf4f254223b2d149/492c9b5432a2b9e40cebb3b60f0df019a8c7bf6d-1280x720.png" length="0" type="image/png"/>
    <pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Entity resolution with Elasticsearch, part 4: The ultimate challenge]]></title>
    <description><![CDATA[Solving and evaluating entity resolution challenges in a highly diverse “ultimate challenge” dataset designed to prevent shortcuts.]]></description>
    <content:encoded><![CDATA[<p>We’ve now seen intelligent entity resolution implemented in two ways. Both approaches begin the same way: entity preparation and extraction, followed by candidate retrieval with Elasticsearch. From there, we evaluate those candidates using a large language model (LLM), either through prompt-based JSON generation or through function calling, and require the model to provide a transparent explanation for its judgment.</p><p>As we saw in the <a href="https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling">previous post</a>, the consistency provided by function calling is not just a nice optimization; it’s essential. Once we removed structural errors from the evaluation loop, results on standard scenarios (such as those in the tier 4 dataset) improved dramatically.</p><p>Yet there’s an obvious question left to answer:</p><p><em>Does this approach still work when things get genuinely messy?</em></p><p>Real-world entity resolution rarely fails because of simple cases. It fails when names cross languages, cultures, writing systems, time periods, and organizational boundaries. It fails when people are referenced by titles instead of names, when companies change names, when transliterations aren’t consistent, and when context (not spelling) is the only thing tying a mention to a real-world entity.</p><p>So, for the final post in this series, we put the system through what we called <strong>the ultimate challenge</strong>.</p><h2>What makes this the ultimate challenge?</h2><p>In earlier evaluations, we tested the system using increasingly complex datasets. By the time we reached tier 4, discussed in the previous post, we were already dealing with a mix of nicknames, titles, multilingual names, and semantic references. Those tests showed that the architecture itself was sound, but that reliability issues, especially malformed JSON, were suppressing recall.</p><p>With function calling in place, we finally had a stable foundation. That gave us the opportunity to ask a more interesting question:</p><p><em>Can one unified pipeline handle </em><em><strong>many different kinds</strong></em><em> of entity resolution problems at once?</em></p><p>The ultimate challenge dataset was designed to push precisely on that dimension.</p><p>Instead of focusing on a single difficulty (like nicknames or transliteration), this dataset combines <strong>50+ distinct challenge types</strong>, including:</p><ul><li><p>Cultural naming conventions.</p></li><li><p>Title-based references.</p></li><li><p>Business relationships and historical name changes.</p></li><li><p>Multilingual and cross-script mentions.</p></li><li><p>Compound challenges that mix several of the above.</p></li></ul><p>Crucially, this isn’t about optimizing for any one narrow use case. It’s about testing whether the <em>design pattern</em> holds up when the rules change from entity to entity.</p><h2>The dataset at a glance</h2><p>The ultimate challenge dataset consists of:</p><ul><li><p><strong>50 entities</strong>, spanning people, organizations, and institutions.</p></li><li><p><strong>~60 articles</strong>, with varying structure and linguistic complexity.</p></li><li><p><strong>51 distinct challenge categories</strong>, grouped broadly into:</p><ul><li><p>Cultural naming conventions.</p></li><li><p>Titles and professional context.</p></li><li><p>Business and organizational relationships.</p></li><li><p>Multilingual and transliteration challenges.</p></li><li><p>Combined and edge‑case scenarios.</p></li></ul></li></ul><p>Earlier in the series, we saw that using generative AI (GenAI) to create datasets can be a mixed blessing. Without it, assembling sufficiently large and diverse test data would be extremely difficult. But left unchecked, the model has a tendency to make things too easy.</p><p>On an early generation pass, for example, we discovered that the model had included phrases like “the Russian president” as explicit aliases for Vladimir Putin. That might seem reasonable today, but it defeats the purpose of testing contextual resolution. What happens if the article is discussing Russia in the 1990s? The system should infer the correct entity from context, not rely on a hard-coded alias.</p><p>For that reason, this dataset was deliberately designed so that <strong>shortcuts don’t work</strong>. Aliases are not explicitly listed when the system is expected to infer meaning. Descriptive phrases are not prelinked to entities. Correct matches often depend on article-level context, not just local text.</p><p><strong>Important note:</strong> Although we demonstrate the system’s capabilities across diverse scenarios, this is still an educational prototype. Production systems handling real-world sanctioned-entity monitoring would require additional validation, compliance checks, audit trails, and specialized handling for sensitive use cases.</p><h2>Why these scenarios are hard</h2><p>Back in the first post in this series, we introduced a simple but ambiguous example: “The new Swift update is here!” The challenge is that “Swift” can resolve to multiple real-world entities, depending on context. That example captures a broader truth: Natural language is inherently ambiguous.</p><p>Entity resolution, therefore, is not just a string-matching problem. Humans routinely rely on shared knowledge, cultural norms, and situational context to resolve references, and we rarely even notice we’re doing it.</p><p>Consider a few common cases:</p><ul><li><p>A title like “the president” is meaningless without geopolitical and temporal context.</p></li><li><p>A company name may refer to a parent, a subsidiary, or a former brand depending on when the article was written.</p></li><li><p>A person’s name may appear in different orders, scripts, or transliterations, depending on language and culture.</p></li><li><p>The same phrase can legitimately refer to different entities in different contexts, and the system must be able to <em>reject</em> matches just as confidently as it accepts them.</p></li></ul><p>There is no single rule set that handles all of this cleanly. That’s why this prototype separates concerns so aggressively:</p><ul><li><p>Elasticsearch narrows the candidate space efficiently and transparently.</p></li><li><p>The LLM is used only where judgment is required and is forced to explain itself.</p></li><li><p>Retrieval and reasoning remain distinct steps.</p></li></ul><p>This separation becomes even more important as the diversity of challenge types increases.</p><h2>How the system handles diversity without special cases</h2><p>One of the most interesting outcomes of this evaluation is what <em>didn’t</em> change:</p><ul><li><p>We did <strong>not</strong> add special logic for Japanese names.</p></li><li><p>We did <strong>not</strong> add custom rules for Arabic patronymics.</p></li><li><p>We did <strong>not</strong> add hard-coded mappings for historical company names.</p></li></ul><p>Instead, the system relied on the same core ingredients introduced earlier in the series:</p><ul><li><p>Context-enriched entities indexed for semantic search.</p></li><li><p>Hybrid retrieval (exact, alias, and semantic) in Elasticsearch.</p></li><li><p>A small, well-defined set of candidate matches.</p></li><li><p>LLM judgment constrained by function calling and minimal schemas.</p></li></ul><p>This suggests that the system’s flexibility comes from <strong>representation and architecture</strong>, not from an ever-growing collection of rules.</p><p>When the system succeeds, it’s because the right candidates are retrieved and the LLM has enough context to explain why a reference does (or does not) map to a specific entity.</p><h2>Results: How did it perform?</h2><p>On the ultimate challenge dataset, the system produced the following overall results:</p><ul><li><p><strong>Precision:</strong> ~91%</p></li><li><p><strong>Recall:</strong> ~86%</p></li><li><p><strong>F1 Score:</strong> ~89%</p></li><li><p><strong>LLM acceptance rate:</strong> ~72%</p></li></ul><h3>Performance across challenge types</h3><p>Breaking down results by challenge type reveals strengths and limitations:</p><p><strong>Strongest performance (100% F1 score)</strong> was observed in areas such as:</p><ul><li><p>Cross-script matching (Cyrillic, Korean, Chinese business entities).</p></li><li><p>Hebrew scenarios (patronymics, professional titles, religious titles, transliteration).</p></li><li><p>Business hierarchies (aerospace, diversified manufacturing, multidivision corporations).</p></li><li><p>Professional titles (academic, military, political, religious).</p></li><li><p>Combined Japanese scenarios involving multiple writing systems.</p></li></ul><p><strong>Strong performance (80–99% F1 score)</strong> included:</p><ul><li><p>International political figures (98%).</p></li><li><p>Historical name changes (90%).</p></li><li><p>Complex business hierarchies (89%).</p></li><li><p>Japanese company names (93%).</p></li><li><p>Cross-script transliteration (86%).</p></li><li><p>Arabic patronymics (86%).</p></li></ul><p><strong>More challenging areas</strong> included:</p><ul><li><p>Advanced transliteration (Chinese, Korean): 0% F1.</p></li><li><p>Certain Japanese scenarios (honorifics, name order, writing system variation): ~67% F1.</p></li><li><p>Some Arabic scenarios (company names, institutional references): ~40% F1.</p></li></ul><p>What’s important here is <em>why</em> the system struggled in these cases. The failures were not due to the overall approach breaking down, but to limitations in specific components, most notably the dense vector model used for semantic search in certain multilingual scenarios.</p><p>Because retrieval and judgment are cleanly separated, improving performance does not require rewriting the system. Swapping in a more capable multilingual embedding model, enriching entity context, or refining retrieval strategies would improve results across these categories without changing the core architecture.</p><p>From an architectural standpoint, that’s the real success metric.</p><h2>What this tells us about the design</h2><p>Looking back across the series, a few patterns stand out:</p><ul><li><p><strong>Preparation matters more than clever matching. </strong>Enriching entities with context up front dramatically reduces ambiguity later.</p></li><li><p><strong>LLMs are most valuable as judges, not retrievers. </strong>Asking them to explain <em>why</em> a match makes sense is far more powerful than asking them to search.</p></li><li><p><strong>Reliability enables accuracy. </strong>Function calling didn’t just clean up JSON; it unlocked recall that was already latent in the retrieval step.</p></li><li><p><strong>Generalization beats specialization. </strong>A small number of well-chosen abstractions handled dozens of challenge types without custom logic.</p></li></ul><p>This is why the prototype is intentionally Elasticsearch-native and intentionally conservative in how it uses LLMs. The goal isn’t to replace search; it’s to make search explainable in situations where meaning matters.</p><h2>Final thoughts</h2><p>The ultimate challenge wasn’t about chasing perfect metrics; it was about answering a more fundamental question:</p><p><em>Can a transparent, search-first, LLM-assisted architecture handle real-world entity ambiguity without collapsing into rules or black boxes?</em></p><p>For this educational prototype, the answer is yes, with clear caveats around production hardening, compliance, monitoring, and data quality. If you’re building systems that need to justify <em>why</em> an entity match was made, this pattern is worth serious consideration. I hope this series has shown that entity resolution doesn’t have to be mysterious. With the right separation of concerns, it becomes something you can reason about, measure, and improve.</p><p>This work also suggests a broader architectural pattern. What emerges is a slight but important evolution of classic retrieval augmented generation (RAG). Instead of allowing retrieval to feed generation directly, we introduce an explicit evaluation step. The LLM is first used to judge and sanity-check retrieved candidates, and only those approved results are allowed to augment generation. You can think of this as Generation-Augmented Retrieval-Augmented Generation with Evaluation, or GARAGE, because who doesn’t love a good acronym.</p><p>What other use cases could benefit from this pattern? Systems that require trust, transparency, and defensible reasoning are natural candidates. Future work in this area should prove as compelling as the results we’ve seen here, and I’m excited to see where the community takes it next.</p><h2>Next steps: Try it yourself</h2><p>Want to see the ultimate challenge in action? Check out the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=5%20minutes%20ago-,05_ultimate_challenge_v3.ipynb,-Initial%20public%20lab"><strong>Ultimate Challenge notebook</strong></a> for a complete walkthrough, with real implementations, detailed explanations, and hands-on examples.</p><p>The complete entity resolution pipeline demonstrates the core concepts and architecture needed for production use. You can use it as a foundation to build systems that monitor news articles, track entity mentions, and answer questions about which entities appear in which articles, all while retaining transparency and explainability.
</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/entity-resolution-elasticsearch-llm-challenges</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/entity-resolution-elasticsearch-llm-challenges</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltc58be329ffebcd60/6a17043e47d49c0bc62d88ab/70fb0ff949f6db9ac9b8a28ecb4329ab915ebf46-720x420.png" length="0" type="image/png"/>
    <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Hybrid search with Java: LangChain4j Elasticsearch integration]]></title>
    <description><![CDATA[Learn how to use hybrid search in LangChain4j via its Elasticsearch integrations, with a complete Java example.]]></description>
    <content:encoded><![CDATA[<p>In our <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> on hybrid search with Elasticsearch in LangChain, we explained why hybrid search can help retrieve better results than simple vector search, along with how it works. We recommend reading that article first.</p><p>In addition to Python and JavaScript, the LangChain ecosystem also has a community-driven Java project called <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>, which will be the focus of this article, showing how powerful hybrid search can be by writing a complete application using LangChain4j, Elasticsearch, and Ollama.</p><h2>Setting up the environment</h2><h3>Running a local Elasticsearch instance</h3><p>Before running the examples, you'll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><p>&gt; <strong>Note: This script is for local testing only. Do not use it in production. For production installations, refer to the </strong><a href="https://www.elastic.co/downloads/elasticsearch"><strong>official documentation</strong></a><strong> for Elasticsearch.</strong></p><h3>Running a local Ollama instance</h3><p>You’ll also need to connect your application to an embedding model. Although you can choose between any provider supported by LangChain4j (check the <a href="https://docs.langchain4j.dev/integrations/language-models/">complete list</a>), for this example we’ll be using Ollama, which can be easily set up locally following the <a href="https://docs.ollama.com/quickstart">quickstart</a>.</p><h2>Let’s start coding</h2><p>The idea for the application is simple: Given a dataset of movies (taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>), we want to be able to find movies whose descriptions are relevant to our queries. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub repo</a>, along with the full code for this demo.</p><h2>Step 1: Dependencies and environment</h2><p>Open your favorite integrated development environment (IDE), create a new blank project, preferably with a modern Java version (we’re using Java24) and a gradle/maven version to match (in our case, Gradle 9.0).</p><p>We only need three dependencies:</p>dependencies {
    implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.17.0")
    implementation("dev.langchain4j:langchain4j-elasticsearch:1.11.0-beta19")
    implementation("dev.langchain4j:langchain4j-ollama:1.11.0")
}<p>The first one is needed to ingest the data that we’ll embed and query; the other two are the necessary LangChain4j dependencies to connect and manage our Elasticsearch vector store and Ollama embedding model.</p><p>The best way to connect to the external services is to set up environment variables and set them at the start of our main function:</p>String elasticsearchServerUrl = System.getenv("ES_LOCAL_URL");
String elasticsearchApiKey = System.getenv("ES_LOCAL_API_KEY");

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

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

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

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

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

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

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

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

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

System.out.println("Hybrid search results:");
hybridSearchResult.forEach(v -&gt; System.out.println(v.textSegment().metadata().getString(
            "movie_name")));Hybrid search results:
Edge of Tomorrow
The Witch: Part 1 - The Subversion
Boss Level
Divinity
The Maze Runner<h2>Why these results?</h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li><p>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped / escape) can appear.</p></li><li><p>Hybrid (BM25 + kNN + reciprocal rank fusion [RRF]) rewards documents that match keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</p></li></ul><p>Hybrid search doesn’t guarantee that every result is perfect; it balances lexical and semantic signals, so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords. Check the <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a> for more information on how hybrid search works.</p><h2>Full code example</h2><p>You can find the full demo code on <a href="https://github.com/elastic/hybrid-search-elastic-langchain4j">GitHub</a>.</p><h2>Conclusion</h2><p>In this article, we demonstrated how to use hybrid search in LangChain4j through its Elasticsearch integrations, with a complete Java example. This article is an extension of a <a href="https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search">previous article</a>, which presents the LangChain integrations for Python and JavaScript and introduces and explains hybrid search. We’re planning to continue our collaboration with LangChain4j in the future by contributing to the embedding models with our Elasticsearch <a href="https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-inference">Inference API</a>.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain4j-elasticsearch-hybrid-search</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <category><![CDATA[Java]]></category>
    <dc:creator><![CDATA[Laura Trotta]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt63218799e944e16d/6a1710ef6f7f04ee68914952/93d8e0d84fb4cfbf5e51df85df7ec2e600d9dcc7-1088x607.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Entity resolution with Elasticsearch, part 3: Optimizing LLM integration with function calling]]></title>
    <description><![CDATA[Learn how function calling enhances LLM integration, enabling a reliable and cost-efficient entity resolution pipeline in Elasticsearch.]]></description>
    <content:encoded><![CDATA[<p>In <a href="https://www.elastic.co/search-labs/blog/entity-resolution-llm-elasticsearch">part 1</a> and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-semantic-search">part 2</a> of this series, we built a complete entity resolution pipeline that included preparing entities with context and indexing them for semantic search, extracting entities from articles using hybrid named entity recognition (NER), and matching entities using semantic search and large language model (LLM) judgment. The results were promising, but JSON parsing errors significantly lowered measured accuracy by causing otherwise valid judgments to be discarded. The system wasn’t failing because it made bad judgments; it was failing because it couldn’t reliably express them.</p><p>The root of this problem was our somewhat naive choice to use prompt-based JSON generation in which the LLM generates JSON responses in text format. If we asked the LLM to judge more than a couple of matches at a time, the generated JSON was often ill-formed. To mitigate this, we were forced to reduce the processing batch size, which simply won't scale in a production system.</p><p>So the prompt-based JSON generation helped validate our approach to entity resolution, but we need a more systematic and reliable method. OpenAI function calling provides a better path by guaranteeing structure and type safety while reducing errors and costs. We chose OpenAI's functions for the educational prototype, but other LLM providers typically provide similar functionality (for example, Claude tools).</p><p><strong>Note:</strong> While we discuss production challenges here, this is still an educational prototype demonstrating optimization techniques. Real production systems would need additional considerations, like monitoring, alerting, fallback strategies, and comprehensive error handling.</p><h2>Key concepts: Function calling, schema design, and cost benefits</h2><p><strong>What is function calling?</strong> <em>Function calling</em> is OpenAI's structured output API. With it, we can define schemas for LLM responses, so we always know exactly what we're going to get. By enforcing the JSON format rather than trying to define it in the LLM prompt, we should be able to eliminate parsing errors.</p><p><strong>Why is it better than prompt-based JSON?</strong> LLMs generate nondeterministic output. One hopes that they'll at least generate content that contains the correct response, but the presentation of that response is unpredictable. With a chatbot, this is often not a problem, but our prototype is trying to programmatically process the output. Computer programs demand consistency, so when the LLM generates what we expect, everything is fine, but as soon as it goes off script, so to speak, the code errors out. We could try to account for the different possibilities, but it would be very difficult to catch everything. We could try to enforce more consistent behavior by adding something like "Always return parsable JSON". We tried this exact technique in the prototype's prompt, but we've seen that prompt-based JSON still goes off the rails pretty quickly, particularly if we try to process a batch of matches.</p><p>Function calling makes the LLM generation controllable and predictable, exactly what we need for entity resolution. To aid in the definition of the functions, we’ll also follow minimal schema design principles.</p><p><strong>What are minimal schema design principles?</strong> <em>Minimal schema design</em> means defining only the fields you need, using simple types, and avoiding nested structures when possible. This reduces token usage (smaller schemas mean fewer tokens), improves reliability (simpler schemas are easier for the LLM to follow), and lowers costs (fewer tokens mean lower API costs).</p><p><strong>What are the cost and reliability benefits?</strong> Since fewer errors means match processing is much more likely to succeed, even with large batch sizes, we don't have to retry judging matches. The elimination of retries reduces costs by reducing token usage, but using minimal schemas also keeps our token count down. This all leads to a less expensive and more reliable approach that’s much more suitable to use in production.</p><p>We need to check one more thing, though. While matches may be getting processed without error, are the errorless results actually correct? How does this new approach compare to the promising results we saw with the prompt-based approach?</p><h2>Real-world results: Side-by-side comparison</h2><p>As we did in the previous blog, we ran the function calling approach against the tier 4 dataset, which consists of 206 expected matches across 69 articles. The results demonstrate a dramatic improvement:</p><p>Metric</p><p>Prompt-based</p><p>Function calling</p><p>Improvement</p><p>Error rate</p><p>30.2%</p><p>0.0%</p><p>100% elimination</p><p>Precision</p><p>83.8%</p><p>90.3%</p><p>+6.5pp</p><p>Recall</p><p>62.6%</p><p>90.8%</p><p>+28.2pp</p><p>F1 score</p><p>71.7%</p><p>90.6%</p><p>+18.9pp</p><p>Acceptance rate</p><p>44.8%</p><p>60.2%</p><p>+15.4pp</p><p>True positives</p><p>129</p><p>187</p><p>+45.0%</p><p>False negatives</p><p>77</p><p>19</p><p>-75.3%</p><h3>Error elimination: The key differentiator</h3><p>The most striking difference is the <strong>complete elimination of JSON parsing errors</strong>. This resulted in a modest precision improvement and a far more dramatic recall improvement. The precision metric captures how often the matches the system accepts were expected in the golden document. So the prototype was decent at judging matches correctly in the prompt-based approach, but function calling does that even better.</p><p></p><p>Conversely, recall tells us how many of the expected matches were found. When a batch of matches comes back with malformed JSON, the system loses all of those matches. It's likely that Elasticsearch sends many of these matches for judgment, but we lose those matches if judgment fails. The significant recall improvement shows that this hypothesis is correct. Elasticsearch identifies the potential matches and function calling verifies which of those matches are correct.</p><p></p><p><strong>Note:</strong> It’s expected that Elasticsearch will find some incorrect matches because we look at the top two or three results from hybrid search. Most of the time, hybrid search returns the correct match as the top result, but having the LLM judge the top few hits ensures that we see how the LLM handles incorrect matches. If we move from the educational prototype to a production system, we’ll likely tune the Elasticsearch queries more carefully so that we only send promising matches to the LLM, further optimizing our LLM costs.</p><h2>What's next: The ultimate challenge</h2><p>Now that we've optimized our LLM integration with function calling, we have a complete entity resolution pipeline with improved reliability and cost efficiency. However, can it handle the ultimate challenge? In the next post, we'll explore how the system handles diverse entity resolution scenarios across 50 different challenge types, including cultural naming conventions, business relationships, titles, and multilingual variations.</p><h2>Try it yourself</h2><p>Want to see function calling optimization in action? Check out the <a href="https://github.com/jesslm/entity-resolution-lab-public/tree/main/notebooks#:~:text=5%20minutes%20ago-,04_function_calling_optimization_v3.ipynb,-Initial%20public%20lab">Function Calling Optimization notebook</a> for a complete walkthrough with real implementations, detailed explanations, and hands-on examples. The notebook shows you exactly how to use function calling for structured output, compare it with prompt-based JSON, and analyze cost and reliability benefits.</p><p><strong>Remember:</strong> This is an educational prototype designed to teach optimization concepts. When building production systems, consider additional factors, like multi-provider support, advanced caching strategies, monitoring and alerting, comprehensive error handling, and compliance requirements that aren't covered in this learning-focused prototype.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-entity-resolution-llm-function-calling</guid>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Jessica Moszkowicz]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blta3b4daaf48985d75/6a170cf360084b0d2c3c45be/b2afa90c1b863c716008f3f5bbdd2866fa1c3577-720x420.png" length="0" type="image/png"/>
    <pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[From vectors to keywords: Elasticsearch hybrid search in LangChain]]></title>
    <description><![CDATA[Learn how to use hybrid search in LangChain via its Elasticsearch integrations, with complete Python and JavaScript examples.]]></description>
    <content:encoded><![CDATA[<p>Elasticsearch hybrid search is available for LangChain across our <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a> integrations. Here we’ll discuss what hybrid search is, when it can be useful and we’ll run through some simple examples to get started.</p><p>We’re also planning to support hybrid search in the community-driven <a href="https://github.com/langchain4j/langchain4j">Java integration</a> very soon.</p><h2><strong>What is hybrid search?</strong></h2><p><em>Hybrid search</em> is an information retrieval approach that combines<em> keyword-based full-text search</em> (lexical matching) with <em>semantic search</em> (vector similarity). Practically, it means a query can match documents because they contain the right terms and/or because they express the right meaning (even if the wording differs).In simple terms, you can think of it like this:</p><ul><li><p>Lexical retrieval: “Do these documents contain the words I typed (or related words)?”</p></li><li><p>Semantic retrieval: “Do these documents mean something similar to what I typed?”</p></li></ul><p>These two retrieval methods produce scores on different scales, so hybrid search systems typically use a fusion strategy to merge them into one ranking, for example, using <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF).</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltf0bfbf25638698c9/6a170d5f964cea3fa308bc25/a36692581ec5adb54d3c517e171b6d2f372efd92-1249x514.png" alt="BM25 example flow for hybrid search" /><p>In the figure above, we show an example: <a href="https://www.elastic.co/blog/practical-bm25-part-2-the-bm25-algorithm-and-its-variables">BM25</a> (keyword search) returns Docs A, B, and C, while semantic search returns Docs X, A, and B. The RRF algorithm then combines these two result lists into the final ranking: Doc A, Doc B, Doc X, and Doc C. With hybrid search, Doc C is included in the results thanks to BM25.</p><h2><strong>Why hybrid search matters</strong></h2><p>If you’ve built search or retrieval-augmented generation (RAG) features in production, you’ve probably seen the same failure modes show up again and again: </p><ul><li><p>Keyword search can be too literal. If the user doesn’t use the exact terms that appear in your documents, relevant content gets buried or missed.</p></li><li><p>Semantic search can be too fuzzy. It’s great at meaning, but it can also return results that feel related while missing a critical constraint, like a product name, an error code, or a specific phrase the user actually typed.</p></li></ul><p>Hybrid search exists because real user queries in production environments usually need <em>both</em>.</p><p>Next we’ll dive into how you get started with hybrid search in the LangChain integration for <a href="https://github.com/langchain-ai/langchain-elastic">Python</a> and <a href="https://github.com/langchain-ai/langchainjs">JavaScript</a>. If you want to read more about hybrid search, check out <a href="https://www.elastic.co/what-is/hybrid-search"><strong>What is hybrid search?</strong></a>and <a href="https://www.elastic.co/search-labs/blog/elasticsearch-hybrid-search"><strong>When hybrid search truly shines</strong></a>.</p><h3>Setting up a local Elasticsearch instance</h3><p>Before running the examples, you'll need Elasticsearch running locally. The easiest way is using the <a href="https://github.com/elastic/start-local?tab=readme-ov-file"><code>start-local</code></a> script:</p>curl -fsSL https://elastic.co/start-local | sh<p>After starting, you'll have:</p><ul><li><p>Elasticsearch at http://localhost:9200.</p></li><li><p>Kibana at http://localhost:5601.</p></li></ul><p>Your API key is stored in the .env file (under the elastic-start-local folder) as <code>ES_LOCAL_API_KEY</code>.</p><h2>Getting started with hybrid search in LangChain (Python and JavaScript)</h2><p>The dataset is a CSV with information on 1,000 science fiction movies, taken from an IMDb dataset on <a href="https://www.kaggle.com/datasets/rajugc/imdb-movies-dataset-based-on-genre/versions/2?select=scifi.csv">Kaggle</a>. This demo uses a subset of the data, which has been cleaned. You can download the dataset used for this article from our <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>, along with the full code for this demo.</p><h3>Step 1: Install what you need.</h3><p>First you’ll need the LangChain Elasticsearch integration and Ollama for embeddings. (You can also use some other embedding model if you wish.)</p><p><strong>In Python:</strong></p>pip install langchain-elasticsearch langchain-ollama<p><strong>In JavaScript:</strong></p>npm install @langchain/community @langchain/ollama @elastic/elasticsearch csv-parse<h3>Step 2: Configure your connection and dataset path.</h3><p><strong>In Python:</strong></p><p>At the top of the script, we set:</p><ul><li><p>Where Elasticsearch is <code>(ES_LOCAL_URL)</code>.</p></li><li><p>How to authenticate <code>(ES_LOCAL_API_KEY)</code>.</p></li><li><p>Which demo index name to use <code>(INDEX_NAME)</code>.</p></li><li><p>Which CSV file we’ll ingest <code>(scifi_1000.csv)</code>.</p></li></ul>ES_URL = os.getenv("ES_LOCAL_URL", "http://localhost:9200") 
ES_API_KEY = os.getenv("ES_LOCAL_API_KEY")
INDEX_NAME = "scifi-movies-hybrid-demo" 
CSV_PATH = Path(__file__).with_name("scifi_1000.csv")<p><strong>In JavaScript:</strong></p><p>Notes for JavaScript:</p><ul><li><p>JavaScript uses <code>process.env</code> instead of <code>os.getenv</code>.</p></li><li><p>Path resolution requires <code>fileURLToPath</code> and <code>dirname</code> for Elasticsearch modules.</p></li><li><p>The class is called <code>ElasticVectorSearch</code> (not <code>ElasticsearchStore</code> as in Python).</p></li></ul>import { Client } from "@elastic/elasticsearch";
import { OllamaEmbeddings } from "@langchain/ollama";
import {
  ElasticVectorSearch,
  HybridRetrievalStrategy,
} from "@langchain/community/vectorstores/elasticsearch";
import { parse } from "csv-parse/sync";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hybrid search (BM25 + kNN + RRF) top results:
1. Edge of Tomorrow (2014)
2. The Witch: Part 1 - The Subversion (2018)
3. Boss Level (2020)
4. Divinity (2023)
5. The Maze Runner (2014)<h2><strong>Why these results? </strong></h2><p>This query (“time loop / reliving the same day”) is a great case where hybrid search tends to shine because the dataset contains literal phrases that BM25 can match and vectors can still capture meaning.</p><ul><li><p>Vector-only (kNN) embeds the query and tries to find semantically similar plots. Using a broad sci‑fi dataset, this can drift into “trapped / altered reality / memory loss / high-stakes sci‑fi” even when there’s no time-loop concept. That’s why results like “The Witch: Part 1 – The Subversion” (amnesia) and “The Maze Runner” (trapped/escape) can appear.</p></li><li><p>Hybrid (BM25 + kNN + RRF) rewards documents that match both keywords and meaning. Movies whose descriptions explicitly mention “time loop” or “relive the same day” get a strong lexical boost, so titles like “Edge of Tomorrow” (relive the same day over and over again…) and “Boss Level” (trapped in a time loop that constantly repeats the day…) rise to the top.</p></li></ul><p>Hybrid search doesn’t guarantee that every result is perfect. It balances lexical and semantic signals so you may still see some non-time-loop sci‑fi in the tail of the top‑k.</p><p>The main takeaway is that hybrid search helps anchor semantic retrieval with exact textual evidence when the dataset contains those keywords.</p><h2>Full code example</h2><p>You can find our full demo code in Python and JavaScript, as well as the dataset used, hosted on <a href="https://gist.github.com/ssh-esh/103fb8220de3b0e045393760c2f36575">GitHub gist</a>.</p><h2>Conclusion</h2><p>Hybrid search provides a pragmatic and powerful retrieval strategy by combining traditional BM25 keyword search with modern vector similarity into a single, unified ranking. Instead of choosing between lexical precision and semantic understanding, you get the best of both worlds, without adding significant complexity to your application.</p><p>In real-world datasets, this approach consistently yields results that feel more intuitively correct. Exact term matches help anchor results to the user’s explicit intent, while embeddings ensure robustness against paraphrasing, synonyms, and incomplete queries. This balance is especially valuable for noisy, heterogeneous, or user-generated content, where relying on only one retrieval method often falls short.</p><p>In this article, we demonstrated how to use hybrid search in LangChain through its Elasticsearch integrations, with complete examples in both Python and JavaScript. We’re also contributing to other open-source projects, such as <a href="https://github.com/langchain4j/langchain4j/pull/4069">LangChain4j</a>, to extend hybrid search support with Elasticsearch.</p><p>We believe hybrid search will be a key capability for generative AI (GenAI) and agentic AI applications, and we plan to continue collaborating with libraries, frameworks, and programming languages across the ecosystem to make high-quality retrieval more accessible and robust.</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/langchain-elasticsearch-hybrid-search</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Integrations]]></category>
    <dc:creator><![CDATA[Margaret Gu,Eyo Eshetu]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/bltbe53f88c9e39c39e/6a170d61a6c2b9013be79762/9159af2b07b88f288e5c7cb719c8dcbe5d3b37d6-1080x608.png" length="0" type="image/png"/>
    <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Hybrid search and multistage retrieval in ES|QL]]></title>
    <description><![CDATA[Explore the multistage retrieval capabilities of ES|QL, using FORK and FUSE commands to integrate hybrid search with semantic reranking and native LLM completions.]]></description>
    <content:encoded><![CDATA[<p>In Elasticsearch 9.2, we’ve introduced the ability to do dense vector search and hybrid search in Elasticsearch Query Language (ES|QL). This continues our investment in making ES|QL the best search language to solve modern search use cases.</p><h2>Multistage retrieval: The challenge of modern search</h2><p>Modern search has evolved beyond simple keyword matching. Today's search applications need to understand intent, handle natural language, and combine multiple ranking signals to deliver the best results.</p><p>Retrieval of the most relevant results happens in multiple stages, with each stage gradually refining the result set. This wasn’t the case in the past, where most use cases would require one or two stages of retrieval: an initial query to get results and a potential rescoring phase.	</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" alt="" /><p>We start with an initial retrieval, where we cast a wide net to gather results that are relevant to our query. Since we need to sieve through all the data, we should use techniques that return results fast, even when we index billions of documents.</p><p>We therefore employ trusted techniques, such as lexical search that Elasticsearch has supported and optimized since the beginning, or vector search, where Elasticsearch excels in speed and accuracy.</p><p>Lexical search using BM25 is quite fast and best at exact term matching or phrase matching, and <a href="https://www.elastic.co/docs/solutions/search/vector">vector</a> or <a href="https://www.elastic.co/docs/solutions/search/semantic-search">semantic search</a> is better suited for handling natural language queries. <a href="https://www.elastic.co/what-is/hybrid-search">Hybrid search</a> combines lexical and <a href="https://www.elastic.co/docs/solutions/search/vector">vector search</a> results to bring the best from both. The challenge that hybrid search solves is that vector and lexical search have completely different and incompatible scoring functions which produce values in different intervals, following different distributions. A vector search score close to 1 can mean a very close match, but it doesn’t mean the same for lexical search. Hybrid search methods, such as <a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">reciprocal rank fusion</a> (RRF) and linear combination of scores, assign new scores that blend the original scores from lexical and vector search.</p><p>After hybrid search, we can employ techniques such as <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> and <a href="https://www.elastic.co/docs/solutions/search/ranking/learning-to-rank-ltr">Learning To Rank</a> (LTR), which use specialized machine learning models to rerank the result.</p><p>With our most relevant results, we can use large language models (LLMs) to further enrich our response or pass the most relevant results as context to LLMs in agentic workflows in tools such as <a href="https://www.elastic.co/search-labs/blog/elastic-ai-agent-builder-context-engineering-introduction">Elastic Agent Builder</a>.</p><p>ES|QL is able to handle all these stages of retrieval. By design, ES|QL is a piped language, where each command transforms the input and sends the output to the next command. Each stage of retrieval is represented by one or more consecutive ES|QL commands. In this article, we show how each stage is supported in ES|QL.</p><h2>Vector search</h2><p>In Elasticsearch 9.2, we introduced tech preview support for dense vector search in ES|QL. This is as simple as calling the <code>knn</code> function, which only requires a <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/dense-vector"><code>dense_vector</code></a> field and a query vector:</p>FROM books METADATA _score
| WHERE KNN(description_vector, ?query_vector)
| SORT _score DESC
| LIMIT 100<p>This query executes an approximate nearest neighbor search, retrieving 100 documents that are the most similar to the <code>query_vector</code>.</p><h2>Hybrid search: Reciprocal rank fusion</h2><p>In Elasticsearch 9.2, we introduced support for hybrid search using RRF and linear combination of results in ES|QL.</p><p>This allows combining vector search and lexical search results into a single result set.</p><p>To achieve this in ES|QL, we need to use the <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> commands. <code>FORK</code> runs multiple branches of execution, and <code>FUSE</code> merges the results and assigns new relevance scores using RRF or linear combination.</p><p>In the following example, we use <code>FORK</code> to run two separate branches, where one is doing a lexical search using the <code>match</code> function, while the other is doing a vector search using the <code>knn</code> function. We then merge the results together using <code>FUSE</code>:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE // uses RRF by default
| SORT _score DESC<p>Let's decompose the query to better understand the execution model and first look at the output of the <code>FORK</code> command:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The<code> FORK</code> commands outputs the results from both branches and adds a <code>_fork</code> discriminator column:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>As you’ll notice, certain documents appear twice, which is why we then use <code>FUSE</code> to merge rows that represent the same documents and assign new relevance scores. <code>FUSE</code> is executed in two stages:</p><ul><li><p>For each row, <code>FUSE</code> assigns a new relevance score, depending on the hybrid search algorithm that is being used.</p></li><li><p>Rows that represent the same document are merged together, and a new score is computed.</p></li></ul><p>In our example, we’re using RRF. As a first step, <code>FUSE</code> assigns a new score to each row using the RRF formula:</p>score(doc) = 1 / (rank_constant + rank(doc))<p>Where the <code>rank_constant</code> takes a default value of 60 and <code>rank(doc)</code>represents the position of the document in the result set.</p><p>In the first phase, our results become:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 3) = 0.01587</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 4) = 0.01563</p><p>fork1</p><p>4123</p><p> The Silmarillion</p><p>1 / (60 + 5) = 0.01538</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>1 / (60 + 6) = 0.01515</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>1 / (60 + 1) = 0.01639</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>1 / (60 + 2) = 0.01613</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>1 / (60 + 3) = 0.01587</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>1 / (60 + 4) = 0.01563</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>1 / (60 + 5) = 0.01538</p><p>fork2</p><p>Then the rows are merged together and a new score is assigned. Since a <code>SORT _score DESC</code> follows the <code>FUSE</code> command, the final results are:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.01639 + 0.01639 = 0.03279</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.01613 + 0.01613 = 0.03226</p><p>4005</p><p>The Two Towers</p><p>0.01587 + 0.01563 = 0.0315</p><p>4123</p><p>The Silmarillion</p><p>0.01538 + 0.01587 = 0.03125</p><p>4006</p><p>The Return of the King</p><p>0.01563 + 0.01538 = 0.03101</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><h2>Hybrid search: Linear combination of scores</h2><p><a href="https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion">Reciprocal rank fusion</a> is the simplest way to do hybrid search, but it isn’t the only hybrid search method that we support in ES|QL.</p><p>In the following example, we use <code>FUSE</code> to combine lexical and <a href="https://www.elastic.co/docs/solutions/search/semantic-search/semantic-search-semantic-text">semantic search</a> results using linear combination of scores:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 } }
| SORT _score DESC<p>Let's first decompose the query and take a look at the input of the <code>FUSE</code> command when we only run the <code>FORK</code> command.</p><p>Notice that we use the <code>match</code> function, which is able to not only query lexical fields, such as <code>text</code> or <code>keyword</code>, but also <a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text"><code>semantic_text</code></a> fields.</p><p>The first <code>FORK</code> branch executes a semantic query by querying a <code>semantic_text</code> field, while the second one executes a lexical query:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)<p>The output of the <code>FORK</code> command can contain rows with the same <code>_id</code> and <code>_index</code> values representing the same Elasticsearch document:</p><p>_id</p><p>title</p><p>_score</p><p>_fork</p><p>4001</p><p>The Hobbit</p><p>0.88</p><p>fork1</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.88</p><p>fork1</p><p>4005</p><p>The Two Towers</p><p>0.86</p><p>fork1</p><p>4006</p><p>The Return of the King</p><p>0.84</p><p>fork1</p><p>4123</p><p>The Silmarillion</p><p>0.78</p><p>fork1</p><p>4144</p><p>The Children of Húrin</p><p>0.79</p><p>fork1</p><p>4001</p><p>The Hobbit</p><p>4.55</p><p>fork2</p><p>3999</p><p>The Fellowship of the Ring</p><p>4.25</p><p>fork2</p><p>4123</p><p>The Silmarillion</p><p>4.11</p><p>fork2</p><p>4005</p><p>The Two Towers</p><p>3.8</p><p>fork2</p><p>4006</p><p>The Return of the King</p><p>4.1</p><p>fork2</p><p>In the next step, we use <code>FUSE</code> to merge rows that have the same <code>_id</code> and <code>_index</code> values, and assign new relevance scores.</p><p>The new score is a linear combination of the scores the row had in each <code>FORK</code> branch:</p>_score = 0.7 *_score1 + 0.3 * _score2<p>Here, <code>_score1</code> and <code>_score2</code> represent the score a document has in the first <code>FORK</code> branch and the second <code>FORK</code> branch, respectively.</p><p>Notice that we also apply custom weights, giving more weight to the semantic score over the lexical one, resulting in this set of documents:</p><p>_id</p><p>title</p><p>_score</p><p>4001</p><p>The Hobbit</p><p>0.7 * 0.88 + 0.3 * 4.55 = 1.981</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.7 * 0.88 + 0.3 * 4.25 = 1.891</p><p>4006</p><p>The Return of the King</p><p>0.7 * 0.84 + 0.3 * 4.1 = 1.818</p><p>4123</p><p>The Silmarillion</p><p>0.7 * 0.78 + 0.3 * 4.11 = 1.779</p><p>4005</p><p>The Two Towers</p><p>0.7 * 0.86 + 0.3 * 3.8 = 1.742</p><p>4144</p><p>The Children of Húrin</p><p>0.7 * 0.79 + 0.3 * 0 = 0.553</p><p>One challenge is that the semantic and lexical scores can be incompatible to apply the linear combination, since they can follow completely different distributions. To mitigate this, we first need to normalize the scores, employing score normalization methods, such as <code>minmax</code>. This ensures that the scores from each <code>FORK</code> branch are first normalized to take values between 0 and 1, before applying the linear combination formula.</p><p>To achieve this with <code>FUSE</code>, we need to specify the <code>normalizer</code> option:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE MATCH(semantic_description, ?query) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 }, "normalizer": "minmax" }
| SORT _score DESC<h2>Semantic reranking</h2><p>At this stage, after hybrid search, we should be left with the most relevant documents. We can now use semantic reranking to reorder the results using the <code>RERANK</code> command. By default, <code>RERANK</code> uses the latest Elastic <a href="https://www.elastic.co/docs/solutions/search/ranking/semantic-reranking">semantic reranking</a> machine learning model, so no additional configuration is needed:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC<p>We now have our best results, sorted by relevance.</p><p>One key feature that sets the <code>RERANK</code> command apart from other products that offer semantic reranking integrations is that it doesn’t require the input to represent a mapped field from an index. <code>RERANK</code> only expects an expression that evaluates to a string value, making it possible to do semantic reranking using multiple fields:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON CONCAT(title, "\n", description) 
| SORT _score DESC<h2>LLM completions</h2><p>Now we have a set of highly relevant, reranked results.</p><p>At this stage, you might simply decide to return the results back to your application or you might want to further enhance your results using LLM completions.</p><p>If you’re using ES|QL as part of a retrieval-augmented generation (RAG) workflow, you can choose to call your favorite LLM directly from ES|QL.
To achieve this, we’ve added a new <code>COMPLETION</code> command that takes in a prompt, a completion inference ID which designates which LLM to call, and a column identifier to specify where to output the LLM response.</p><p>In the following example, we’re using <code>COMPLETION</code> to add a new <code>_completion</code> column that contains the summary of the <code>content</code> column:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| COMPLETION CONCAT("Summarize the following:\n", description) WITH { "inference_id" : "my_inference_endpoint" } <p>Each row now contains a summary:</p><p>_id</p><p>title</p><p>_score</p><p>summary</p><p>4001</p><p>The Hobbit</p><p>0.03279</p><p>Bilbo helps dwarves reclaim Erebor from the dragon Smaug.</p><p>3999</p><p>The Fellowship of the Ring</p><p>0.03226</p><p>Frodo begins the quest to destroy the One Ring.</p><p>4005</p><p>The Two Towers</p><p>0.0315</p><p>The Fellowship splits; war comes to Rohan; Frodo nears Mordor.</p><p>4123</p><p>The Silmarillion</p><p>0.03125</p><p>Ancient myths and history of Middle-earth's First Age.</p><p>4006</p><p>The Return of the King</p><p>0.3101</p><p>Sauron is defeated and Aragorn is crowned King.</p><p>4144</p><p>The Children of Húrin</p><p>0.01515</p><p>The tragic tale of Túrin Turambar's cursed life.</p><p>In another use case, you may simply want to answer a question using the proprietary data that you have indexed in Elasticsearch. In this case, the best search results that we’ve computed in the previous stage can be used as context for the prompt:</p>FROM books METADATA _score, _id, _index
| FORK (WHERE KNN(description_vector, ?query_vector) | SORT _score DESC | LIMIT 100)
       (WHERE MATCH(description, ?query) | SORT _score DESC | LIMIT 100)
| FUSE
| SORT _score DESC
| LIMIT 100
| RERANK ?query ON description
| SORT _score DESC
| LIMIT 10
| STATS context = VALUES(CONCAT(title, "\n", description)
| COMPLETION CONCAT("Answer the following question ", ?query, "based on:\n", context) WITH { "inference_id" : "my_inference_endpoint" }<p>Since the <code>COMPLETION</code> command unlocks the ability to send any prompt to an LLM, the possibilities are endless. Although we’re only showing a few examples, the <code>COMPLETION</code> command can be used in a wide range of scenarios, from security analysts using it to assign scores depending on whether a log event can represent a malicious action or data scientists using it to analyze data, to cases where you just need to<a href="https://www.elastic.co/search-labs/blog/esql-completion-command-llm-fact-generator"> generate Chuck Norris facts based on your data</a>.</p><h2>This is only the beginning</h2><p>In the future, we’ll be expanding ES|QL to improve semantic reranking for long documents, better conditional execution of the ES|QL queries using multiple <code>FORK</code> commands, support sparse vector queries, removing close duplicate results to enhance result diversity, allowing full text search on runtime generated columns, and many other scenarios.</p><p>Additional tutorials and guides:</p><ul><li><p><a href="https://www.elastic.co/docs/solutions/search/esql-for-search">ES|QL for search</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/esql-search-tutorial">ES|QL for search tutorial</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/semantic-text">Semantic_text field type</a></p></li><li><p><a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fork"><code>FORK</code></a> and <a href="https://www.elastic.co/docs/reference/query-languages/esql/commands/fuse"><code>FUSE</code></a> documentation</p></li><li><p>ES|QL search functions</p></li></ul>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/hybrid-search-multi-stage-retrieval-esql</guid>
    <category><![CDATA[ES|QL]]></category>
    <category><![CDATA[Hybrid Search]]></category>
    <category><![CDATA[Relevance]]></category>
    <dc:creator><![CDATA[Ioana Tagirta,Aurélien Foucret,Carlos Delgado]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt3382265814939417/6a170df3a929cf1246ae0a61/fceada10b0c09d6a4a372f137bb3040e1ff41fbf-1600x895.png" length="0" type="image/png"/>
    <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Built with Elastic: Hybrid search for Cypris – the world’s largest innovation database]]></title>
    <description><![CDATA[Discover how Cypris optimized hybrid search in Elasticsearch to handle 500M vectors, cutting query times from 60 seconds down to 5–10 seconds using binary quantization and segment tuning.]]></description>
    <content:encoded><![CDATA[<em>“When I first typed ‘drone’ into the search and saw results for ‘unmanned aerial vehicles’ without synonyms, I was like, ‘Wow, this thing really gets it.’ That’s when it clicked—it genuinely felt like magic.” — Logan Pashby, Principal Engineer, Cypris.ai</em><h2>Relevance at scale: Cypris’ search story</h2><p>Cypris is a platform that helps R&amp;D and innovation teams navigate a massive dataset of patents and research papers of over 500 million documents. Their mission is to make it easier to track innovation, find prior art, and understand the organizations driving new technologies.</p><p>But there was a problem. To get relevant results, users had to write complex boolean queries—which was fine for expert users, but a barrier for many others. Cypris needed a way to make search more intuitive and accessible.</p><p>The answer was semantic search powered by vector similarity. However, they discovered that scaling semantic search over a large corpus turned out to be a tough engineering problem. Handling 500 million high dimensional vectors wasn’t just a matter of pushing them into a system and hitting “search.” “When we first indexed all 500 million vectors, we were looking at 30- to 60-second query times in the worst case.”</p><p>It would require a series of carefully considered trade-offs between model complexity, hardware resources, and indexing strategy.</p><p><em><strong>Logan Pashby </strong></em><em>is a Principal Engineer at Cypris, where he focuses on the platform's innovation intelligence features. With expertise in topics such as deep learning, distributed systems, and full-stack development, Logan solves complex data challenges and develops efficient search solutions for R&amp;D and IP teams.</em></p><h2>Choosing the right model</h2><p>Cypris’ first attempt at vector search used 750-dimensional embeddings for every document, but they quickly realized scaling such large embeddings across 500 million documents would be unmanageable. By using the <a href="https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/approximate-knn-search#_ensure_data_nodes_have_enough_memory">memory approximation formula</a> without quantization, the estimated bytes of RAM required would be around 1500 GB, making it clear that they needed to adjust their strategy.</p><p>“We assumed, and we hoped, that the larger the dimension of the vector, the more information we could encode. A richer embedding space should mean better search relevance.”</p><p>They considered using sparse vectors like Elastic’s ELSER which avoids the fixed-dimension limitations of dense embeddings by representing documents as weighted lists of tokens instead. However, at the time, ELSER’s CPU-only inference seemed too slow for Cypris’s dataset. Dense vectors, on the other hand, let them leverage off-cluster GPU acceleration, which improved throughput by 10x to 50x when generating embeddings.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4bb779818f016fef/6a17f811445de9a9824d02d1/3a16fcc95b52e9ec3171dea7a021e33dd5bfeb5c-1394x1116.png" alt="Cypris’ setup to reduce hybrid search latency including an external GPU based service to compute vectors which were then indexed into Elasticsearch." /><p>The team ultimately decided on lower-dimensional dense vectors that struck a balance: they were compact enough to make indexing and search feasible, yet rich enough to maintain relevance in results.</p><h2>Making hybrid search work with production scale data</h2><h3>Challenges - disk space</h3><p>Once Cypris had vectors ready to be indexed, they faced the next hurdle: <strong>efficiently storing and searching over them in Elasticsearch</strong>.</p><p>The first step was reducing disk space. “At the end of the day, vectors are just arrays of floats.... But when you have 500 million of them, the storage requirements add up quickly.” By default, vectors in Elasticsearch are stored multiple times: first in the _source field (the original JSON document), then in doc_values (columnar storage optimized for retrieval), and finally within the HNSW graph itself. Given that each 750-dimensional float32 vector takes about 3KB, storing 500 million vectors quickly becomes problematic, potentially exceeding 1.5 terabytes per storage layer.</p><p>One practical optimization Cypris used was excluding vectors from the source document in Elasticsearch. This helped reduce overhead, but it turned out disk space wasn’t the biggest challenge. The bigger challenge was memory management.</p><p><em><strong>Did you know?</strong></em></p><p><em>Elasticsearch allows you to optimize disk space by excluding vectors from the source document. This can significantly reduce storage costs, especially when dealing with large datasets. However, be aware that excluding vectors from the source will impact reindexing performance. For more details, check out the </em><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html"><em>Elasticsearch documentation on source filtering</em></a><em>.</em></p><h3>Challenges - RAM explosion</h3><p>Known nearest neighbor (kNN) search in Elasticsearch relies on HNSW graphs, which perform best when fully loaded into RAM. With 500 million high-dimensional vectors, there were significant memory demands on the system. “Trying to fit all of those vectors in memory at query time was not an easy thing to do,” Logan adds.</p><p>Cypris had to juggle multiple memory requirements: the vectors and their HNSW graphs needed to reside in off-heap memory for fast search performance, while the JVM heap had to remain available for other operations. On top of that, they still needed to support traditional keyword search, and the associated Elasticsearch inverted index would need to stay in memory as well.</p><h4>Managing memory with dimensionality reduction, quantization, and segments</h4><p>Cypris explored multiple approaches to better manage memory and storage, here were three that worked well:</p><ul><li><p><strong>Lower-dimensional vectors</strong>: The Cypris team swapped to using a smaller model that reduced vector sizes, thereby lowering resource requirements.</p></li><li><p><strong>BBQ (Better Binary Quantization)</strong>: Cypris was considering int8 quantization, but when Elastic released BBQ, Cypris adopted it quickly. “We tested it out and it didn’t have a huge hit to relevance and was significantly cheaper. So we implemented it right away”, says Logan<strong>. </strong>BBQ immediately reduced the size of their vector indexes by around <strong>20%</strong>!</p></li></ul><p><em><strong>Did you know?</strong></em></p><p><em>Elasticsearch’s Binary Quantized Vectors (BBQ) can reduce the size of vector indexes by ~20%, with minimal impact on search relevance. BBQ reduces both disk usage—by shrinking index size—and memory usage, since smaller vectors take up less space in RAM during searches. It’s especially helpful when scaling KNN search with HNSW graphs, where keeping everything in memory is critical for performance. Explore how BBQ can optimize your search infrastructure in the Elasticsearch documentation on vector search.</em></p><ul><li><p><strong>Segment and shard tuning: </strong>Cypris also optimized how Elasticsearch segments and shards were managed. HNSW graphs are built per segment, so searching dense vectors means querying across all segments in a shard. As Logan explains: “HNSW graphs are independent within each segment and each dense vector field search involves finding the nearest neighbors in every segment, making the total cost dependent on the number of segments.”

Fewer segments generally mean faster searches—but aggressively merging them can slow down indexing. Since Cypris ingests new documents daily, they regularly force-merge segments to keep them slightly below the default 5GB threshold, preserving automatic merging and tombstone garbage collection. To balance search speed with indexing throughput, force-merging occurs during low-traffic periods, and shard sizes are maintained within a healthy range (below 50GB) to optimize performance without sacrificing ingestion speed.</p></li></ul><h3>More vectors, faster searches, hybrid search and happy users</h3><p>With these optimizations, Cypris brought query times down from <strong>30–60 seconds</strong> to <strong>5–10 seconds</strong>. They are also seeing <strong>60–70%</strong> of their user queries shift from the previous boolean search experience to the new semantic search interface.</p><p>But the team is not stopping here! The goal is to achieve sub-second queries to support fast, iterative search and get most of their users to shift to semantic search.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt2af7d4fbeca1afbd/6a17f81396142a0f2eeb1cc6/2c7007332a935495e9b279bdaf3968905f021e68-1600x1003.png" alt="Cypris' product handling 500M docs providing real-time AI search and retreival." /><h2>What did the Cypris team learn? … and what’s next?</h2><h3>500 million vectors don’t scale themselves</h3><p>Handling 500 million vectors isn’t just a storage problem or a search problem—it’s both. Cypris had to balance search relevance, hardware resources, and indexing performance at every step.</p><p><em><strong>Did you know</strong></em></p><p><em>Elasticsearch's _search API includes a profile feature that allows you to analyze the execution time of search queries. This can help identify bottlenecks and optimize query performance. By enabling profiling, you can gain insights into how different components of your query are processed. Learn more about using the profile feature in the </em><a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-profile.html"><em>Elasticsearch search profiling documentation</em></a><em>.</em></p><h3>With search, there’s always a trade-off</h3><p>BBQ was a major win, but it didn’t eliminate the need to rethink sharding, memory allocation, and indexing strategy. Reducing the number of segments improved search speed, but made indexing slower. Excluding vectors from the source reduced disk space but complicated reindexing, as Elasticsearch doesn’t retain the original vector data needed to efficiently recreate the index. Every optimization came with a cost that had to be carefully weighed.</p><h3>Prioritize your users, not the model</h3><p>Cypris didn’t chase the largest models or highest dimension vectors. They focused on what made sense for their users, and working backwards. “Figure out what relevance means for your data,” Logan advises. “And work backward from there.”</p><p>Cypris is now expanding to other datasets, which could double the number of documents they have to index in Elastic. They need to move quickly to stay competitive, “We’re a small team,” Logan says. “So everything we do has to scale—and it has to work.”</p><p>To learn more, visit <a href="http://cypris.ai">cypris.ai</a></p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/building-hybrid-search-at-cypris</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/building-hybrid-search-at-cypris</guid>
    <category><![CDATA[Hybrid Search]]></category>
    <dc:creator><![CDATA[Elastic Team,Logan Pashby]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt9dd95ca3f3bd2004/6a17f8153e9e457308ba165d/69d9c14dac7e255e3df6c3aa15c84ac6564c3401-720x420.jpg" length="0" type="image/jpeg"/>
    <pubDate>Tue, 06 May 2025 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>