How to join retrieved documents in Haystack

Hybrid retrieval works best when keyword and embedding branches can contribute to one context list without returning the same source twice. Haystack DocumentJoiner combines those branch outputs and hands one ranked list to a ranker, prompt builder, or generator.

The component accepts multiple document lists through its variadic documents input. The reciprocal_rank_fusion mode uses each document's position instead of comparing the branches' original scores, which suits BM25 and embedding retrievers whose score ranges differ.

Documents with the same ID are treated as duplicates, and appearing near the top of more than one list increases the fused score. The top_k limit is applied after fusion, so the output can be checked for both the expected ranking and a single copy of each shared document.

Steps to join retrieved documents in Haystack:

  1. Create document_joiner_demo.py with the Haystack imports and keyword-ranked candidate list.
    document_joiner_demo.py
    from haystack import Document
    from haystack.components.joiners import DocumentJoiner
     
     
    keyword_documents = [
        Document(
            id="doc-hybrid-search",
            content="Hybrid search combines keyword and vector retrieval.",
            score=8.4,
        ),
        Document(
            id="doc-reranker",
            content="A reranker reorders joined retrieval results.",
            score=6.1,
        ),
        Document(
            id="doc-cleaning",
            content="Document cleaning removes repeated headers.",
            score=3.2,
        ),
    ]

    In a retrieval pipeline, this list comes from a retriever's documents output.

  2. Append the embedding-ranked candidate list to document_joiner_demo.py.
    embedding_documents = [
        Document(
            id="doc-hybrid-search",
            content="Hybrid search combines keyword and vector retrieval.",
            score=0.94,
        ),
        Document(
            id="doc-question-answering",
            content="RAG answers questions from retrieved context.",
            score=0.88,
        ),
        Document(
            id="doc-reranker",
            content="A reranker reorders joined retrieval results.",
            score=0.70,
        ),
    ]

    The repeated doc-hybrid-search and doc-reranker IDs represent documents returned by both branches.

  3. Append the reciprocal-rank-fusion operation to document_joiner_demo.py.
    joiner = DocumentJoiner(
        join_mode="reciprocal_rank_fusion",
        top_k=3,
    )
     
    result = joiner.run(
        documents=[keyword_documents, embedding_documents]
    )
    joined_documents = result["documents"]

    top_k=3 limits the list after duplicate handling and rank fusion. The merge mode and matching weights are appropriate only when the incoming scores can be compared directly.

  4. Add rank, duplicate, and printed-output checks at the end of document_joiner_demo.py.
    joined_ids = [document.id for document in joined_documents]
    assert joined_ids == [
        "doc-hybrid-search",
        "doc-reranker",
        "doc-question-answering",
    ]
    assert joined_ids.count("doc-hybrid-search") == 1
     
    for position, document in enumerate(joined_documents, start=1):
        print(f"{position}. {document.id} | {document.score:.4f}")

    The first assertion checks the fused order and top_k boundary. The second fails if the shared document remains duplicated.

  5. Compare the completed document_joiner_demo.py file with the assembled source.
    document_joiner_demo.py
    from haystack import Document
    from haystack.components.joiners import DocumentJoiner
     
     
    keyword_documents = [
        Document(
            id="doc-hybrid-search",
            content="Hybrid search combines keyword and vector retrieval.",
            score=8.4,
        ),
        Document(
            id="doc-reranker",
            content="A reranker reorders joined retrieval results.",
            score=6.1,
        ),
        Document(
            id="doc-cleaning",
            content="Document cleaning removes repeated headers.",
            score=3.2,
        ),
    ]
     
    embedding_documents = [
        Document(
            id="doc-hybrid-search",
            content="Hybrid search combines keyword and vector retrieval.",
            score=0.94,
        ),
        Document(
            id="doc-question-answering",
            content="RAG answers questions from retrieved context.",
            score=0.88,
        ),
        Document(
            id="doc-reranker",
            content="A reranker reorders joined retrieval results.",
            score=0.70,
        ),
    ]
     
    joiner = DocumentJoiner(
        join_mode="reciprocal_rank_fusion",
        top_k=3,
    )
     
    result = joiner.run(
        documents=[keyword_documents, embedding_documents]
    )
    joined_documents = result["documents"]
     
    joined_ids = [document.id for document in joined_documents]
    assert joined_ids == [
        "doc-hybrid-search",
        "doc-reranker",
        "doc-question-answering",
    ]
    assert joined_ids.count("doc-hybrid-search") == 1
     
    for position, document in enumerate(joined_documents, start=1):
        print(f"{position}. {document.id} | {document.score:.4f}")
  6. Verify the joined order and duplicate check by running document_joiner_demo.py in the Python environment that contains haystack-ai.
    $ python3 document_joiner_demo.py
    1. doc-hybrid-search | 1.0000
    2. doc-reranker | 0.9761
    3. doc-question-answering | 0.4919

    The command exits with an assertion error if fusion leaves a duplicate, changes the expected order, or returns a different number of documents.
    Related: How to install Haystack with pip