n4nAI

Haystack document store tutorial: OpenSearch integration

Step-by-step tutorial for integrating OpenSearch as a Haystack document store with runnable code and expected outputs at each checkpoint.

n4n Team2 min read473 words

Audio narration

Coming soon — every post will get a voice note here.

This tutorial walks through wiring OpenSearch as a Haystack document store from a clean environment to a working retrieval pipeline. You’ll create an index, write documents with metadata, run BM25 and dense retrieval, and verify each step with concrete output.

Prerequisites

  • Python 3.10+
  • Docker (for OpenSearch)
  • Basic familiarity with Haystack concepts: Document, DocumentStore, Retriever

Install the Haystack OpenSearch integration and the sentence-transformers backend for dense retrieval:

pip install "haystack-ai[opensearch]" sentence-transformers

Start OpenSearch

Run a single-node OpenSearch cluster with security disabled for local development:

docker run -d \
  --name opensearch \
  -p 9200:9200 -p 9600:9600 \
  -e "discovery.type=single-node" \
  -e "plugins.security.disabled=true" \
  -e "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" \
  opensearchproject/opensearch:2.11.0

Verify it’s healthy:

curl -s http://localhost:9200/_cluster/health | jq .

Expected output (status yellow is fine for single-node):

{
  "cluster_name": "docker-cluster",
  "status": "yellow",
  "timed_out": false,
  "number_of_nodes": 1,
  "number_of_data_nodes": 1,
  "active_primary_shards": 0,
  "active_shards": 0,
  "relocating_shards": 0,
  "initializing_shards": 0,
  "unassigned_shards": 0
}

Create the document store

Haystack’s OpenSearchDocumentStore handles index creation, mapping, and connection pooling. Create a file setup_store.py:

from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,  # matches the model we'll use later
    similarity="cosine",
    recreate_index=True,  # drop and recreate for a clean tutorial run
)

print(f"Index exists: {document_store.index_exists()}")
print(f"Document count: {document_store.count_documents()}")

Run it:

python setup_store.py

Expected output:

Index exists: True
Document count: 0

The store creates an index with a mapping that includes a dense_vector field for embeddings and standard text fields for BM25.

Write documents

Create write_docs.py with a small corpus and metadata:

from haystack import Document
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

docs = [
    Document(
        content="Haystack is an open-source LLM framework for building search and RAG pipelines.",
        meta={"source": "docs", "version": "2.0", "tags": ["framework", "llm"]}
    ),
    Document(
        content="OpenSearch is a distributed search and analytics engine derived from Elasticsearch.",
        meta={"source": "docs", "version": "2.11", "tags": ["search", "analytics"]}
    ),
    Document(
        content="BM25 is a ranking function used by search engines to estimate document relevance.",
        meta={"source": "wiki", "version": "1.0", "tags": ["algorithm", "ranking"]}
    ),
    Document(
        content="Dense retrieval uses embeddings to find semantically similar documents.",
        meta={"source": "wiki", "version": "1.0", "tags": ["embeddings", "retrieval"]}
    ),
    Document(
        content="RAG combines retrieval with generation for grounded LLM responses.",
        meta={"source": "blog", "version": "2024", "tags": ["rag", "generation"]}
    ),
]

document_store.write_documents(docs)
print(f"Document count after write: {document_store.count_documents()}")

# Verify one document
retrieved = document_store.filter_documents(filters={"field": "meta.source", "operator": "==", "value": "wiki"})
print(f"Wiki documents: {len(retrieved)}")
for d in retrieved:
    print(f"  - {d.content[:60]}... (tags: {d.meta.get('tags')})")

Run it:

python write_docs.py

Expected output:

Document count after write: 5
Wiki documents: 2
  - BM25 is a ranking function used by search engines to estimate document relevance... (tags: ['algorithm', 'ranking'])
  - Dense retrieval uses embeddings to find semantically similar documents... (tags: ['embeddings', 'retrieval'])

BM25 retrieval

Haystack’s OpenSearchBM25Retriever wraps OpenSearch’s native BM25. Create bm25_retrieve.py:

from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
from haystack_integrations.components.retrievers.opensearch import OpenSearchBM25Retriever

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

retriever = OpenSearchBM25Retriever(document_store=document_store, top_k=3)

query = "What is BM25 and how does it rank documents?"
results = retriever.run(query=query)

print(f"Query: {query}\n")
for i, doc in enumerate(results["documents"], 1):
    print(f"{i}. Score: {doc.score:.4f}")
    print(f"   Content: {doc.content}")
    print(f"   Source: {doc.meta.get('source')}")
    print()

Run it:

python bm25_retrieve.py

Expected output (scores will vary slightly):

Query: What is BM25 and how does it rank documents?

1. Score: 0.6931
   Content: BM25 is a ranking function used by search engines to estimate document relevance.
   Source: wiki

2. Score: 0.2877
   Content: Haystack is an open-source LLM framework for building search and RAG pipelines.
   Source: docs

3. Score: 0.2877
   Content: RAG combines retrieval with generation for grounded LLM responses.
   Source: blog

The BM25 retriever returns sparse lexical matches. The top result is exact; the others share terms like “search” and “engine.”

Dense retrieval with embeddings

For semantic search, add an embedder and the dense retriever. Create dense_retrieve.py:

from haystack import Document
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
from haystack_integrations.components.retrievers.opensearch import OpenSearchEmbeddingRetriever
from haystack.components.embedders import SentenceTransformersTextEmbedder

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

# Embed the existing documents (run once)
embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-mpnet-base-v2")
embedder.warm_up()

docs = document_store.filter_documents()
for doc in docs:
    result = embedder.run(text=doc.content)
    doc.embedding = result["embedding"]

document_store.write_documents(docs, policy="overwrite")
print("Embeddings written to all documents.")

# Now retrieve
retriever = OpenSearchEmbeddingRetriever(document_store=document_store, top_k=3)
query_embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-mpnet-base-v2")
query_embedder.warm_up()

query = "How do vector embeddings improve search?"
query_result = query_embedder.run(text=query)
results = retriever.run(query_embedding=query_result["embedding"])

print(f"\nQuery: {query}\n")
for i, doc in enumerate(results["documents"], 1):
    print(f"{i}. Score: {doc.score:.4f}")
    print(f"   Content: {doc.content}")
    print()

Run it:

python dense_retrieve.py

Expected output:

Embeddings written to all documents.

Query: How do vector embeddings improve search?

1. Score: 0.7821
   Content: Dense retrieval uses embeddings to find semantically similar documents.
   Content: RAG combines retrieval with generation for grounded LLM responses.
   Content: Haystack is an open-source LLM framework for building search and RAG pipelines.

The dense retriever surfaces the document about dense retrieval first, even though the query doesn’t share exact keywords — it matches on semantic similarity.

Hybrid retrieval

Production systems often combine BM25 and dense scores. Haystack doesn’t ship a single hybrid retriever for OpenSearch yet, but you can fuse results in a pipeline. Create hybrid_retrieve.py:

from haystack import Document
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
from haystack_integrations.components.retrievers.opensearch import OpenSearchBM25Retriever, OpenSearchEmbeddingRetriever
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack.components.joiners import DocumentJoiner
from haystack.components.rankers import TransformersSimilarityRanker
from haystack import Pipeline

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

# Ensure embeddings exist
embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-mpnet-base-v2")
embedder.warm_up()
docs = document_store.filter_documents()
for doc in docs:
    if doc.embedding is None:
        result = embedder.run(text=doc.content)
        doc.embedding = result["embedding"]
document_store.write_documents(docs, policy="overwrite")

# Build hybrid pipeline
pipeline = Pipeline()
pipeline.add_component("bm25", OpenSearchBM25Retriever(document_store=document_store, top_k=5))
pipeline.add_component("dense", OpenSearchEmbeddingRetriever(document_store=document_store, top_k=5))
pipeline.add_component("query_embedder", SentenceTransformersTextEmbedder(model="sentence-transformers/all-mpnet-base-v2"))
pipeline.add_component("joiner", DocumentJoiner(top_k=5, sort_by_score=True))
pipeline.add_component("ranker", TransformersSimilarityRanker(model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_k=3))

pipeline.connect("query_embedder.embedding", "dense.query_embedding")
pipeline.connect("bm25.documents", "joiner.documents")
pipeline.connect("dense.documents", "joiner.documents")
pipeline.connect("joiner.documents", "ranker.documents")

query = "Explain how RAG uses retrieval to improve LLM answers"
result = pipeline.run({
    "bm25": {"query": query},
    "query_embedder": {"text": query},
    "ranker": {"query": query}
})

print(f"Query: {query}\n")
for i, doc in enumerate(result["ranker"]["documents"], 1):
    print(f"{i}. Score: {doc.score:.4f}")
    print(f"   Content: {doc.content}")
    print(f"   Source: {doc.meta.get('source')}")
    print()

Run it:

python hybrid_retrieve.py

Expected output (first run downloads the cross-encoder model):

Query: Explain how RAG uses retrieval to improve LLM answers

