Build a Q&A System with BERT
Learn to build a question answering system with BERT in this hands-on Applied AI engineering tutorial. Step-by-step guidance, troubleshooting, and next steps to master extractive QA.
Focus: build a question answering system with bert
You have a pile of documents, a burning question, and no time to read every page. Manually extracting answers from long reports, support tickets, or research papers is slow, error-prone, and doesn't scale. This lesson shows you how to build a question answering system with BERT — a focused, extractive QA pipeline that pinpoints the exact sentence in your text that answers a user's question. By the end, you'll have a working Python system that turns raw documents into instant answers, and you'll understand the core ideas behind the transformer revolution in NLP.
The problem this lesson solves
Traditional search returns documents — you still have to read them. Keyword matching (think regex or simple in checks) fails at synonyms and paraphrase: asking "What is the capital of France?" won't match a paragraph that says "Paris serves as the seat of government." This is the information overload problem that every analyst, support team, and researcher faces daily.
You need a system that understands the meaning of your question and the context of the document, and then extracts the answer — not a list of links. A BERT-based question answering system does exactly this: it takes a question and a passage, and returns the start and end positions of the answer span within the passage. It's a supervised extractive QA approach, trained on pairs of questions and passages with labeled answers.
The pain is real: support teams drowning in ticket backlogs, legal teams scanning thousands of contract pages, researchers reviewing hundreds of papers. Automating the answer-finding step with BERT slashes time from hours to milliseconds, and it's accessible even on modest hardware using a pre-trained model.
Core concept / mental model
Think of BERT as a high-speed reader with a highlighter. When you give it a question and a passage (the context), it doesn't 'generate' text from scratch — it reads the passage, understands the question's intent, and highlights the exact substring that answers it. This is extractive question answering, as opposed to generative approaches (like GPT) that write new sentences.
Why BERT? BERT (Bidirectional Encoder Representations from Transformers) processes text both left-to-right and right-to-left simultaneously, capturing deep context. Each word (token) gets a rich vector representation that depends on all the other words around it. For QA, two special output layers are added: one predicts the probability that a token is the start of the answer, the other predicts the probability that it's the end. The system then selects the span with the highest combined probability.
How it works in three layers:
1. Tokenizer — converts raw text into token IDs, adding special [CLS] and [SEP] tokens to mark the question and passage boundaries. The input format is [CLS] question [SEP] passage [SEP].
2. Encoder — passes token IDs through the transformer layers, producing a context-aware embedding for every token.
3. Classifier head — two linear layers that map each token embedding to start/end scores. The span with the highest total score becomes the answer.
This architecture is task-agnostic — you can fine-tune the same pre-trained BERT on any extractive QA dataset (like SQuAD) and it learns the pattern of highlighting relevant spans.
How it works step by step
Here's the cause-and-effect chain of an extractive QA pipeline:
- Input preparation — You provide a question (string) and a context (string). The tokenizer combines them into a single sequence with special separators. If the context is long, it's split into overlapping chunks (sliding window) to stay within the model's maximum token limit (typically 512).
- Tokenization — Words are broken into subword tokens (e.g., 'embedding' → ['em', '##bed', '##ding']). This handles out-of-vocabulary words gracefully and keeps the vocabulary compact.
- Model inference — The tokenized input is fed through the pre-trained BERT model with a QA head. You get two probability distributions: one for start token positions, one for end token positions.
- Answer span decoding — You find the token pair (start, end) with the highest joint probability, respecting that start ≤ end. You then map those token indices back to character offsets in the original passage to extract the answer string.
- Confidence scoring — The probability of the chosen span serves as a confidence score. You can set a threshold to filter out low-confidence answers (e.g., when the model is unsure, fall back to 'no answer').
Handling long contexts: If your passage exceeds 512 tokens, you use a sliding window approach — split the context into overlapping segments, run the model on each, and keep the answer with the highest overall confidence. This is standard practice in production QA systems.
Confidence vs. correctness: High confidence doesn't always mean correct, especially on out-of-domain text. Always evaluate on a held-out set (see Troubleshooting).
Hands-on walkthrough
Let's build a complete, runnable extractive QA system using Hugging Face's transformers library. First, install the dependencies:
pip install transformers torch
Step 1: Load a pre-trained BERT QA model
We'll use distilbert-base-cased-distilled-squad, a distilled version of BERT fine-tuned on the SQuAD dataset. It's fast and accurate enough for most tasks.
from transformers import pipeline
# Quickest path: use the high-level pipeline API
qa_pipeline = pipeline("question-answering", model="distilbert-base-cased-distilled-squad")
context = """
Paris is the capital of France. It is known for its art, fashion, and culture.
The Eiffel Tower, a wrought-iron lattice tower, is a major landmark.
"""
question = "What is the capital of France?"
result = qa_pipeline(question=question, context=context)
print(result)
# OUTPUT: {'score': 0.99, 'start': 0, 'end': 5, 'answer': 'Paris'}
Step 2: Manual pipeline with explicit tokenizer and model
For more control (e.g., custom confidence thresholds, debugging), use the lower-level AutoTokenizer and AutoModelForQuestionAnswering:
from transformers import AutoTokenizer, AutoModelForQuestionAnswering
import torch
model_name = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForQuestionAnswering.from_pretrained(model_name)
question = "Where is the Eiffel Tower?"
context = "The Eiffel Tower is located in Paris, on the Champ de Mars."
inputs = tokenizer(question, context, return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
start_logits = outputs.start_logits[0]
end_logits = outputs.end_logits[0]
# Get the most probable start and end indices
start_idx = torch.argmax(start_logits)
end_idx = torch.argmax(end_logits)
# Convert to answer text
answer_tokens = inputs["input_ids"][0][start_idx:end_idx+1]
answer = tokenizer.decode(answer_tokens, skip_special_tokens=True)
print(answer) # OUTPUT: Paris
Expected output:
Paris
Step 3: Build a reusable function with confidence filtering
def answer_question(question, context, topk=1):
inputs = tokenizer(question, context, return_tensors="pt", truncation=True)
with torch.no_grad():
outputs = model(**inputs)
start_probs = torch.softmax(outputs.start_logits, dim=-1)[0]
end_probs = torch.softmax(outputs.end_logits, dim=-1)[0]
# naive: best start and end (ignoring constraints)
start_idx = torch.argmax(start_probs)
end_idx = torch.argmax(end_probs)
if start_idx > end_idx:
return None, 0.0
answer = tokenizer.decode(inputs["input_ids"][0][start_idx:end_idx+1], skip_special_tokens=True)
confidence = (start_probs[start_idx] * end_probs[end_idx]).item()
return answer, confidence
answer, conf = answer_question("Who designed the Eiffel Tower?", "The Eiffel Tower was designed by Gustave Eiffel.")
print(f"Answer: {answer}, Confidence: {conf:.3f}")
# OUTPUT: Answer: Gustave Eiffel, Confidence: 0.983
Compare options / when to choose what
Not all QA systems are equal. Here's a quick comparison of the main approaches you can adopt.
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Extractive BERT (this lesson) | Fast, interpretable, high precision on in-domain data | Requires the answer to be verbatim in the context; limited to span extraction | Factoid questions from trusted documents |
| Generative LLMs (e.g., GPT-4) | Can paraphrase and synthesize answers; flexible | Slower, pricier, can hallucinate | Open-ended questions, summarization, creative answers |
| Retrieval + Reader (RAG) | Scales to huge corpora; dictionary-style knowledge | More complex infrastructure (vector DB, retriever) | Enterprise search, support bots with large knowledge bases |
| Fine-tuned small models (DistilBERT) | Lightweight, runs on CPU | Lower accuracy than full BERT/RoBERTa | Edge devices, real-time apps |
When to choose extractive BERT: - You need deterministic, verifiable answers (e.g., legal, medical, financial). - Your corpus is fixed and clean — answers are present verbatim. - You have limited compute budget.
When to avoid: - Questions require synthesis (e.g., "Summarize the main argument"). - The answer never appears verbatim in the source. - You need to handle multiple documents at once (then use RAG).
Pro tip: For a production-grade system, always combine a retriever (e.g., BM25 or embeddings) with BERT reader — this is the foundation of Retrieval-Augmented Generation (RAG), which you'll likely explore in a later lesson.
Troubleshooting & edge cases
Problem: The model returns a nonsensical or empty answer. - Cause: The answer is not present in the context, or the context is too short. - Fix: Check the context length (minimum 5 tokens). If the context is irrelevant, fine-tune on your domain or use a larger model.
Problem: The answer is a single word but you expected a phrase.
- Cause: The model found a high-probability start but a low-probability end, or the end index is before the start.
- Fix: Enforce start_idx <= end_idx in your decoding logic (as in the example above). For phrase-level answers, consider using a model fine-tuned on a dataset with longer spans (e.g., Natural Questions).
Problem: The model is slow on CPU.
- Cause: Full BERT is heavy.
- Fix: Use a distilled or quantized version (e.g., distilbert-base-uncased-distilled-squad) or switch to ONNX Runtime. See the code below for a distilbert example.
Problem: The pipeline fails when the context exceeds 512 tokens. - Cause: BERT's positional embeddings are limited to 512. - Fix: Implement a sliding window: split the context into chunks of, say, 400 tokens with 50-token overlap, run QA on each chunk, and keep the highest-confidence answer.
def long_context_qa(question, long_context, chunk_size=400, overlap=50):
# tokenize without truncation to get offsets
tokens = tokenizer.encode(long_context, add_special_tokens=False)
best_answer = None
best_score = 0.0
for start in range(0, len(tokens), chunk_size - overlap):
chunk_tokens = tokens[start:start+chunk_size]
chunk_text = tokenizer.decode(chunk_tokens, skip_special_tokens=True)
answer, score = answer_question(question, chunk_text)
if answer and score > best_score:
best_answer, best_score = answer, score
return best_answer, best_score
Edge case: No answer in context. The model will always output something — even a wrong span. Mitigate by setting a confidence threshold (e.g., 0.5) and returning "Unable to answer" below it.
What you learned & what's next
You now know how to build a question answering system with BERT: you can load a pre-trained extractive QA model, feed it a question and context, and receive a precise answer with a confidence score. You've seen the mental model of BERT as a reader-with-highlighter, understood the tokenization → encoding → span decoding pipeline, and practiced with both the high-level pipeline API and the manual tokenizer/model approach. You've also compared extractive BERT to generative LLMs and RAG, and you're equipped to handle long contexts and low-confidence answers.
Next lesson: In the Python AI track, you'll likely move on to Retrieval-Augmented Generation (RAG) — combining a document retriever with a reader like BERT to answer questions over an entire corpus. That's the natural next step to scale your QA system beyond a single passage.
Now go ahead — extract answers from your own documents and see how much time you save!
Practice recap
Try building a QA system for a real document: take a chapter from a public domain book (e.g., from Project Gutenberg), ask three factoid questions, and measure the confidence scores. Then introduce a typo in the context and observe how the answer changes — this will help you internalize the model's behavior and prepare you for the next lesson on RAG.
Common mistakes
- Asking questions whose answers require synthesis or reasoning not present verbatim in the context — BERT can only extract spans, not create new text.
- Forgetting to check
start_idx <= end_idx, leading to empty or nonsensical answers when decoding the span. - Ignoring the 512-token limit and passing a full document as context, which often silently truncates and misses the answer.
- Using a generic SQuAD model on a highly specialized domain without fine-tuning, then wondering why accuracy is low.
- Not setting a confidence threshold, so the model always returns an answer — even when it's just guessing.
Variations
- Use a different pre-trained model like
bert-large-uncased-whole-word-masking-finetuned-squadfor higher accuracy at the cost of speed. - Generate answers with a generative LLM (e.g.,
text-davinci-003) when you need paraphrased or synthesized responses rather than exact spans. - Combine a retriever (e.g., BM25 or sentence embeddings) before the BERT reader to handle large document collections — the basis of RAG systems.
Real-world use cases
- Customer support chatbot that answers 'How do I reset my password?' from a knowledge base article.
- Legal contract analysis tool that extracts 'What is the termination clause for?' from a long agreement.
- Medical research assistant that finds 'What is the dosage of aspirin for adults?' within clinical study PDFs.
Key takeaways
- BERT-based QA is extractive — it pinpoints and returns a verbatim span from the context, not a generated sentence.
- The input format is
[CLS] question [SEP] context [SEP], and the model outputs start and end logits for each token. - The Hugging Face
pipelineAPI gives you a working QA system in a few lines of code, but manual control offers better debugging and customization. - Respect the 512-token limit on context — use a sliding window for long documents and keep the highest-confidence answer.
- Always set a confidence threshold to gracefully handle questions where the answer is not in the context.
- For answering across many documents, pair BERT with a retriever to build a RAG pipeline — the natural next step.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.