Haystack developer on retainer: custom component passes same list reference to write_documents() and return value — DuplicatePolicy.OVERWRITE empties list in-place — downstream InMemoryBM25Retriever receives empty documents on every re-index run — Haystack developer on monthly retainer

haystack-ai 2.2.0 • Python 3.11 • InMemoryDocumentStore • InMemoryBM25Retriever • custom DocumentEnricher component • DuplicatePolicy.OVERWRITE in-place list mutation • same list reference to write_documents() and return • 200 documents emptied on second run • all queries return no context • fix: copy list before write • 2.0h retainer engagement

Northbridge Document Intelligence Ltd. builds an internal HR knowledge base Q&A system for a 400-person professional services firm. In February 2026 they deployed a Haystack 2.2.0 RAG pipeline that answered employee questions about benefits, leave policies, and expense guidelines. The indexing pipeline ran nightly to pick up revised policy documents. For 8 months, every re-index run silently broke the query pipeline: all questions received the answer “I don’t have information about that policy.” The team assumed the documents needed reformatting, tried different chunk sizes, different embedders, and different prompt templates. Nothing worked longer than the first day. They engaged a Haystack retainer developer in October 2026.

The indexing pipeline at the time of the engagement:

# indexing_pipeline.py
# Northbridge Document Intelligence Ltd.
# haystack-ai 2.2.0, Python 3.11

from haystack import Document, Pipeline, component
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.converters import TextFileToDocument
from haystack.components.preprocessors import DocumentSplitter
from haystack.components.writers import DocumentWriter
from haystack.document_stores.types import DuplicatePolicy
from typing import List, Dict, Any

document_store = InMemoryDocumentStore()

@component
class DocumentEnricher:
    """Add source metadata and normalize content before indexing."""

    def __init__(self, document_store: InMemoryDocumentStore):
        self.document_store = document_store

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]) -> Dict[str, Any]:
        enriched_docs = []
        for doc in documents:
            enriched_docs.append(Document(
                content=doc.content.strip(),
                meta={
                    **doc.meta,
                    "enriched": True,
                    "word_count": len(doc.content.split()),
                }
            ))

        # ← PROBLEM: write to store as a side effect, passing same list object
        # DuplicatePolicy.OVERWRITE modifies enriched_docs in-place via slice assignment
        # on the second (and any subsequent) run where all documents already exist
        self.document_store.write_documents(
            enriched_docs,
            policy=DuplicatePolicy.OVERWRITE
        )

        # ← Returns the same list reference that write_documents() may have emptied
        return {"documents": enriched_docs}

indexing_pipeline = Pipeline()
indexing_pipeline.add_component("converter", TextFileToDocument())
indexing_pipeline.add_component("enricher", DocumentEnricher(document_store))
indexing_pipeline.add_component("splitter", DocumentSplitter(
    split_by="word", split_length=200, split_overlap=20
))

indexing_pipeline.connect("converter.documents", "enricher.documents")
indexing_pipeline.connect("enricher.documents", "splitter.documents")
# Note: no separate DocumentWriter — DocumentEnricher handles writing as a side effect
# query_pipeline.py
# Northbridge Document Intelligence Ltd.

from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator

query_pipeline = Pipeline()
query_pipeline.add_component("retriever", InMemoryBM25Retriever(
    document_store=document_store,  # ← same shared InMemoryDocumentStore instance
    top_k=5
))
query_pipeline.add_component("prompt_builder", PromptBuilder(template="""
Given these documents, answer the employee's question.
Documents: {% for doc in documents %}{{ doc.content }}{% endfor %}
Question: {{ query }}
Answer:
"""))
query_pipeline.add_component("generator", OpenAIGenerator(model="gpt-4o-mini"))
query_pipeline.connect("retriever.documents", "prompt_builder.documents")
query_pipeline.connect("prompt_builder.prompt", "generator.prompt")
# nightly_reindex.sh — runs daily at 02:00 UTC
# python reindex.py --source /mnt/hr-docs/*.txt

# reindex.py
import glob
result = indexing_pipeline.run({
    "converter": {"sources": glob.glob("/mnt/hr-docs/*.txt")}
})
print(f"Indexing complete. Splitter output: {len(result['splitter']['documents'])} chunks")
# ← This prints: "Indexing complete. Splitter output: 0 chunks"
# ← on every run after the first, but the team doesn't check this output

