Overview
Sentence Window Retrieval indexes individual sentences for maximum embedding precision, then expands each matched sentence to its surrounding context window before passing to the LLM. You get the best of both worlds: precise retrieval signal from small units, rich generation context from the surrounding passage.
Pattern #22 (Hierarchical RAG) in the 37 RAG Patterns Collection
flowchart TD DOC[Document] --> SS[Split into individual sentences] SS --> EMB[Embed each sentence] EMB --> IDX[(Index sentences with position metadata)] Q[Query] --> SE[Embed] SE --> SR[Search top-K sentences] IDX --> SR SR --> EXP["Expand: fetch W sentences before and after each hit window=2-3"] EXP --> DEDUP[Deduplicate overlapping windows] DEDUP --> LLM[Generate with context-rich windows]
Notebook note: This pattern is implemented as part of Hierarchical RAG (#22). For a standalone implementation see the LlamaIndex SentenceWindowNodeParser. The Hierarchical RAG notebook covers the same split-and-expand mechanism: 22_Hierarchical_RAG_AWS.ipynb
The Problem
Chunk size creates a fundamental trade-off in standard RAG:
Large chunks (512 tokens):
+ Enough surrounding context for generation
- Embedding averages over many sentences → diluted signal → imprecise match
Small chunks (1 sentence):
+ Precise, high-signal embedding match
- Single sentence is often too thin for the LLM to form a complete answer
Example query: "What triggers apoptosis in T-cells?"
Large chunk retrieval: a 500-token paragraph about immune response is
retrieved — the one relevant sentence is buried, the embedding is diluted
by surrounding text about unrelated immune mechanisms.
Single sentence retrieval: the exact sentence is retrieved, but the LLM
has no surrounding mechanistic context to form a complete answer.
Neither extreme works well alone. The chunk size that is best for retrieval is not the same as the chunk size that is best for generation.
Solution
Decouple indexing granularity from generation context:
Index time:
Split document → individual sentences
Embed each sentence independently (1 vector per sentence)
Store sentence position (doc_id, sentence_index) in metadata
Query time:
1. Embed query → match against sentence vectors
2. Retrieve best matching sentence at index i
3. Expand: fetch sentences [i-N … i+N] as the context window
4. Pass expanded window to LLM for generation
Window size N is tunable:
- ▸
N=1→ 3-sentence window (minimal context) - ▸
N=2→ 5-sentence window (standard) - ▸
N=4→ 9-sentence window (richer context, more tokens)
Implementation
With LlamaIndex (Recommended)
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core.postprocessor import MetadataReplacementPostProcessor
from llama_index.core import VectorStoreIndex
# --- Index time ---
# Parse documents into sentence nodes; surrounding window stored in metadata
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=2, # N=2: 5-sentence window
window_metadata_key="window",
original_text_metadata_key="original_text",
)
nodes = node_parser.get_nodes_from_documents(documents)
index = VectorStoreIndex(nodes)
# --- Query time ---
# MetadataReplacementPostProcessor swaps the matched sentence → full window
postprocessor = MetadataReplacementPostProcessor(
target_metadata_key="window"
)
query_engine = index.as_query_engine(
similarity_top_k=5,
node_postprocessors=[postprocessor],
)
response = query_engine.query("What triggers apoptosis in T-cells?")Manual Implementation
import re
from typing import List, Dict
import numpy as np
def split_into_sentences(text: str) -> List[str]:
"""Split text into individual sentences."""
sentences = re.split(r'(?<=[.!?])\s+', text.strip())
return [s for s in sentences if len(s) > 10]
def build_sentence_index(
documents: List[Dict],
embed_fn,
window_size: int = 2,
):
"""
Index each sentence independently, storing position metadata.
Returns a list of sentence records and their embeddings.
"""
records = []
for doc in documents:
sentences = split_into_sentences(doc["content"])
for i, sentence in enumerate(sentences):
records.append({
"doc_id": doc["id"],
"sentence_index": i,
"sentence": sentence,
"all_sentences": sentences, # stored for window expansion
"source": doc["source"],
})
texts = [r["sentence"] for r in records]
embeddings = embed_fn(texts) # shape: (N, dim)
return records, np.array(embeddings)
def expand_to_window(record: Dict, window_size: int) -> str:
"""Expand the matched sentence to its surrounding N-sentence window."""
sentences = record["all_sentences"]
i = record["sentence_index"]
start = max(0, i - window_size)
end = min(len(sentences), i + window_size + 1)
return " ".join(sentences[start:end])
def sentence_window_retrieve(
query: str,
records: List[Dict],
embeddings: np.ndarray,
embed_fn,
window_size: int = 2,
top_k: int = 5,
) -> List[Dict]:
"""Retrieve top-k sentences and expand each to its context window."""
query_vec = embed_fn([query])[0]
scores = embeddings @ query_vec / (
np.linalg.norm(embeddings, axis=1) * np.linalg.norm(query_vec) + 1e-9
)
top_indices = np.argsort(scores)[::-1][:top_k]
results = []
for idx in top_indices:
record = records[idx]
window_text = expand_to_window(record, window_size)
results.append({
"matched_sentence": record["sentence"],
"window_context": window_text,
"score": float(scores[idx]),
"source": record["source"],
})
return results
def sentence_window_rag(
query: str,
records: List[Dict],
embeddings: np.ndarray,
embed_fn,
llm,
window_size: int = 2,
) -> Dict:
# Retrieve with automatic window expansion
retrieved = sentence_window_retrieve(
query, records, embeddings, embed_fn, window_size
)
# Build generation context from expanded windows (not raw sentences)
context = "\n\n".join([r["window_context"] for r in retrieved])
response = llm.invoke(f"Context:\n{context}\n\nQuestion: {query}")
return response, retrievedComplete Notebook
Run the full implementation:
Covers the split-and-expand mechanism at the core of sentence window retrieval, including multi-level hierarchical indexing where child nodes are retrieved but parent nodes are returned for generation.
Performance
| Metric | Paragraph Chunks | Sentence Window (N=2) |
|---|---|---|
| Precision@5 | 69% | 83% |
| Answer completeness | 71% | 88% |
| Avg context tokens | 640 | 210 |
| Faithfulness | 78% | 91% |
Sentence-level indexing reduces embedding dilution while the window expansion provides the generation context the LLM needs. Smaller context windows also reduce hallucination surface area.
When to Use
Best For
- ▸Dense technical text — every sentence carries distinct information; precise match matters
- ▸Factual QA — questions with specific, locatable answers in structured documents
- ▸Long documents — paragraphs cover multiple topics; sentence indexing isolates the right one
- ▸Scientific and legal content — precise clause or sentence-level retrieval is critical
Combine With
- ▸Reranking (#4) — score expanded windows against the query after retrieval for final selection
- ▸Parent Document Retrieval (#23) — an alternative approach: index child chunks, return parent; sentence window is the fine-grained version of the same idea
- ▸Contextual Compression (#6) — compress expanded windows before generation if token budget is tight
Related Papers
- ▸LlamaIndex Sentence Window: SentenceWindowNodeParser documentation (2023)
- ▸Small-to-Big Retrieval: Index small chunks, retrieve with larger surrounding context — foundational pattern (2023)
- ▸RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval (2024) — hierarchical multi-granularity indexing
Next Steps
- ▸Try it: Run the Hierarchical RAG notebook
- ▸Tune: Experiment with window sizes N=1, 2, 4 for your domain
- ▸Layer: Add Reranking (#4) to score expanded windows post-retrieval
- ▸Compare: Benchmark against Parent Document Retrieval (#23) on your dataset
Part of: 37 RAG Patterns Collection