§7.3Using Haystack to Build a QA Pipeline

Part II NLPT pp. 47–55 · ~4 min read

  • retriever-reader architecture
  • retriever
  • reader
  • sparse retriever
  • dense retriever
  • bm25
  • tf-idf
  • document store

In §7.2 we handed the model both the question and the context. But real users only give a question — so the system must find the relevant reviews itself. Concatenating all of a product’s reviews into one giant context is too slow (30 reviews × 100 ms ≈ 3 s per query). The answer is the retriever-reader architecture .

Retriever and reader

Click a stage to see what it does. Data flows left → right; each stage transforms its input for the next.

Figure 7-9 (recreated). The retriever-reader architecture — click each stage.
  • The retriever fetches the documents relevant to a query. It comes in two flavors:
    • Sparse retrievers represent query and document as sparse word-frequency vectors ( TF-IDF , BM25 ) and score by inner product — fast, exact-match.
    • Dense retrievers use a transformer to embed query and document as dense contextualized vectors, capturing semantic meaning even when the words don’t match.
  • The reader extracts the answer from the retrieved documents — our span-classification model from §7.2.

Building it with Haystack

We use Haystack (by deepset), built around this architecture. Beyond the retriever and reader it adds two components: a document store (a document database queried at runtime) and a pipeline (wires the components into a query flow).

The document store

Each store supports a different set of retrievers. We pick Elasticsearch because it supports both sparse and dense retrievers:

Table 7-3 — which retrievers pair with which document stores. Elasticsearch supports both sparse and dense, so we use it.
In memoryElasticsearchFAISSMilvus
TF-IDFYesYes
BM25Yes
EmbeddingYesYesYesYes
DPRYesYesYesYes
Dotted-underlined cells have explanations — click one.

Elasticsearch (built on Lucene) indexes our reviews; we load them as {text, meta} dicts so we can later filter by product and split:

from haystack.document_store.elasticsearch import ElasticsearchDocumentStore
document_store = ElasticsearchDocumentStore(return_embedding=True)

for split, df in dfs.items():
    docs = [{"text": row["context"],
             "meta": {"item_id": row["title"], "question_id": row["id"], "split": split}}
            for _, row in df.drop_duplicates(subset="context").iterrows()]
    document_store.write_documents(docs, index="document")
# Loaded 1615 documents

The retriever

We start sparse, with BM25 (“Best Match 25”) — an improved TF-IDF that saturates term frequency quickly and normalizes for document length (favoring short, focused reviews):

from haystack.retriever.sparse import ElasticsearchRetriever
es_retriever = ElasticsearchRetriever(document_store=document_store)

item_id = "B0074BW614"          # an Amazon Fire tablet
retrieved_docs = es_retriever.retrieve(
    query="Is it good for reading?", top_k=3,
    filters={"item_id": [item_id], "split": ["train"]})
# each doc carries a relevance `score` (higher = better match)

Note — filter to one product

We filter to a single item_id, or the retriever would surface reviews about unrelated products (asking about a laptop camera could return phone reviews). The meta fields we stored enable this.

The reader

Haystack offers two readers: FARMReader (deepset’s FARM framework — can fine-tune, doesn’t normalize logits across passages so scores compare across documents) and TransformersReader (inference-only, softmaxes per passage). Since we’ll fine-tune later, we use FARMReader with our MiniLM checkpoint:

from haystack.reader.farm import FARMReader
model_ckpt = "deepset/minilm-uncased-squad2"
reader = FARMReader(model_name_or_path=model_ckpt, progress_bar=False,
                    max_seq_len=384, doc_stride=128, return_no_answer=True)

The max_seq_len and doc_stride are the sliding-window controls from §7.2.

Putting it together

An ExtractiveQAPipeline chains one retriever and one reader:

from haystack.pipeline import ExtractiveQAPipeline
pipe = ExtractiveQAPipeline(reader, es_retriever)

preds = pipe.run(query="Is it good for reading?", top_k_retriever=3, top_k_reader=3,
                 filters={"item_id": [item_id], "split": ["train"]})
# Answer 1: I mainly use it for book reading
# Answer 2: the larger screen compared to the Kindle makes for easier reading
# Answer 3: it is great for reading books when no light is available

We have an end-to-end QA system! But the answers aren’t perfectly ranked — the best ones are second and third. To do better we need to measure each component, starting with evaluating the retriever.

Check yourself: the retriever-reader pipeline

1.Why not just concatenate all of a product's reviews into one context and feed it to the reader?

2.Why is the retriever so important to overall QA performance?

3.What distinguishes a sparse retriever from a dense one?

4.What is BM25?

5.Why is the retriever query filtered to a single item_id?

6.Why use FARMReader rather than TransformersReader here?

6 questions