Large-scale Document Retrieval with ElasticSearch
Walks through building an Elasticsearch index with dense vectors and comparing ANN algorithms (LSH, IVFPQ, HNSW, FlatL2) for movie retrieval.
What this file does
Walks through building an Elasticsearch index with dense vectors and comparing ANN algorithms (LSH, IVFPQ, HNSW, FlatL2) for movie retrieval.
When to use it
- Learning to combine Elasticsearch with embedding-based search
- Comparing approximate nearest neighbor algorithms for retrieval
- Building a semantic search prototype on a small dataset
- Understanding script_score queries with cosine similarity
Assumes this stack
jupyter: jupytext: text_representation: extension: .md format_name: markdown format_version: '1.3' jupytext_version: 1.13.7 kernelspec: display_name: Python 3 language: python name: python3
<!-- #region id="cNfAFrCcPjbS" -->Large-scale Document Retrieval with ElasticSearch
A tutorial to understand the process of retrieving documents/items using elastic search and vector indexing methods.
- toc: true
- badges: true
- comments: true
- categories: [elasticsearch, jupyter]
- image:
Retrieval Flow Overview
<!-- #endregion --> <!-- #region id="CuqDb8yOPrLR" --> <!-- #endregion --> <!-- #region id="Mz_q-We5Ms_c" -->Part 1 - Setting up Elasticsearch
- Download the elasticsearch archive (linux), setup a local server
- Create a client connection to the local elasticsearch instance
# download the latest elasticsearch version
!wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.11.1-linux-x86_64.tar.gz
!tar -xzvf elasticsearch-7.11.1-linux-x86_64.tar.gz
!chown -R daemon:daemon elasticsearch-7.11.1
# prep the elasticsearch server
import os
from subprocess import Popen, PIPE, STDOUT
es_subprocess = Popen(['elasticsearch-7.11.1/bin/elasticsearch'], stdout=PIPE, stderr=STDOUT, preexec_fn=lambda : os.setuid(1))
# wait for a few minutes for the local host to start
!curl -X GET "localhost:9200/"
# install elasticsearch python api
!pip install -q elasticsearch
# check if elasticsearch server is properly running in the background
from elasticsearch import Elasticsearch, helpers
es_client = Elasticsearch(['localhost'])
es_client.info()
<!-- #region id="bYunh06DPbGC" -->
Part 2 - Walking through an embedding-based retrieval system
<!-- #endregion --> <!-- #region id="Gn9I5VRcQGpF" -->Download MovieLens dataset
<!-- #endregion -->!wget https://files.grouplens.org/datasets/movielens/ml-25m.zip --no-check-certificate
!unzip ml-25m.zip
import pandas as pd
data = pd.read_csv('ml-25m/movies.csv').drop_duplicates()
data.head()
<!-- #region id="3UluFk94RVvq" -->
Build index with document vectors
<!-- #endregion -->import tensorflow_hub as hub
from timeit import default_timer as timer
import json
embed = hub.load("https://tfhub.dev/google/universal-sentence-encoder-large/5")
# constants
INDEX_NAME = "movie_title"
BATCH_SIZE = 200
SEARCH_SIZE = 10
MAPPINGS = {
'mappings': {'_source': {'enabled': 'true'},
'dynamic': 'true',
'properties': {'title_vector':
{'dims': 512, 'type': 'dense_vector'},
'movie_id': {'type': 'keyword'},
'genres': {'type': 'keyword'}
}
},
'settings': {'number_of_replicas': 1, 'number_of_shards':2}
}
def index_movie_lens(df, num_doc=500):
print('creating the {} index.'.format(INDEX_NAME))
es_client.indices.delete(index=INDEX_NAME, ignore=[404])
es_client.indices.create(index=INDEX_NAME, body=json.dumps(MAPPINGS))
requests = []
count = 0
start = timer()
for row_index, doc in df.iterrows():
# specify the index size to avoid long waiting time
if count >= num_doc:
break
# construct requests
if len(requests) < BATCH_SIZE:
title_text = doc.title
genres_text = doc.genres
title_vector = embed([title_text]).numpy().tolist()[0]
request = {
"op_type": "index",
"_index": INDEX_NAME,
"_id": row_index,
"title": title_text,
"genres": genres_text,
"title_vector": title_vector,
"movie_id": doc.movieId
}
requests.append(request)
else:
helpers.bulk(es_client, requests)
count += len(requests)
requests.clear()
if count % (BATCH_SIZE * 2) == 0:
print("Indexed {} documents in {:.2f} seconds.".format(count, timer()-start))
# Index the remaining
helpers.bulk(es_client, requests)
end = timer()
print("Done indexing {} documents in {:.2f} seconds".format(count, end-start))
<!-- #region id="y4cN-nnnWmIe" -->
Ref - https://youtu.be/F4D08uU3mPA
<!-- #endregion -->index_movie_lens(data, num_doc=2000)
<!-- #region id="nG4Yhjk7YB1X" -->
Search with query vector
<!-- #endregion -->def return_top_movies(query):
embedding_start = timer()
query_vector = embed([query]).numpy().tolist()[0]
embedding_time = timer() - embedding_start
formula = "cosineSimilarity(params.query_vector, 'title_vector') + 1.0"
script_query = {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": formula,
"params": {"query_vector": query_vector}
}
}
}
search_start = timer()
response = es_client.search(
index=INDEX_NAME,
body={
"size":SEARCH_SIZE,
"query": script_query,
"_source": {"includes": ["title", "genres"]}
}
)
search_time = timer() - search_start
print()
print("{} total hits.".format(response["hits"]["total"]["value"]))
for hit in response["hits"]["hits"]:
print("id: {}, score: {}".format(hit["_id"], hit["_score"] - 1))
print(hit["_source"])
print()
return_top_movies("war")
<!-- #region id="VT0xw-Ykbh6d" -->
Part 3 - Approximate Nearest Neighbor (ANN) Algorithms
<!-- #endregion --> <!-- #region id="fW6Iwum1bxEX" --> <!-- #endregion -->#hide_output
!pip install faiss
!pip install nmslib
!apt-get install libomp-dev
import faiss
import nmslib
documents = data['title'].to_list()[:2000]
# # OOM for large document size
embeddings = embed(documents).numpy()
embeddings.shape
class DemoIndexLSH():
def __init__(self, dimension, documents, embeddings):
self.dimension = dimension
self.documents = documents
self.embeddings = embeddings
def build(self, num_bits=8):
self.index = faiss.IndexLSH(self.dimension, num_bits)
self.index.add(self.embeddings)
def query(self, input_embedding, k=5):
distances, indices = self.index.search(input_embedding, k)
return [(distance, self.documents[index]) for distance, index in zip(distances[0], indices[0])]
index_lsh = DemoIndexLSH(512, documents, embeddings)
index_lsh.build(num_bits=16)
class DemoIndexIVFPQ():
def __init__(self, dimension, documents, embeddings):
self.dimension = dimension
self.documents = documents
self.embeddings = embeddings
def build(self,
number_of_partition=2,
number_of_subquantizers=2,
subvector_bits=4):
quantizer = faiss.IndexFlatL2(self.dimension)
self.index = faiss.IndexIVFPQ(quantizer,
self.dimension,
number_of_partition,
number_of_subquantizers,
subvector_bits)
self.index.train(self.embeddings)
self.index.add(self.embeddings)
def query(self, input_embedding, k=5):
distances, indices = self.index.search(input_embedding, k)
return [(distance, self.documents[index]) for distance, index in zip(distances[0], indices[0])]
index_pq = DemoIndexIVFPQ(512, documents, embeddings)
index_pq.build()
class DemoHNSW():
def __init__(self, dimension, documents, embeddings):
self.dimension = dimension
self.documents = documents
self.embeddings = embeddings
def build(self, num_bits=8):
self.index = nmslib.init(method='hnsw', space='cosinesimil')
self.index.addDataPointBatch(self.embeddings)
self.index.createIndex({'post': 2}, print_progress=True)
def query(self, input_embedding, k=5):
indices, distances = self.index.knnQuery(input_embedding, k)
return [(distance, self.documents[index]) for distance, index in zip(distances, indices)]
index_hnsw = DemoHNSW(512, documents, embeddings)
index_hnsw.build()
class DemoIndexFlatL2():
def __init__(self, dimension, documents, embeddings):
self.dimension = dimension
self.documents = documents
self.embeddings = embeddings
def build(self, num_bits=8):
self.index = faiss.IndexFlatL2(self.dimension)
self.index.add(self.embeddings)
def query(self, input_embedding, k=5):
distances, indices = self.index.search(input_embedding, k)
return [(distance, self.documents[index]) for distance, index in zip(distances[0], indices[0])]
index_flat = DemoIndexFlatL2(512, documents, embeddings)
index_flat.build()
def return_ann_top_movies(ann_index, query, k=SEARCH_SIZE):
query_vector = embed([query]).numpy()
search_start = timer()
top_docs = ann_index.query(query_vector, k)
search_time = timer() - search_start
print("search time: {:.2f} ms".format(search_time * 1000))
return top_docs
return_ann_top_movies(index_flat, "romance")
return_ann_top_movies(index_lsh, "romance")
return_ann_top_movies(index_pq, "romance")
return_ann_top_movies(index_hnsw, "romance")
What's inside
3 parts: Elasticsearch setup, embedding indexing and search, 4 ANN algorithm demos with timing comparisons
Change this for your project
- Replace
https://tfhub.dev/google/universal-sentence-encoder-large/5with your own embedding model - Replace
ml-25m/movies.csvwith your dataset path - Replace
INDEX_NAME = "movie_title"with your index name - Replace
MAPPINGSdictionary fields to match your document schema
Where it goes
Reference documentation for a retrieval pipeline. Keep with the ingestion or retrieval code it describes.
Worth borrowing
- Using script_score with cosineSimilarity to rank by embedding distance
- Wrapping ANN libraries (FAISS, nmslib) in a uniform query interface
- Batching bulk index requests to Elasticsearch for performance
Related Documents
SUMMARY
Proposes three on-prem AI architectures, modular, hybrid, and fully local RAG, with hardware specs and vendor lists.
Retrieval & Prompts
Explains how CharMemory's extraction prompt and Vector Storage settings determine memory retrieval quality in SillyTavern.
App Review Support Guide — Switch2Go
Explains an AAC app's accessibility permissions, hardware needs, and reviewer walkthrough to pass App Store review.
RFC-BLite: High-Performance Embedded Document Database for .NET
Specifies an embedded document database for.NET with zero-allocation I/O, C-BSON format, and ACID transactions.