Why the first run works but every subsequent run fails

The Haystack 2.x pipeline passes component output dictionaries between components without deep-copying the values. When DocumentEnricher.run() returns {"documents": enriched_docs}, Haystack routes that exact list object to the downstream DocumentSplitter. If enriched_docs has been emptied before the return, the splitter receives an empty list and produces no chunks.

The InMemoryDocumentStore.write_documents() method with DuplicatePolicy.OVERWRITE deduplicates the input list before writing. It uses a slice assignment to filter out documents whose content already matches the stored version:

# InMemoryDocumentStore.write_documents() — haystack-ai 2.2.0
# Simplified to show the relevant deduplication path

def write_documents(
    self,
    documents: List[Document],
    policy: DuplicatePolicy = DuplicatePolicy.NONE
) -> int:
    if policy == DuplicatePolicy.OVERWRITE:
        # Identify which documents differ from their stored versions
        # Slice assignment modifies the INPUT list in-place
        documents[:] = [
            d for d in documents
            if d.id not in self.storage
            or self._content_differs(d, self.storage[d.id])
        ]
        # After this point: documents == enriched_docs (same object)
        # On first run:  documents still has 200 items (all are new)
        # On second run: documents is now [] (all 200 match their stored versions)

    for doc in documents:
        self.storage[doc.id] = doc

    return len(documents)

On the first indexing run, all 200 HR policy document chunks are new. The deduplication filter keeps all of them. enriched_docs retains 200 items. The component returns a list with 200 documents. The downstream DocumentSplitter processes them and produces chunks. Queries work.

On the second nightly re-index (same 200 documents, same content, because HR policy files rarely change), all 200 documents already exist in the store with matching content. The slice assignment sets enriched_docs to []. Because enriched_docs is the same Python object as documents inside write_documents(), the mutation is visible from the caller. DocumentEnricher.run() returns {"documents": []}. The DocumentSplitter receives an empty list and produces zero chunks. The InMemoryBM25Retriever still has the documents from the first run in the store (the OVERWRITE deduplication skipped all 200 because none had changed, so the store is intact), but the DocumentSplitter receives nothing, and the indexing run “completes” with 0 chunks written.

# Data flow timeline — first run vs. second run

# FIRST RUN (Feb 1 2026, cold start)
# ─────────────────────────────────────────────────────────────────────
# document_store.storage = {}  (empty)
# DocumentEnricher.run() builds enriched_docs = [D1, D2, ..., D200]
# write_documents(enriched_docs, DuplicatePolicy.OVERWRITE):
#   dedup filter: all 200 are new → enriched_docs[:] = [D1..D200] (unchanged)
#   stores D1..D200 in document_store.storage
#   returns 200
# enriched_docs still has 200 items
# DocumentEnricher returns {"documents": [D1, ..., D200]}
# DocumentSplitter receives 200 documents → produces 847 chunks
# InMemoryBM25Retriever queries 847 indexed chunks → queries WORK ✓

# SECOND RUN (Feb 2 2026, nightly re-index)
# ─────────────────────────────────────────────────────────────────────
# document_store.storage = {D1.id: D1, ..., D200.id: D200}  (from first run)
# DocumentEnricher.run() builds enriched_docs = [D1', D2', ..., D200']
#   (same content, new Document objects — all 200 match stored versions)
# write_documents(enriched_docs, DuplicatePolicy.OVERWRITE):
#   dedup filter: all 200 match stored versions → enriched_docs[:] = []
#   stores nothing (nothing to write — all skipped by dedup)
#   returns 0
# enriched_docs is now []  ← SAME OBJECT, now empty
# DocumentEnricher returns {"documents": []}
# DocumentSplitter receives 0 documents → produces 0 chunks
# Pipeline "completes" with no errors, no warnings
# InMemoryBM25Retriever: document_store.storage STILL has D1..D200 (untouched)
# BUT: the pipeline just ran and produced no new index updates
# Query pipeline: retriever.run(query="vacation policy") → returns top_k=5 documents
#   (from the FIRST run's index — which is still valid and correct)
# Wait — queries should still work?

