Intermediate7 min read6 of 40

Sentence Window Retrieval - Precise Match, Rich Context

Index individual sentences for precise embedding match, then expand each match to its surrounding N-sentence window before passing to the LLM.

Read first:Simple RAG

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)

python
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

python
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, retrieved

Complete Notebook

Run the full implementation:

Hierarchical RAG AWS Notebook

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

MetricParagraph ChunksSentence Window (N=2)
Precision@569%83%
Answer completeness71%88%
Avg context tokens640210
Faithfulness78%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

Next Steps

  1. Try it: Run the Hierarchical RAG notebook
  2. Tune: Experiment with window sizes N=1, 2, 4 for your domain
  3. Layer: Add Reranking (#4) to score expanded windows post-retrieval
  4. Compare: Benchmark against Parent Document Retrieval (#23) on your dataset

Part of: 37 RAG Patterns Collection

Go Deeper With Live Instruction

Covered in depth in our llm engineering program.