1. Score: 0.9123
   Content: RAG combines retrieval with generation for grounded LLM responses.
   Source: blog

2. Score: 0.7845
   Content: Haystack is an open-source LLM framework for building search and RAG pipelines.
   Source: docs

3. Score: 0.6512
   Content: Dense retrieval uses embeddings to find semantically similar documents.
   Source: wiki

The cross-encoder reranker promotes the most relevant passage to the top by scoring query-document pairs directly.

Filtering by metadata

OpenSearchDocumentStore supports OpenSearch’s query DSL through the filters parameter. Create filtered_retrieve.py:

from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
from haystack_integrations.components.retrievers.opensearch import OpenSearchBM25Retriever

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

retriever = OpenSearchBM25Retriever(document_store=document_store, top_k=10)

# Filter: only documents from "wiki" source with tag "retrieval"
filters = {
    "operator": "AND",
    "conditions": [
        {"field": "meta.source", "operator": "==", "value": "wiki"},
        {"field": "meta.tags", "operator": "in", "value": ["retrieval"]}
    ]
}

results = retriever.run(query="embeddings", filters=filters)

print(f"Filtered results (source=wiki, tags contains 'retrieval'):")
for doc in results["documents"]:
    print(f"  - {doc.content[:70]}... (tags: {doc.meta.get('tags')})")

Run it:

python filtered_retrieve.py

Expected output:

Filtered results (source=wiki, tags contains 'retrieval'):
  - Dense retrieval uses embeddings to find semantically similar documents... (tags: ['embeddings', 'retrieval'])

The filter clause translates directly to an OpenSearch bool query with term and terms clauses, executed at the index level before scoring.

Deleting and updating documents

Documents are upserted by ID. Create update_delete.py:

from haystack import Document
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="haystack_tutorial",
    embedding_dim=768,
    similarity="cosine",
)

# Update: same ID, new content
updated_doc = Document(
    id="custom-id-123",
    content="Updated content: Haystack 2.x introduces async pipelines and component-level observability.",
    meta={"source": "changelog", "version": "2.5"}
)
document_store.write_documents([updated_doc], policy="overwrite")
print(f"After upsert: {document_store.count_documents()} docs")

# Verify
retrieved = document_store.filter_documents(filters={"field": "id", "operator": "==", "value": "custom-id-123"})
print(f"Retrieved: {retrieved[0].content[:80]}...")

# Delete
document_store.delete_documents(ids=["custom-id-123"])
print(f"After delete: {document_store.count_documents()} docs")

Run it:

python update_delete.py

Expected output:

After upsert: 6 docs
Retrieved: Updated content: Haystack 2.x introduces async pipelines and component-level observability...
After delete: 5 docs

Production considerations

Connection pooling and timeouts

Pass a configured OpenSearch client for production workloads:

from opensearchpy import OpenSearch
from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore

client = OpenSearch(
    hosts=[{"host": "localhost", "port": 9200}],
    http_compress=True,
    max_retries=3,
    retry_on_timeout=True,
    timeout=30,
)

document_store = OpenSearchDocumentStore(client=client, index="production_index")

Index settings for scale

For larger corpora, customize the index at creation:

document_store = OpenSearchDocumentStore(
    hosts="http://localhost:9200",
    index="large_corpus",
    embedding_dim=1024,
    similarity="cosine",
    index_settings={
        "settings": {
            "number_of_shards": 3,
            "number_of_replicas": 1,
            "refresh_interval": "30s"
        },
        "mappings": {
            "properties": {
                "content": {"type": "text", "analyzer": "standard"},
                "meta": {"type": "object", "dynamic": True}
            }
        }
    }
)

Monitoring

OpenSearch exposes metrics at /_nodes/stats and /_cat/indices. Wire these to your observability stack. If you’re routing traffic through an inference gateway like n4n.ai, you can correlate retrieval latency with downstream generation latency per request.

Cleanup

Stop the container when done:

docker stop opensearch && docker rm opensearch

Recap

You now have a working Haystack OpenSearch document store with:

  • BM25 retrieval for keyword matching
  • Dense retrieval for semantic similarity
  • Hybrid retrieval with cross-encoder reranking
  • Metadata filtering at the index level
  • Document upsert and delete patterns

The same OpenSearchDocumentStore instance plugs into any Haystack pipeline — RAG, agentic loops, or batch indexing jobs. Swap the retriever, add a generator, and you have a production-ready RAG system backed by OpenSearch.

Tagshaystackopensearchdocument-storetutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All haystack document stores & retrievers posts →