Advanced RAG: Sentence-Window Retrieval & Metadata Filtering

22 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Illusion of Simplicity in Production RAG

For senior engineers tasked with building robust, production-ready Retrieval-Augmented Generation (RAG) systems, the initial tutorials and high-level frameworks often paint a deceptively simple picture: split documents, embed chunks, and perform a vector search. This naive approach crumbles when faced with the messy reality of dense, complex documents—legal contracts, financial reports, or extensive technical documentation. The primary failure point is almost always the retrieval step, where the quality of the context fed to the Large Language Model (LLM) is determined.

Naive fixed-size or recursive character splitting is a blunt instrument. It frequently results in two critical, context-destroying failure modes:

  • Context Fragmentation: A crucial sentence or idea is bisected across two separate chunks. The retrieved chunk contains only half the necessary information, leading to incomplete or incorrect answers from the LLM.
  • Contextual Insufficiency: A retrieved chunk, while containing the keywords from the query, lacks the surrounding sentences that provide essential context. For example, a sentence stating "the system is not liable" is useless without the preceding sentences that define what system and under what conditions.
  • This article dives deep into a sophisticated solution to this problem: Sentence-Window Retrieval. We will pair this advanced parsing technique with another production necessity: Metadata Pre-filtering. Together, these strategies allow for the creation of RAG systems that are not only more accurate but also more efficient and scalable. We will move beyond framework abstractions to implement the core logic, providing you with the understanding needed to build and customize these pipelines for your specific use cases.


    Section 1: Deconstructing Sentence-Window Retrieval

    The core idea behind Sentence-Window Retrieval is elegant yet powerful: embed a single, focused sentence, but retrieve a larger window of context surrounding that sentence. This decouples the unit of embedding (for precise semantic search) from the unit of retrieval (for comprehensive context).

    Here’s how it works:

  • Parsing: A document is first split into individual sentences.
  • Node Creation: For each sentence (let's call it the central_sentence), we create a "node" or a document object for our vector store.
  • Embedding: The embedding for this node is generated only from the central_sentence.
  • Metadata Augmentation: We store the full "window" of surrounding sentences (e.g., k sentences before and k sentences after) in the metadata of that node.
  • Retrieval: When a user query matches the embedding of the central_sentence, we retrieve the node. Instead of passing just the central_sentence to the LLM, we pass the full text of the sentence window stored in its metadata.
  • This approach provides the best of both worlds: the high precision of sentence-level semantic search and the rich context required by the LLM to generate a high-quality response.

    Implementation: A Custom `SentenceWindowNodeParser`

    While frameworks like LlamaIndex offer built-in sentence-window parsers, understanding the underlying mechanism is crucial for customization and debugging. Let's build our own implementation in Python. We'll use nltk for robust sentence tokenization.

    python
    import nltk
    from typing import List, Dict, Any
    import uuid
    
    # Ensure you have the 'punkt' tokenizer downloaded
    try:
        nltk.data.find('tokenizers/punkt')
    except nltk.downloader.DownloadError:
        nltk.download('punkt')
    
    class DocumentNode:
        def __init__(self, text: str, metadata: Dict[str, Any]):
            self.id = str(uuid.uuid4())
            self.text = text
            self.metadata = metadata
    
        def __repr__(self):
            return f"DocumentNode(id={self.id}, text='{self.text[:50]}...', metadata={self.metadata})"
    
    class SentenceWindowNodeParser:
        def __init__(self, window_size: int = 2):
            """
            Initializes the parser.
            :param window_size: The number of sentences to include before and after the central sentence.
                              A window_size of 2 means 2 sentences before, 1 central, 2 sentences after.
            """
            if window_size < 0:
                raise ValueError("window_size must be non-negative.")
            self.window_size = window_size
    
        def parse_document(self, doc_id: str, doc_text: str, doc_metadata: Dict[str, Any] = None) -> List[DocumentNode]:
            """
            Parses a single document into sentence-window nodes.
            """
            sentences = nltk.sent_tokenize(doc_text)
            nodes = []
            if not sentences:
                return []
    
            for i, sentence in enumerate(sentences):
                start_index = max(0, i - self.window_size)
                end_index = min(len(sentences), i + self.window_size + 1)
                
                window_sentences = sentences[start_index:end_index]
                window_text = " ".join(window_sentences)
    
                # The text to be embedded is the single, central sentence
                text_to_embed = sentence
                
                # The context passed to the LLM is the full window
                # We store this in the metadata
                node_metadata = {
                    "doc_id": doc_id,
                    "window": window_text,
                    "original_sentence": sentence,
                    "sentence_index": i
                }
                
                # Merge with any pre-existing document metadata
                if doc_metadata:
                    node_metadata.update(doc_metadata)
    
                node = DocumentNode(text=text_to_embed, metadata=node_metadata)
                nodes.append(node)
                
            return nodes
    
    # --- Example Usage ---
    doc_text = ( 
        "The James Webb Space Telescope (JWST) is a space telescope designed primarily to conduct infrared astronomy. "
        "As the largest optical telescope in space, its high resolution and sensitivity allow it to view objects too old, distant, or faint for the Hubble Space Telescope. "
        "This capability has enabled a broad range of investigations across many fields of astronomy and cosmology, such as observation of the first stars and the formation of the first galaxies. "
        "JWST's primary mirror consists of 18 hexagonal segments made of gold-plated beryllium which combine to create a 6.5-meter diameter mirror. "
        "The telescope was launched on an Ariane 5 rocket from Kourou, French Guiana, on 25 December 2021. "
        "It entered a halo orbit around the second Sun-Earth Lagrange point (L2) in January 2022."
    )
    
    parser = SentenceWindowNodeParser(window_size=1)
    nodes = parser.parse_document("jwst_doc_01", doc_text, {"source": "wikipedia", "year": 2023})
    
    # Let's inspect the third node (index 2)
    node_to_inspect = nodes[2]
    
    print(f"Node ID: {node_to_inspect.id}")
    print("--- Text to be Embedded ---")
    print(node_to_inspect.text)
    print("\n--- Metadata (including full context window) ---")
    print(node_to_inspect.metadata)
    

    Output of the example:

    text
    Node ID: <some_uuid>
    --- Text to be Embedded ---
    This capability has enabled a broad range of investigations across many fields of astronomy and cosmology, such as observation of the first stars and the formation of the first galaxies.
    
    --- Metadata (including full context window) ---
    {'doc_id': 'jwst_doc_01', 'window': 'As the largest optical telescope in space, its high resolution and sensitivity allow it to view objects too old, distant, or faint for the Hubble Space Telescope. This capability has enabled a broad range of investigations across many fields of astronomy and cosmology, such as observation of the first stars and the formation of the first galaxies. JWST\'s primary mirror consists of 18 hexagonal segments made of gold-plated beryllium which combine to create a 6.5-meter diameter mirror.', 'original_sentence': 'This capability has enabled a broad range of investigations across many fields of astronomy and cosmology, such as observation of the first stars and the formation of the first galaxies.', 'sentence_index': 2, 'source': 'wikipedia', 'year': 2023}

    Notice how the text field (for embedding) is just one sentence, while the metadata['window'] field contains the surrounding context. This is the key to the entire technique.


    Section 2: Production-Grade Metadata Filtering

    In any real-world application, documents are not a monolithic blob. They have crucial metadata: source, creation date, author, access control lists, document type, etc. A user might ask, "What were our Q4 2023 financial liabilities?" Your RAG system must be able to filter for documents that are type: 'financial_report' and quarter: 'Q4' and year: 2023 before performing the semantic search.

    Performing this filtering after the vector search (post-filtering) is grossly inefficient. You might retrieve 100 irrelevant document chunks from the wrong quarter, filter them all out, and be left with zero useful results. The correct approach is pre-filtering, where the metadata filter is applied at the database level as part of the query.

    Most modern vector databases like Qdrant, Pinecone, and Weaviate support powerful metadata filtering alongside vector search. This is often called "hybrid search."

    Designing a Metadata Schema

    Before ingestion, define a strict metadata schema. This prevents inconsistencies and ensures your filtering logic is robust.

    Example Schema:

    * doc_id: string (Unique identifier for the source document)

    * source: string (e.g., 'confluence', 'jira', 's3_bucket_name')

    * doc_type: enum (e.g., 'legal_contract', 'meeting_notes', 'technical_spec')

    * created_at: integer (Unix timestamp for date-range queries)

    * security_level: integer (e.g., 1 for public, 5 for highly confidential)

    * tags: List[string] (For flexible, keyword-based filtering)

    Integrating Metadata into the Ingestion Pipeline

    Let's build a complete ingestion and querying pipeline using Qdrant as our vector store. Qdrant is an excellent choice due to its open-source nature and powerful filtering capabilities. We'll use an in-memory instance for this example, but the client API is identical for a production deployment.

    We will need to install the required libraries:

    bash
    pip install qdrant-client sentence-transformers nltk

    Now, let's create the full pipeline.

    python
    import nltk
    import uuid
    from typing import List, Dict, Any
    
    from qdrant_client import QdrantClient, models
    from qdrant_client.http.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
    from sentence_transformers import SentenceTransformer
    
    # --- Re-using our Node Parser from before ---
    
    class DocumentNode:
        def __init__(self, text: str, metadata: Dict[str, Any]):
            self.id = str(uuid.uuid4())
            self.text = text
            self.metadata = metadata
    
        def __repr__(self):
            return f"DocumentNode(id={self.id}, text='{self.text[:50]}...', metadata={self.metadata})"
    
    class SentenceWindowNodeParser:
        def __init__(self, window_size: int = 2):
            if window_size < 0:
                raise ValueError("window_size must be non-negative.")
            self.window_size = window_size
    
        def parse_document(self, doc_id: str, doc_text: str, doc_metadata: Dict[str, Any] = None) -> List[DocumentNode]:
            sentences = nltk.sent_tokenize(doc_text)
            nodes = []
            if not sentences:
                return []
            for i, sentence in enumerate(sentences):
                start_index = max(0, i - self.window_size)
                end_index = min(len(sentences), i + self.window_size + 1)
                window_sentences = sentences[start_index:end_index]
                window_text = " ".join(window_sentences)
                text_to_embed = sentence
                node_metadata = {
                    "doc_id": doc_id,
                    "window": window_text,
                    "original_sentence": sentence,
                    "sentence_index": i
                }
                if doc_metadata:
                    node_metadata.update(doc_metadata)
                node = DocumentNode(text=text_to_embed, metadata=node_metadata)
                nodes.append(node)
            return nodes
    
    # --- Main Pipeline Logic ---
    
    class RAGPipeline:
        def __init__(self, embedding_model_name='all-MiniLM-L6-v2', collection_name='advanced_rag_demo'):
            # Use an in-memory Qdrant client for this example
            self.client = QdrantClient(":memory:")
            self.embedding_model = SentenceTransformer(embedding_model_name)
            self.collection_name = collection_name
            self.vector_size = self.embedding_model.get_sentence_embedding_dimension()
            self.node_parser = SentenceWindowNodeParser(window_size=2)
            self._create_collection()
    
        def _create_collection(self):
            try:
                self.client.recreate_collection(
                    collection_name=self.collection_name,
                    vectors_config=VectorParams(size=self.vector_size, distance=Distance.COSINE),
                )
                print(f"Collection '{self.collection_name}' created.")
            except Exception as e:
                print(f"Collection may already exist. Error: {e}")
    
        def ingest_documents(self, documents: List[Dict[str, Any]]):
            print("Starting document ingestion...")
            all_nodes = []
            for doc in documents:
                nodes = self.node_parser.parse_document(doc['id'], doc['text'], doc['metadata'])
                all_nodes.extend(nodes)
    
            # Generate embeddings in a batch for efficiency
            texts_to_embed = [node.text for node in all_nodes]
            embeddings = self.embedding_model.encode(texts_to_embed, show_progress_bar=True)
    
            # Prepare points for Qdrant
            points = [
                PointStruct(
                    id=node.id,
                    vector=embedding.tolist(),
                    payload=node.metadata
                )
                for node, embedding in zip(all_nodes, embeddings)
            ]
    
            # Upsert points to the collection
            self.client.upsert(
                collection_name=self.collection_name,
                points=points,
                wait=True
            )
            print(f"Ingested {len(all_nodes)} nodes from {len(documents)} documents.")
    
        def query(self, query_text: str, filters: Dict[str, Any] = None, top_k: int = 3) -> List[Dict[str, Any]]:
            print(f"\nExecuting query: '{query_text}' with filters: {filters}")
            query_embedding = self.embedding_model.encode(query_text).tolist()
            
            # Construct the Qdrant filter from our dictionary
            qdrant_filter = None
            if filters:
                must_conditions = []
                for key, value in filters.items():
                    must_conditions.append(
                        FieldCondition(key=key, match=MatchValue(value=value))
                    )
                qdrant_filter = Filter(must=must_conditions)
    
            search_result = self.client.search(
                collection_name=self.collection_name,
                query_vector=query_embedding,
                query_filter=qdrant_filter,
                limit=top_k,
                with_payload=True
            )
    
            # The payload contains our full context window
            results = []
            for hit in search_result:
                results.append({
                    "score": hit.score,
                    "retrieved_context": hit.payload.get('window'),
                    "source_doc_id": hit.payload.get('doc_id')
                })
            
            return results
    
    # --- Putting it all together: A Real-World Scenario ---
    
    # Sample documents with rich metadata
    sample_docs = [
        {
            "id": "doc-001",
            "text": "The Q1 2023 report indicates a 15% growth in revenue. Key drivers were the North American and European markets. The new product line, 'Project Phoenix', contributed significantly to this growth. However, operational costs also increased by 8% due to supply chain issues.",
            "metadata": {"doc_type": "financial_report", "year": 2023, "quarter": "Q1"}
        },
        {
            "id": "doc-002",
            "text": "Our security protocol update for 2023 requires all employees to use multi-factor authentication. This applies to all internal systems, including email and VPN. The protocol was drafted in response to the recent increase in phishing attacks. Failure to comply will result in account suspension.",
            "metadata": {"doc_type": "security_protocol", "year": 2023, "quarter": "Q1"}
        },
        {
            "id": "doc-003",
            "text": "The Q4 2023 financial report shows a slight downturn. Revenue decreased by 3% compared to the previous quarter. This was primarily due to market saturation in North America. We anticipate a rebound in the next fiscal period with the launch of 'Project Chimera'.",
            "metadata": {"doc_type": "financial_report", "year": 2023, "quarter": "Q4"}
        }
    ]
    
    # Initialize and run the pipeline
    pipeline = RAGPipeline()
    pipeline.ingest_documents(sample_docs)
    
    # --- Query Scenario 1: Unfiltered, general query ---
    results_1 = pipeline.query("What are the key financial drivers?")
    print("\n--- Results (Unfiltered) ---")
    for res in results_1:
        print(f"Score: {res['score']:.4f}, Source: {res['source_doc_id']}")
        print(f"Context: {res['retrieved_context']}\n")
    
    # --- Query Scenario 2: Filtered query for a specific report ---
    query_filters = {"doc_type": "financial_report", "quarter": "Q1"}
    results_2 = pipeline.query("What was the revenue growth?", filters=query_filters)
    print("\n--- Results (Filtered for Q1 Financial Report) ---")
    for res in results_2:
        print(f"Score: {res['score']:.4f}, Source: {res['source_doc_id']}")
        print(f"Context: {res['retrieved_context']}\n")

    In the second query scenario, the filters dictionary is translated into a Qdrant Filter object. The vector search is then performed only on the subset of nodes that match doc_type: 'financial_report' AND quarter: 'Q1'. This is vastly more efficient and guarantees that only relevant documents are considered, preventing the LLM from being confused by information from security protocols or other financial quarters.


    Section 3: Advanced Considerations and Performance Tuning

    Implementing sentence-window retrieval and metadata filtering gets you 90% of the way to a production-ready system. The final 10% involves nuance, performance tuning, and handling edge cases.

    1. The Role of Re-ranking

    Vector similarity search is a powerful but imperfect first pass. It excels at finding a broad set of relevant candidates but may not always place the single most relevant chunk at the very top. This is where a re-ranker comes in.

    A re-ranker is typically a more computationally expensive but more accurate model (often a cross-encoder) that takes the initial top N results from the vector search and re-orders them based on a more sophisticated understanding of semantic relevance to the query.

    Integration into the pipeline:

  • Retrieve More Candidates: Instead of fetching top_k=3, fetch a larger set, e.g., top_k=25.
  • Apply Re-ranker: Pass the query and the retrieved context windows to the re-ranker.
  • Select Final Context: Take the new top k (e.g., top 3) results from the re-ranker's output to pass to the LLM.
  • python
    # Pseudocode for integrating a re-ranker (e.g., using Cohere's API or a local model)
    
    def query_with_reranker(self, query_text, filters=None, initial_k=25, final_k=3):
        # Step 1: Initial retrieval from vector store
        initial_results = self.query(query_text, filters=filters, top_k=initial_k)
        
        # Step 2: Prepare documents for re-ranking
        docs_to_rerank = [res['retrieved_context'] for res in initial_results]
        
        # Step 3: Call the re-ranker
        # reranker_response = cohere_client.rerank(query=query_text, documents=docs_to_rerank, top_n=final_k)
        # Or use a sentence-transformers cross-encoder model
        reranked_indices = self.rerank_model.predict([(query_text, doc) for doc in docs_to_rerank])
        
        # Step 4: Select the new top results
        final_results = [initial_results[i] for i in reranked_indices[:final_k]]
        return final_results
    

    This two-stage process significantly improves the quality of the final context, reducing hallucinations and increasing the factual accuracy of the LLM's response.

    2. Performance Benchmarking and Trade-offs

    * Window Size (k): This is a critical hyperparameter. A small k (e.g., 0 or 1) provides very focused but potentially insufficient context. A large k (e.g., 5) provides rich context but increases the payload size and might introduce more noise or hit the LLM's context token limit. The optimal size is domain-specific. For legal documents, larger windows might be necessary to capture all relevant clauses. For short FAQs, smaller windows are better. Benchmark this parameter using an evaluation dataset with metrics like Hit Rate and Mean Reciprocal Rank (MRR).

    * Embedding Model Selection: The choice of embedding model is paramount. Generic models like OpenAI's text-embedding-ada-002 are decent, but retrieval-specific models often perform much better. Models like BAAI/bge-large-en-v1.5 or GritLM-7B are trained specifically for retrieval tasks and can lead to a significant uplift in performance. Always choose a model whose embedding dimensions and training data align with your use case.

    * Ingestion vs. Query Latency: Sentence-window parsing is more computationally intensive at ingestion time than simple chunking. However, this one-time cost pays dividends in reduced query-time processing and higher-quality results. For most applications, optimizing for query latency and quality is the correct trade-off.

    3. Edge Case Handling

    * Document Boundaries: Our simple parser doesn't handle the beginning or end of a document perfectly (the window is smaller). For most applications, this is acceptable. For highly sensitive use cases, you could pad the context with special tokens like [DOCUMENT_START] or [DOCUMENT_END].

    * Non-prose Content: How do you handle tables, code blocks, or deeply nested lists? Simple sentence tokenization will fail here. A more robust solution involves a hierarchical parser. For example, you could identify a table using regex or a markup parser, embed a semantic summary of the table (e.g., "A table comparing Q1 and Q2 financial results"), and store the full Markdown/HTML of the table in the metadata. This is an advanced topic but essential for handling semi-structured data.

    Conclusion: Graduating to Advanced RAG

    Production-grade RAG is an exercise in moving from broad strokes to fine-grained control. By abandoning naive chunking in favor of Sentence-Window Retrieval, you directly address the core problem of context fragmentation and insufficiency. By integrating Metadata Pre-filtering into your vector database queries, you build efficient, scalable, and highly relevant retrieval systems that can navigate vast and diverse document sets.

    These techniques, combined with a two-stage retrieval process using re-rankers, form the foundation of a sophisticated RAG pipeline. They transform your system from a simple demo into a robust tool capable of delivering precise, contextually-aware, and trustworthy answers, meeting the high standards required for enterprise-level AI applications.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles