Haystack’s PromptBuilder is the component that turns retrieved documents into structured prompts for your generator. This haystack promptbuilder rag pipeline tutorial walks through building a complete RAG system: from a minimal working example to a production pipeline with citation handling, fallback logic, and token-aware truncation. You’ll end up with code you can drop into a service.
Prerequisites
Python 3.10+ and a virtual environment. Install the core packages:
pip install haystack-ai==2.6.0 \
sentence-transformers==3.0.1 \
rank-bm25==0.2.2 \
openai==1.35.0
You need an OpenAI API key (or any OpenAI-compatible endpoint) for the generator. Export it:
export OPENAI_API_KEY="sk-..."
If you prefer a local model, swap the generator for HuggingFaceLocalGenerator — the PromptBuilder usage stays identical.
Minimal PromptBuilder RAG pipeline
Start with the simplest working pipeline: an in-memory document store, BM25 retrieval, and a single PromptBuilder feeding GPT-4o-mini.
# minimal_rag.py
import os
from haystack import Document, Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="Haystack 2.0 introduces a component-based architecture where each piece — retriever, builder, generator — is a replaceable class."),
Document(content="PromptBuilder uses Jinja2 templates. You can loop over documents, conditionally include sections, and access metadata."),
Document(content="The generator receives the rendered prompt string. It does not know about documents unless you pass them in the template."),
])
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=3)
template = """
Answer the question using only the provided documents.
Documents:
{% for doc in documents %}
[{{ loop.index }}] {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
prompt_builder = PromptBuilder(template=template)
generator = OpenAIGenerator(model="gpt-4o-mini")
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("prompt_builder", prompt_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "prompt_builder.documents")
rag.connect("prompt_builder.prompt", "generator.prompt")
question = "What architecture does Haystack 2.0 use?"
result = rag.run({"retriever": {"query": question}, "prompt_builder": {"question": question}})
print(result["generator"]["replies"][0])
Run it:
python minimal_rag.py
Expected output (abridged):
Haystack 2.0 uses a component-based architecture where each piece — retriever, builder, generator — is a replaceable class.
The pipeline ran three components in sequence. The retriever fetched three documents, PromptBuilder rendered them into a single string, and the generator returned an answer grounded in those documents.
Adding citations and metadata
Production RAG needs citations. Extend the template to include document IDs and scores, then parse them post-generation.
# cited_rag.py
from haystack import Document, Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.document_stores.in_memory import InMemoryDocumentStore
import re
document_store = InMemoryDocumentStore()
docs = [
Document(content="Haystack 2.0 uses a component-based architecture.", meta={"source": "docs/architecture.md", "page": 1}),
Document(content="PromptBuilder templates are Jinja2 with custom filters.", meta={"source": "docs/promptbuilder.md", "page": 3}),
Document(content="Generators only see the rendered prompt string.", meta={"source": "docs/generators.md", "page": 2}),
]
document_store.write_documents(docs)
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=3)
template = """
Answer the question using only the provided documents. Cite sources inline like [1], [2].
Documents:
{% for doc in documents %}
[{{ loop.index }}] (source: {{ doc.meta.source }}, page: {{ doc.meta.page }}) {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
prompt_builder = PromptBuilder(template=template)
generator = OpenAIGenerator(model="gpt-4o-mini")
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("prompt_builder", prompt_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "prompt_builder.documents")
rag.connect("prompt_builder.prompt", "generator.prompt")
question = "How do PromptBuilder templates work?"
result = rag.run({"retriever": {"query": question}, "prompt_builder": {"question": question}})
reply = result["generator"]["replies"][0]
print("Raw reply:\n", reply)
# Extract citations for downstream use
citations = re.findall(r'\[(\d+)\]', reply)
print("\nCited doc indices:", citations)
Output:
Raw reply:
PromptBuilder templates use Jinja2 syntax with custom filters [1]. They can loop over documents and access metadata like source and page [2].
Cited doc indices: ['1', '2']
The generator now emits bracketed numbers that map back to the retrieved list. Your API layer can replace those with links or expand them into full citations.
Token-aware truncation
LLMs have context limits. A haystack promptbuilder rag pipeline tutorial must handle documents that exceed the model’s window. Haystack provides TokenCountTruncator — but you can also implement truncation inside the template using a custom filter.
First, add a token counter utility:
# token_utils.py
import tiktoken
_enc = tiktoken.encoding_for_model("gpt-4o-mini")
MAX_PROMPT_TOKENS = 12000 # leave room for completion
def count_tokens(text: str) -> int:
return len(_enc.encode(text))
def truncate_documents(documents: list, question: str, template: str, max_tokens: int = MAX_PROMPT_TOKENS) -> list:
"""
Greedily drop lowest-ranked documents until the rendered prompt fits.
"""
from haystack.components.builders import PromptBuilder
builder = PromptBuilder(template=template)
for i in range(len(documents), 0, -1):
prompt = builder.run(documents=documents[:i], question=question)["prompt"]
if count_tokens(prompt) <= max_tokens:
return documents[:i]
return [documents[0]] # always keep at least one
Wire it into the pipeline as a custom component:
# truncation_component.py
from haystack import component
from typing import List
from haystack import Document
from token_utils import truncate_documents
@component
class DocumentTruncator:
def __init__(self, template: str, max_tokens: int = 12000):
self.template = template
self.max_tokens = max_tokens
@component.output_types(documents=List[Document])
def run(self, documents: List[Document], question: str):
truncated = truncate_documents(documents, question, self.template, self.max_tokens)
return {"documents": truncated}
Now insert it between retriever and PromptBuilder:
# rag_with_truncation.py
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.document_stores.in_memory import InMemoryDocumentStore
from truncation_component import DocumentTruncator
document_store = InMemoryDocumentStore()
# ... write many long documents ...
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=10)
template = """
{% for doc in documents %}
[{{ loop.index }}] {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
truncator = DocumentTruncator(template=template, max_tokens=12000)
prompt_builder = PromptBuilder(template=template)
generator = OpenAIGenerator(model="gpt-4o-mini")
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("truncator", truncator)
rag.add_component("prompt_builder", prompt_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "truncator.documents")
rag.connect("truncator.documents", "prompt_builder.documents")
rag.connect("prompt_builder.prompt", "generator.prompt")
result = rag.run({
"retriever": {"query": "complex question"},
"truncator": {"question": "complex question"},
"prompt_builder": {"question": "complex question"},
})
The truncator receives the raw document list and the question, renders a trial prompt, and drops tail documents until it fits. This keeps the generator from silently truncating or erroring.
Conditional sections and fallback logic
Real queries vary: some need citations, some need a “no answer found” path, some need a summary prefix. PromptBuilder handles this with Jinja2 conditionals and Haystack’s ConditionalRouter.
Add a router that checks retrieval score:
# conditional_rag.py
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.components.routers import ConditionalRouter
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="Haystack is an LLM framework.", meta={"source": "intro"}),
Document(content="PromptBuilder builds prompts.", meta={"source": "pb"}),
])
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=3)
# Two templates: one for high-confidence, one for low-confidence
high_conf_template = """
High-confidence answer (retrieval score > 0.5).
Documents:
{% for doc in documents %}
[{{ loop.index }}] {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer with citations:
"""
low_conf_template = """
Low-confidence answer (retrieval score <= 0.5).
I found limited relevant information. Here is what I have:
{% for doc in documents %}
- {{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer honestly, stating uncertainty:
"""
high_builder = PromptBuilder(template=high_conf_template, required_variables=["documents", "question"])
low_builder = PromptBuilder(template=low_conf_template, required_variables=["documents", "question"])
generator = OpenAIGenerator(model="gpt-4o-mini")
router = ConditionalRouter(
routes=[
{"condition": "{{ documents[0].score > 0.5 }}", "output": "{{ documents }}", "output_name": "high_conf_docs", "output_type": "list[Document]"},
{"condition": "True", "output": "{{ documents }}", "output_name": "low_conf_docs", "output_type": "list[Document]"},
]
)
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("router", router)
rag.add_component("high_builder", high_builder)
rag.add_component("low_builder", low_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "router.documents")
rag.connect("router.high_conf_docs", "high_builder.documents")
rag.connect("router.low_conf_docs", "low_builder.documents")
rag.connect("high_builder.prompt", "generator.prompt")
rag.connect("low_builder.prompt", "generator.prompt")
# Pass question to both builders via run kwargs
result = rag.run({
"retriever": {"query": "What is Haystack?"},
"high_builder": {"question": "What is Haystack?"},
"low_builder": {"question": "What is Haystack?"},
})
print(result["generator"]["replies"][0])
The router inspects the top document’s BM25 score (available as doc.score after retrieval) and routes to the appropriate builder. Both builders feed the same generator — the pipeline merges their prompt outputs automatically because only one branch executes per run.
Streaming responses
For latency-sensitive UIs, stream tokens instead of waiting for the full completion. OpenAIGenerator supports streaming via a callback:
# streaming_rag.py
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.document_stores.in_memory import InMemoryDocumentStore
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="Streaming sends tokens as they arrive."),
Document(content="Use a callback to process each chunk."),
])
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=2)
template = """
{% for doc in documents %}
{{ doc.content }}
{% endfor %}
Question: {{ question }}
Answer:
"""
prompt_builder = PromptBuilder(template=template)
generator = OpenAIGenerator(model="gpt-4o-mini", streaming_callback=lambda chunk: print(chunk, end="", flush=True))
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("prompt_builder", prompt_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "prompt_builder.documents")
rag.connect("prompt_builder.prompt", "generator.prompt")
rag.run({"retriever": {"query": "How does streaming work?"}, "prompt_builder": {"question": "How does streaming work?"}})
print() # newline after stream
Output appears token-by-token:
Streaming sends tokens as they arrive. Use a callback to process each chunk.
The PromptBuilder usage is unchanged — streaming is a generator concern.
Evaluating prompt quality
Before shipping, measure whether your template actually helps. Use Haystack’s evaluation harness with a small labeled set:
# eval_prompt.py
from haystack import Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.builders import PromptBuilder
from haystack.components.generators import OpenAIGenerator
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.eval import EvaluationRunResult, evaluate
from haystack.eval.metrics import AnswerExactMatch, AnswerF1
document_store = InMemoryDocumentStore()
document_store.write_documents([
Document(content="Paris is the capital of France.", meta={"id": "1"}),
Document(content="Berlin is the capital of Germany.", meta={"id": "2"}),
])
retriever = InMemoryBM25Retriever(document_store=document_store, top_k=2)
template = "Docs: {% for d in documents %}{{ d.content }} {% endfor %} Q: {{ question }} A:"
prompt_builder = PromptBuilder(template=template)
generator = OpenAIGenerator(model="gpt-4o-mini")
rag = Pipeline()
rag.add_component("retriever", retriever)
rag.add_component("prompt_builder", prompt_builder)
rag.add_component("generator", generator)
rag.connect("retriever.documents", "prompt_builder.documents")
rag.connect("prompt_builder.prompt", "generator.prompt")
# Labeled examples
questions = ["Capital of France?", "Capital of Germany?"]
ground_truth = ["Paris", "Berlin"]
results = []
for q, gt in zip(questions, ground_truth):
out = rag.run({"retriever": {"query": q}, "prompt_builder": {"question": q}})
pred = out["generator"]["replies"][0].strip()
results.append({"question": q, "ground_truth": gt, "prediction": pred})
# Compute metrics
em = AnswerExactMatch()
f1 = AnswerF1()
for r in results:
print(f"Q: {r['question']} | GT: {r['ground_truth']} | Pred: {r['prediction']} | EM: {em.compute(r['ground_truth'], r['prediction'])} | F1: {f1.compute(r['ground_truth'], r['prediction'])}")
Sample output:
Q: Capital of France? | GT: Paris | Pred: Paris | EM: 1.0 | F1: 1.0
Q: Capital of Germany? | GT: Berlin | Pred: Berlin | EM: 1.0 | F1: 1.0
Swap templates, re-run, compare. This is how you justify prompt engineering time.
Wiring it to an OpenAI-compatible gateway
If your organization routes model calls through a gateway (for fallbacks, usage metering, or cache-control forwarding), point OpenAIGenerator at that endpoint:
generator = OpenAIGenerator(
model="gpt-4o-mini",
api_base_url="https://api.n4n.ai/v1", # example gateway endpoint
api_key=os.getenv("GATEWAY_KEY"),
)
The rest of the pipeline — retriever, PromptBuilder, truncator, router — remains untouched. The gateway handles provider degradation and token accounting transparently.
Checklist before deploying
- Template version control: Store PromptBuilder templates as
.j2files, not inline strings. Load them at startup. - Input validation: Ensure
questionis non-empty and under a character limit before it hits the template. - Observability: Log the rendered prompt (truncated to 500 chars) and the document IDs used. Correlate with generator latency.
- Fallback chain: If the generator errors, retry with a smaller
top_kor a simpler template. - Token budget: Set
max_tokenson the generator and enforce it in the truncator. Monitor actual usage vs. budget.
What to build next
- Swap BM25 for a dense retriever (
InMemoryEmbeddingRetriever+SentenceTransformersDocumentEmbedder) and compare recall. - Add a
DocumentJoinerto merge results from sparse and dense retrievers before PromptBuilder. - Implement a
PromptBuilderthat emits JSON Schema for structured extraction — useful for downstream agents. - Hook the pipeline into a FastAPI endpoint with request validation, streaming SSE responses, and per-tenant usage quotas.
The PromptBuilder is a small component, but it sits at the seam between retrieval and generation. Getting its template right — citations, truncation, conditionals, streaming — determines whether your RAG pipeline feels like a demo or a product.