# ← The real failure mode is more subtle:
# If the team's indexing pipeline also feeds a separate DocumentWriter
# that handles chunk-level indexing downstream of the splitter,
# AND the DocumentWriter uses OVERWRITE, then after the second run:
# splitter output = [] → DocumentWriter writes nothing → BM25 index is stale
# for any documents that WERE changed between runs.
# For Northbridge's use case: HR docs change quarterly.
# When they update "Annual Leave Policy.txt" on April 1,
# the enricher sees 199 unchanged + 1 updated doc.
# enriched_docs[:] = [D_leave_updated] (only 1 survives the dedup filter)
# enriched_docs is now [D_leave_updated]
# DocumentSplitter processes 1 document → 4 chunks
# But the BM25 index still has the OLD 4 chunks from the Feb 1 run
# The new 4 chunks are ADDED alongside the old ones (DocumentWriter OVERWRITE
# by chunk ID fails because chunk IDs changed with new content hashes)
# Result: 8 copies of the leave policy in the index after 8 quarterly updates
# BM25 returns 5 stale chunks for "vacation policy" out of the top 5
# Generator reads February 2026 policy, not current April 2026 policy

Discovery: tracing the list object identity

The retainer developer’s diagnostic was to add object identity logging to the DocumentEnricher component:

# Diagnostic additions to DocumentEnricher.run()

@component.output_types(documents=List[Document])
def run(self, documents: List[Document]) -> Dict[str, Any]:
    enriched_docs = []
    for doc in documents:
        enriched_docs.append(Document(
            content=doc.content.strip(),
            meta={**doc.meta, "enriched": True, "word_count": len(doc.content.split())}
        ))

    print(f"[DEBUG] Before write_documents: len(enriched_docs) = {len(enriched_docs)}")
    print(f"[DEBUG] enriched_docs object id: {id(enriched_docs)}")

    self.document_store.write_documents(enriched_docs, policy=DuplicatePolicy.OVERWRITE)

    print(f"[DEBUG] After write_documents:  len(enriched_docs) = {len(enriched_docs)}")
    print(f"[DEBUG] enriched_docs object id: {id(enriched_docs)}")

    return {"documents": enriched_docs}

# Output on first run:
# [DEBUG] Before write_documents: len(enriched_docs) = 200
# [DEBUG] enriched_docs object id: 140234567890112
# [DEBUG] After write_documents:  len(enriched_docs) = 200
# [DEBUG] enriched_docs object id: 140234567890112

# Output on second run (same documents):
# [DEBUG] Before write_documents: len(enriched_docs) = 200
# [DEBUG] enriched_docs object id: 140234512345678
# [DEBUG] After write_documents:  len(enriched_docs) = 0    ← mutation confirmed
# [DEBUG] enriched_docs object id: 140234512345678          ← same object

# The list was emptied in-place by write_documents().
# The object ID is the same before and after — the list was modified, not replaced.
# Any caller that holds a reference to this list now sees an empty list.

Reading the InMemoryDocumentStore source confirmed the slice assignment. The deduplication filter at line 127 in haystack-ai 2.2.0 uses documents[:] = [...] rather than creating a new list. This is an implementation detail that mutates the input list in-place, which is safe when the caller doesn’t hold another reference to the same list — but breaks when the caller does.

The fix: save a copy before passing to write_documents()

# indexing_pipeline.py — FIXED
# Northbridge Document Intelligence Ltd.
# haystack-ai 2.2.0, Python 3.11

@component
class DocumentEnricher:
    """Add source metadata and normalize content before indexing."""

    def __init__(self, document_store: InMemoryDocumentStore):
        self.document_store = document_store

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]) -> Dict[str, Any]:
        enriched_docs = []
        for doc in documents:
            enriched_docs.append(Document(
                content=doc.content.strip(),
                meta={
                    **doc.meta,
                    "enriched": True,
                    "word_count": len(doc.content.split()),
                }
            ))

        # Save a copy BEFORE write_documents() may mutate enriched_docs
        docs_to_return = list(enriched_docs)  # ← shallow copy: new list, same Document objects

        # write_documents() may empty enriched_docs via slice assignment in OVERWRITE path
        self.document_store.write_documents(
            enriched_docs,
            policy=DuplicatePolicy.OVERWRITE
        )

        # Return the pre-copy, which is unaffected by write_documents() mutation
        return {"documents": docs_to_return}

