<?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[Miguel Grinberg - 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[Miguel Grinberg - 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/author/miguel-grinberg</link>
    </image>
    <link>https://www.elastic.co/search-labs/author/miguel-grinberg</link>
    <atom:link href="https://www.elastic.co/search-labs/rss/author/miguel-grinberg.xml" rel="self" type="application/rss+xml"/>
    <language><![CDATA[en]]></language>
    <lastBuildDate>Wed, 23 Sep 2026 02:32:30 GMT</lastBuildDate>
  <item>
    <title><![CDATA[Export your Kibana Dev Console requests to Python and JavaScript Code]]></title>
    <description><![CDATA[The Kibana Dev Console now offers the option to export requests to Python and JavaScript code that is ready to be integrated into your application.]]></description>
    <content:encoded><![CDATA[<p>Have you used the Kibana Dev Console? This is a fantastic prototyping tool that allows you to build and test your Elasticsearch requests interactively. But what do you do after you have a working request in the Console?</p><p>In this article we'll take a look at the new code generation feature in the Kibana Dev Console, and how it can significantly reduce your development effort by generating ready to use code for you.</p><p>This feature is available in our Serverless platform and in Elastic Cloud and self-hosted releases 8.16 and up.</p><h2>The Kibana Dev Console</h2><p>This section provides a quick introduction to the <a href="https://www.elastic.co/guide/en/kibana/current/console-kibana.html">Kibana Dev Console</a>, in case you have never used it before. Skip to the next section if you are already familiar with it.</p><p>While you are in any part of the Search section in Kibana, you will notice a "Console" link at the bottom of your browser's page:</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt7c0bbd6964b0886d/6a170aeb6f7f04dfb991484b/e80850635ecc74536696743181afb3ac0c74e38f-1024x742.png" alt="The Kibana Dev Console - Open Console" /><p>When you click this link, the Console expands to cover the page. Click it again to collapse it.</p><p>In the left-side panel of the Dev Console, you can enter Elasticsearch requests, with the help of an interactive editor that provides auto-completion and checks your syntax. Some example requests are already pre-populated so that you have something to start experimenting with.</p><p>When the cursor is on a request, a "play" button appears to its right. You can click this button to send the request to your Elasticsearch server.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8e8bf8f61f065daa/6a170aed964cea4ffa08bb9b/520637e15cd03234aefd26502e42c80310b3734f-1006x230.png" alt="Kibana Dev Console Send Request" /><p>After you execute a request, the response from the server appears in the panel on the right.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt8c9a61493ab010d9/6a170aef0e2e49de6d41a0e2/ef250921da3ff260a6f56d6d4745842096809564-1024x642.png" alt="Kibana Dev Console Response" /><h2>Code Export feature in Kibana Dev Console</h2><p>The Dev Console makes it easy to prototype your requests or queries until you get exactly what you want. But what happens next? If you need to convert the request to code so that you can incorporate it into your application, then you can save time using the new code export feature.</p><p>Next to the Play button you will find the three dot or "kebab" button, which opens a menu of options. The first option provides access to the code export feature. If you've never used this feature before, it will appear with a "Copy as curl" label.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt69fd88f2ae0eadae/6a170af02b835f8ca2f4b205/27fe3d5aa5874d094d26d35ff2188ccc0e435b9f-1272x476.png" alt="Kibana Dev Console Options Menu" /><p>If you select this option, your clipboard will be loaded with a <a href="https://curl.se/">curl</a> command that is equivalent to the selected request.</p><p>Now, things get more interesting when you click the "Change" link, which allows you to switch to a different target language. In this initial release, the code export adds support for Python and JavaScript. More languages are expected to be added in future releases.</p><p>You can now select your desired language and click "Copy code" to put the exported code in your clipboard. You can also change the default language that is offered in the menu.</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" alt="Kibana Dev Console Select Language" /><p>The exported code is a complete script in the selected language, using the official Elasticsearch client for that language. Here is an example of how the <code>PUT /my-index</code> request shown above looks when exported to the Python language:</p>import os
from elasticsearch import Elasticsearch

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

resp = client.indices.create(
    index="my-index",
)
print(resp)<p>To use the exported code follow these steps:</p><ul><li><p>Paste the code from the clipboard to a new file with the correct extension (<code>.py</code> for Python, or <code>.js</code> for JavaScript).</p></li><li><p>In your terminal, add an environment variable called <code>ELASTIC_API_KEY</code> with a valid API Key for your Elasticsearch cluster. You can <a href="https://www.elastic.co/guide/en/kibana/current/api-keys.html#create-api-key">create an API key</a> right in Kibana if you don't have one yet.</p></li><li><p>Execute the script with the <code>python</code> or <code>node</code> commands depending on your language, making sure the official Elasticsearch client is installed.</p></li></ul><p>Now you are ready to adapt the exported code as needed to integrate it into your application!</p><h2>Conclusion</h2><p>In this article you have learned about the new Code Export feature in the Kibana Dev Console. We hope this feature will streamline your development process with Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/kibana-dev-console-code-export</guid>
    <category><![CDATA[Python]]></category>
    <category><![CDATA[Javascript]]></category>
    <category><![CDATA[Kibana]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt64d35579b66099a4/6a170af266c4f9d021f8c02d/dd4c2c94ea17ba74bae7cf05fbab4b3944cb37ed-1256x702.png" length="0" type="image/png"/>
    <pubDate>Wed, 30 Oct 2024 00:00:00 GMT</pubDate>
  </item>
  <item>
    <title><![CDATA[Vector embeddings made simple with the Elasticsearch-DSL client for Python]]></title>
    <description><![CDATA[Learn how to ingest and search dense vectors in Python using the Elasticsearch-DSL client.]]></description>
    <content:encoded><![CDATA[<p>In this article we'll take a look at the <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> client for Python, with a focus on how it simplifies the task of building a vector search solution.</p><p>The <a href="https://github.com/miguelgrinberg/quotes">code</a> that accompanies this article implements a database of famous quotes. It includes a back end written in Python with the <a href="https://fastapi.tiangolo.com/">FastAPI</a> web framework, and a front end written in <a href="https://www.typescriptlang.org/">TypeScript</a> and <a href="https://react.dev/">React</a>. Regarding vector search, this application demonstrates how to:</p><ul><li><p>run a local Elasticsearch service using Docker,</p></li><li><p>bulk-ingest a large number of documents efficiently,</p></li><li><p>generate vector embeddings for documents as they are ingested,</p></li><li><p>leverage the power of a GPU to accelerate the generation of vector embeddings through parallelization,</p></li><li><p>run vector search queries using the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#approximate-knn">approximate kNN algorithm</a>,</p></li><li><p>aggregate results from vector search,</p></li><li><p>compare vector search results against those resulting from a standard <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html">match</a> (BM25) query.</p></li></ul><p>Below you can see a screenshot of the application. In this article you will find a detailed explanation of how the ingest and search features work. You then have the option to install and run the code on your own computer to experiment and learn!</p><img src="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" alt="Application screenshot" /><h2>What is the Elasticsearch-DSL client for Python?</h2><p>Sometimes called the "high-level" Python client, <a href="https://elasticsearch-dsl.readthedocs.io/en/latest/index.html">Elasticsearch-DSL</a> offers idiomatic (or "Pythonic") access to your Elasticsearch database, in contrast with the official (or "low-level") Python client, which provides direct access to the complete range of Elasticsearch features and endpoints.</p><p>When using Elasticsearch-DSL, the structure (or "mappings") of Elasticsearch indices are defined as classes, with a syntax that is similar to that of Python <a href="https://docs.python.org/3/library/dataclasses.html">dataclasses</a>. The documents stored in these indices are represented by instances of these classes. All the transformations that are necessary to map between Python objects and Elasticsearch documents are automatically and transparently carried out, resulting in application code that is simple and idiomatic.</p><p>To add Elasticsearch-DSL to your Python project, you can install it with <code>pip</code>:</p>pip install elasticsearch-dsl
<p>If your project is asynchronous, then there are additional dependencies that need to be installed, so in that case use the following command instead:</p>pip install "elasticsearch-dsl[async]"
<h2>Index definition</h2><p>As stated above, with Elasticsearch-DSL the structure of an Elasticsearch index is defined as a Python class. The example application featured in this article uses a dataset of famous quotes that have the following fields:</p><ul><li><p><code>quote</code>: the text of the quote, as a string</p></li><li><p><code>author</code>: the name of the author, as a string</p></li><li><p><code>tags</code>: a list of tag names that apply to the quote, each a string</p></li></ul><p>As part of this application we are going to add one additional field, the vector embedding that we will use to search for quotes:</p><ul><li><p><code>embedding</code>: a list of floating point numbers representing a vector embedding for the quote</p></li></ul><p>Let's write an initial document class to describe our famous quotes index:</p>import elasticsearch_dsl as dsl

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

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

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

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

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

    class Index:
        name = 'quotes'

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

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

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

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


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

    class Index:
        name = 'quotes'

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

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

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

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

# ...

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

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

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

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

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

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

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

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

    async def get_next_quote():
        quotes = []
        with open('quotes.csv') as f:
            reader = csv.DictReader(f)
            count = 0
            start = time()
            for row in reader:
                q = QuoteDoc(quote=row['quote'], author=row['author'],
                             tags=row['tags'].split(','))
                quotes.append(q)
                if len(quotes) == 512:
                    embed_quotes(quotes)
                    for q in quotes:
                        yield q
                    count += len(quotes)
                    ingest_progress(count, start)
                    quotes = []
            if len(quotes) &gt; 0:
                embed_quotes(quotes)
                for q in quotes:
                    yield q
            ingest_progress(count, start)
<p>In this version of <code>ingest_quotes()</code>, each <code>QuoteDoc</code> instance is added to the <code>quotes</code> list, and when 512 elements have accumulated the <code>embed_quotes()</code> function added above is used to generate the embeddings more efficiently. Once the objects have their embeddings, they are yielded, so that the <code>bulk()</code> method from Elasticsearch-DSL can add them to the index as before.</p><p>What is the significance of the 512 number? There isn't any. We know that the model uses a batch size of 32, so it makes sense to accumulate at least that many documents. Starting from 32, you can try if larger powers of 2 provide better performance. With the hardware available to me, I've found 512 to give the best performance.</p><p>Here is an example run using batched embeddings:</p>❯ python search.py
Ingested 36864 quotes. (481/sec)
<p>And now the ingestion process runs much faster, with the entire dataset ingested in about 1 minutes and 16 seconds.</p><p>If you decide to try to optimize your ingest, you are encouraged to try different options and see what works best with your hardware.</p><h2>Querying the index</h2><p>If you are following along, by now you have an Elasticsearch index called <code>quotes</code> that is populated with about 37K famous quotes, each with a searchable vector embedding. Now it is time to learn how to query this index.</p><p>When using Elasticsearch-DSL, the document classes return a search object from their <code>search()</code> method:</p>s = QuoteDoc.search()
<p>The search object has a large number of methods that map to the query options in the Elasticsearch <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html">query DSL</a>.</p><p>The simplest query that can be issued is the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-all-query.html">match all</a> query, which returns all the elements. With the class-based approach used by Elasticsearch-DSL, this is how to run the query:</p>s = QuoteDoc.search()
s = s.query(dsl.query.MatchAll())
async for q in s:
    print(q.quote)
<p>This would obviously print a listing of the entire list of quotes stored in the index, up to 10,000, which is the maximum number of results Elasticsearch returns by default.</p><p>In many cases it is useful to request a subset of the results. The search object uses Python style slicing for this. Here is how to request the first 25 results only:</p>async for q in s[:25]:
    print(q.quote)
<p>Here is how to request the second page of results, at 25 results per page:</p>async for q in s[25:50]:
    print(q.quote)
<p>Elasticsearch offers approximate and exact vector search queries, also called <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html">k-nearest neighbor (kNN) queries</a>. To run a vector search query with the approximate k-nearest neighbor algorithm, the <code>Knn</code> query should be used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
<p>The <code>Knn</code> query class accepts the field that stores the embeddings and a search vector as arguments. In the above snippet the variable <code>q</code> has the search text entered by the user.</p><p>If instead you prefer to run a regular full-text search, the <code>Match</code> query class is used:</p>s = QuoteDoc.search()
s = s.query(dsl.query.Match(quote=q))
<h3>Filters</h3><p>One of the most important benefits of using Elasticsearch as a vector database is that it is a robust database system, and all the options you can expect to have from a database nicely integrates with your vector search queries.</p><p>A great example of this is <em>filters</em>. The famous quotes database stores a list of tags for each quote, so it is only natural to have the option to restrict a query to quotes that have a specific tag.</p><p>Given a list of tag filters stored in a <code>tags</code> variable, the following snippet configures a search object to only return results that include the given tags using a "terms" filter:</p>for tag in tags:
    s = s.filter(dsl.query.Terms(tags=[tag]))
<h3>Aggregations</h3><p>Another example of a useful database function that is fully integrated with vector search is <em>aggregations</em>. Given a query, Elasticsearch can aggregate the tags and provide the counts of quotes per tag.</p><p>The next snippet shows how to add a <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">Terms</a> aggregation to an existing query, which will return the 100 most referenced tags in the results:</p>s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
<p>Recall that the <code>tags</code> field was declared with the <code>Keyword()</code> type, which means that the tags will be stored as is on the index, without any processing. This is required by the Terms aggregation, which will count the occurrences of each tag in the results.</p><h3>A complete query example</h3><p>You have seen a few isolated query examples. In this section you can see how they can all be integrated into a function that performs a query in the example application.</p><p>The <code>search_quotes()</code> function shown below accepts a query string <code>q</code>, a list of filters <code>tags</code> and a <code>use_knn</code> flag to choose between kNN or full-text search query. It also accepts <code>start</code> and <code>size</code> pagination arguments.</p><p>The function decides which of the three queries you've seen above to issue depending on the input arguments. If <code>q</code> is empty, then it selects a "match all" query, and in any other case it selects a kNN or match query depending on the <code>use_knn</code> flag, which the user can control from a checkbox in the application's user interface.</p><p>The function returns three results as a tuple:</p><ul><li><p>a list of <code>QuoteDoc</code> instances that are the search results,</p></li><li><p>the tag aggregations as a list of tuples, each with tag name and document count,</p></li><li><p>the total number of results, which is useful to show in paginated queries</p></li></ul><p>Here is the complete code of this function:</p>async def search_quotes(q, tags, use_knn=True, start=0, size=25):
    s = QuoteDoc.search()
    if q == '':
        s = s.query(dsl.query.MatchAll())
    elif use_knn:
        s = s.query(dsl.query.Knn(field=QuoteDoc.embedding, query_vector=model.encode(q).tolist()))
    else:
        s = s.query(dsl.query.Match(quote=q))
    for tag in tags:
        s = s.filter(dsl.query.Terms(tags=[tag]))
    s.aggs.bucket('tags', dsl.aggs.Terms(field=QuoteDoc.tags, size=100))
    r = await s[start:start + size].execute()
    tags = [(tag.key, tag.doc_count) for tag in r.aggs.tags.buckets]
    return r.hits, tags, r['hits'].total.value
<p>To be able to access both the search results and the aggregation results, we now issue the request explicitly through the <code>execute()</code> method and store the response is stored in <code>r</code>. The <code>hits</code> attribute of the response object contains the actual search results, and the <code>aggs</code> attribute provides access to the aggregations. The format in which the aggregation results is provided is described in the <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html">terms aggregation documentation</a>.</p><h2>Conclusion</h2><p>The complete quotes example is available in a <a href="https://github.com/miguelgrinberg/quotes">GitHub repository</a> that you can install and run on your computer. Follow the instructions on the <code>README.md</code> file to set it up.</p><p>You are welcome to use this example to experiment with vector embeddings and Elasticsearch!</p>]]></content:encoded>
    <link>https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</link>
    <guid isPermaLink="true">https://www.elastic.co/search-labs/blog/elasticsearch-dsl-python-vectors</guid>
    <category><![CDATA[Vector Database]]></category>
    <category><![CDATA[AI]]></category>
    <category><![CDATA[Python]]></category>
    <dc:creator><![CDATA[Miguel Grinberg]]></dc:creator>
    <enclosure url="https://static-www.elastic.co/v3/assets/bltefdd0b53724fa2ce/blt4a680a6bf95f4421/6a17122b66c4f9358ef8c157/1225861f71cf9ccbb2102216a9365dd07ff71e9c-1440x858.png" length="0" type="image/png"/>
    <pubDate>Fri, 16 Aug 2024 00:00:00 GMT</pubDate>
  </item>
  </channel>
</rss>