The list(enriched_docs) call creates a new list object containing references to the same Document objects. The slice assignment in write_documents() modifies the enriched_docs list (adding or removing items), but does not affect docs_to_return because they are separate list objects. The Document objects themselves are not copied — they are shared between both lists — but since write_documents() only removes items from the list (not mutates the Document objects), this shallow copy is sufficient.

The retainer developer also recommended a cleaner long-term architecture: separate the side effect (writing to the store) from the pipeline component, using a standard DocumentWriter component connected downstream of the enricher, rather than embedding the write inside the enricher’s run() method. This separation makes both components independently testable and eliminates the shared-list-reference hazard entirely:

# Preferred long-term architecture: separate enrichment from writing

from haystack.components.writers import DocumentWriter

@component
class DocumentEnricher:
    """Add source metadata and normalize content. Does NOT write to store."""

    @component.output_types(documents=List[Document])
    def run(self, documents: List[Document]) -> Dict[str, Any]:
        enriched_docs = [
            Document(
                content=doc.content.strip(),
                meta={**doc.meta, "enriched": True, "word_count": len(doc.content.split())}
            )
            for doc in documents
        ]
        return {"documents": enriched_docs}
        # No side effects. No write_documents(). No shared references.

# Pipeline connects enricher → splitter → writer (DocumentWriter handles store writes)
indexing_pipeline = Pipeline()
indexing_pipeline.add_component("converter", TextFileToDocument())
indexing_pipeline.add_component("enricher", DocumentEnricher())
indexing_pipeline.add_component("splitter", DocumentSplitter(split_by="word", split_length=200))
indexing_pipeline.add_component("writer", DocumentWriter(
    document_store=document_store,
    policy=DuplicatePolicy.OVERWRITE
))

indexing_pipeline.connect("converter.documents", "enricher.documents")
indexing_pipeline.connect("enricher.documents", "splitter.documents")
indexing_pipeline.connect("splitter.documents", "writer.documents")
# DocumentWriter owns all writes; no component returns a reference it also mutates

Validation

# Test results after fix (October 24 2026)

# Indexing pipeline — run 1:
# DocumentEnricher: 200 documents enriched
# DocumentSplitter: 847 chunks produced
# Pipeline output: {'splitter': {'documents': [ × 847]}}
# document_store.count_documents(): 847

# Query pipeline — run 1:
# query: "What is the annual leave entitlement for senior staff?"
# retriever top-5: ['Annual Leave Policy — Section 3...', ...]
# generator: "Senior staff are entitled to 25 days annual leave per year..."
# ✓ Correct answer

# Indexing pipeline — run 2 (same 200 documents, same content):
# DocumentEnricher: 200 documents enriched
# DocumentSplitter: 847 chunks produced
# Pipeline output: {'splitter': {'documents': [ × 847]}}
# document_store.count_documents(): 847  ← same, not emptied

# Query pipeline — run 2:
# query: "What is the annual leave entitlement for senior staff?"
# retriever top-5: ['Annual Leave Policy — Section 3...', ...]  ← same as run 1
# generator: "Senior staff are entitled to 25 days annual leave per year..."
# ✓ Correct answer — re-index no longer breaks querying

# Indexing pipeline — run 3 (updated Annual Leave Policy.txt):
# DocumentEnricher: 200 documents enriched (199 unchanged + 1 updated)
# DocumentSplitter: 847 chunks produced
# Pipeline output: 847 chunks (4 for updated leave policy, replacing old 4)
# document_store.count_documents(): 847  ← unchanged (OVERWRITE replaced 4 chunks)

# Query pipeline — run 3:
# query: "What is the annual leave entitlement for senior staff?"
# retriever top-5: includes updated policy content (April 2026 revision)
# ✓ Correct updated content returned

# Before fix (Feb–Oct 2026):
# Indexing run 2+ always produced 0 chunks
# Retriever always returned stale content from first-ever indexing run
# 8 months of broken re-indexing

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →