Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Building a Self-Improving RAG Pipeline: Automated Chunk Quality Scoring, Retrieval Feedback Loops, and Index Optimization in Production

Building a Self-Improving RAG Pipeline: Automated Chunk Quality Scoring, Retrieval Feedback Loops, and Index Optimization in Production

AI & Machine Learning🔥 Expert29 min readJul 28, 2026Updated Jul 28, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Why Static RAG Fails at Scale
  • Phase 1: Automated Chunk Quality Scoring
  • Designing the Scoring Dimensions
  • Storing Quality Scores as Index Metadata
Phase 2: Instrumentation and Feedback Signal Capture
  • Defining Your Signal Taxonomy
  • Building the Instrumented Retriever
  • The Feedback Store Schema
  • Phase 3: The Retrieval Feedback Loop
  • Incremental Mode: Dynamic Reranking with Score Fusion
  • Batch Mode: Index Optimization Jobs
  • Phase 4: Index Optimization in Production Without Downtime
  • The Blue/Green Index Pattern
  • Continuous Score Propagation Without Full Reindex
  • Hands-On Exercise
  • Setup
  • Step 1: Score Your Existing Chunks
  • Step 2: Instrument Your Retrieval
  • Step 3: Enable Quality Filtering
  • Step 4: Deploy the Feedback Reranker
  • Common Mistakes & Troubleshooting
  • Architecture Considerations at Scale
  • Summary & Next Steps
  • Building a Self-Improving RAG Pipeline: Automated Chunk Quality Scoring, Retrieval Feedback Loops, and Index Optimization in Production

    Introduction

    You've shipped a RAG system. Users are querying it, answers are coming back, and somewhere between "good enough for demo day" and "production-grade intelligence," you've noticed something uncomfortable: the system's performance is frozen in amber. The chunks you created during your initial indexing run still live in your vector store, scored with whatever embedding model you were using three months ago, retrieved by the same naive top-k similarity search you set up in week one. Meanwhile, your users' actual queries have taught you nothing. Your system is getting older, but it isn't getting smarter.

    This is the core tension of production RAG: the pipeline is inherently static, but the world it's supposed to serve is dynamic. Documents get updated, user intent drifts, retrieval patterns reveal which chunks are actually useful versus which ones merely look relevant at embedding time. The gap between a RAG system that works and one that continuously improves is not a matter of choosing a better base model — it's a matter of building feedback infrastructure around the entire pipeline. Every query is a free lesson. Most teams throw those lessons away.

    By the end of this lesson, you'll have built the conceptual and practical foundation for a self-improving RAG pipeline: one that scores chunk quality automatically, captures retrieval signals from real usage, uses those signals to reweight and reorganize its index, and does all of this without requiring a human to babysit every failure mode.

    What you'll learn:

    • How to design and implement an automated chunk quality scoring system that evaluates chunks on multiple dimensions (semantic density, answerability, context sufficiency, and redundancy)
    • How to instrument a RAG pipeline to capture retrieval feedback signals — implicit and explicit — and store them in a structured feedback log
    • How to implement a retrieval feedback loop that uses accumulated signals to rerank, reweight, and progressively retire underperforming chunks
    • How to design an index optimization strategy that runs as a background process without disrupting live traffic
    • How to architect the entire system so each component is testable, observable, and independently replaceable

    Prerequisites

    You should be comfortable with:

    • Building a basic RAG pipeline (chunking, embedding, vector store querying, LLM generation)
    • Python at an intermediate-to-advanced level (async code, dataclasses, type hints)
    • Vector databases — we'll use examples compatible with Qdrant and pgvector, but the concepts transfer to Pinecone, Weaviate, or Chroma
    • Basic understanding of embedding models and cosine similarity
    • Familiarity with LangChain or a similar orchestration framework is helpful but not required

    You do not need prior experience building feedback systems or ML training pipelines. We'll build that intuition from scratch.


    Why Static RAG Fails at Scale

    Before we build anything, we need to understand the failure modes clearly, because the architecture decisions we'll make are direct responses to specific problems.

    The Chunking Lottery. When you first chunk a document corpus, you're making a bet about what queries will look like. Fixed-size chunking assumes every answer fits in 512 tokens. Sentence-boundary chunking assumes semantic meaning doesn't span paragraphs. Recursive chunking is better, but still blind to actual retrieval intent. Whatever strategy you chose, some chunks came out great — coherent, information-dense, contextually complete. Others came out terrible — mid-sentence splits, orphaned data tables, repetitive boilerplate. Your vector store holds both kinds, and it has no way to tell them apart.

    The Embedding Drift Problem. Embedding models encode semantic meaning as it was understood at training time. When your documents use domain-specific language ("churn," "CAC," or "sprint velocity" mean very different things in SaaS versus general conversation), your embeddings may be systematically misaligning query intent with chunk content. This mismatch doesn't show up as an error — it shows up as subtly wrong answers that are hard to debug because the retrieved chunks are plausible.

    The Relevance Signal Graveyard. Every time a user gets a good answer, every time they rephrase a query because the first result was off, every time a generated response includes a caveat like "I couldn't find specific information about..." — all of these are signals. They tell you which chunks are doing work and which are dead weight. Without infrastructure to capture these signals, you're flying blind, rebuilding your index periodically based on intuition rather than evidence.

    The Cold Start / Warm Corpus Mismatch. Your initial index was built on day one, before you had any user traffic. Your corpus has evolved — documents added, updated, deprecated — but chunk-level metadata hasn't kept pace. Over time, your index becomes a palimpsest: new content layered on top of stale structure, with no consistent quality signal across the whole thing.


    Phase 1: Automated Chunk Quality Scoring

    The first piece of self-improvement infrastructure is a scoring system that evaluates every chunk on dimensions that actually predict retrieval success. We're not trying to replace human judgment — we're trying to scale it.

    Designing the Scoring Dimensions

    A useful chunk quality score needs to capture at least four things:

    1. Semantic Density — Does this chunk contain meaningful, information-rich content, or is it mostly structural noise (table of contents entries, page headers, repeated disclaimers)?
    2. Answerability — Could a plausible question be answered using only this chunk? Chunks that can't answer any coherent question are retrieval dead-ends.
    3. Context Sufficiency — Does the chunk contain enough surrounding context to be interpretable in isolation? A chunk that begins with "As mentioned above, the primary reason..." is nearly useless without what came before it.
    4. Redundancy Score — How similar is this chunk to other chunks in the index? High redundancy isn't always bad (sometimes you want multiple perspectives on a topic), but unintentional duplicates pollute retrieval results.

    Each of these can be computed programmatically with varying degrees of sophistication. Let's build a scorer that handles all four.

    import re
    import hashlib
    from dataclasses import dataclass, field
    from typing import Optional
    import numpy as np
    from openai import AsyncOpenAI
    
    @dataclass
    class ChunkQualityScore:
        chunk_id: str
        semantic_density: float      # 0.0 - 1.0
        answerability: float         # 0.0 - 1.0
        context_sufficiency: float   # 0.0 - 1.0
        redundancy_penalty: float    # 0.0 - 1.0 (higher = more redundant)
        composite_score: float       # weighted combination
        scoring_version: str = "1.0"
        metadata: dict = field(default_factory=dict)
    
        def is_indexable(self, threshold: float = 0.45) -> bool:
            return self.composite_score >= threshold
    
    class ChunkQualityScorer:
        """
        Multi-dimensional chunk quality scorer. Designed to run at index time
        and periodically as a background process against existing chunks.
        """
    
        BOILERPLATE_PATTERNS = [
            r"^\s*(page \d+|chapter \d+|table of contents)\s*$",
            r"^\s*(all rights reserved|copyright \d{4})\s*$",
            r"^\s*\.{3,}\s*\d+\s*$",  # TOC dot leaders
            r"^(https?://\S+\s*){3,}$",  # chunks that are mostly URLs
        ]
    
        CONTEXT_DEPENDENCY_PATTERNS = [
            r"^(as mentioned|as discussed|as noted|as stated|as shown) (above|below|earlier|previously)",
            r"^(this|these|those|the following|the above) (is|are|was|were|refers to|shows|demonstrates)",
            r"^(see|refer to|as per) (the|figure|table|section|above|below)",
            r"^(in addition|furthermore|moreover|consequently|therefore|thus),",
        ]
    
        def __init__(
            self,
            openai_client: AsyncOpenAI,
            embedding_model: str = "text-embedding-3-small",
            redundancy_similarity_threshold: float = 0.92,
        ):
            self.client = openai_client
            self.embedding_model = embedding_model
            self.redundancy_threshold = redundancy_similarity_threshold
            self._boilerplate_re = [
                re.compile(p, re.IGNORECASE | re.MULTILINE)
                for p in self.BOILERPLATE_PATTERNS
            ]
            self._context_dep_re = [
                re.compile(p, re.IGNORECASE)
                for p in self.CONTEXT_DEPENDENCY_PATTERNS
            ]
    
        def score_semantic_density(self, text: str) -> float:
            """
            Heuristic density scoring based on information-bearing tokens.
            A production version might use a trained classifier here.
            """
            if not text.strip():
                return 0.0
    
            words = text.split()
            if len(words) < 10:
                return 0.1  # Very short chunks rarely retrieve well
    
            # Check for boilerplate patterns
            for pattern in self._boilerplate_re:
                if pattern.search(text):
                    return 0.05
    
            # Ratio of unique meaningful words to total words
            stopwords = {
                "the", "a", "an", "is", "in", "it", "of", "to", "and",
                "or", "but", "that", "this", "for", "with", "on", "at",
                "from", "by", "as", "be", "was", "are", "were", "been"
            }
            meaningful_words = [
                w.lower() for w in words
                if w.isalpha() and w.lower() not in stopwords
            ]
            unique_meaningful = set(meaningful_words)
    
            lexical_diversity = len(unique_meaningful) / max(len(words), 1)
            content_ratio = len(meaningful_words) / max(len(words), 1)
    
            # Normalize to 0-1, tuned to typical document distributions
            density = min(1.0, (lexical_diversity * 0.6 + content_ratio * 0.4) * 1.8)
            return round(density, 4)
    
        def score_context_sufficiency(self, text: str) -> float:
            """
            Detects whether the chunk depends on context not present in the chunk itself.
            """
            score = 1.0
            first_sentence = text.strip().split('.')[0] if '.' in text else text[:200]
    
            for pattern in self._context_dep_re:
                if pattern.match(first_sentence):
                    score -= 0.35
                    break  # one penalty per chunk; don't double-count
    
            # Hanging references: pronouns at the start suggest prior context needed
            if re.match(r"^(it|they|them|their|its|he|she|his|her)\s", first_sentence, re.IGNORECASE):
                score -= 0.25
    
            # Penalize very short chunks that can't be self-contained
            word_count = len(text.split())
            if word_count < 30:
                score -= 0.20
    
            return max(0.0, round(score, 4))
    
        async def score_answerability(self, text: str, chunk_id: str) -> float:
            """
            Uses an LLM to estimate whether a real question could be answered
            from this chunk alone. This is the most expensive step — cache aggressively.
            """
            prompt = f"""You are evaluating whether a text chunk from a document retrieval system 
    could serve as a useful answer to at least one specific, realistic question.
    
    Respond with a JSON object containing:
    - "can_answer": boolean (true if the chunk answers at least one clear question)
    - "example_question": string (the question it answers, or null)
    - "confidence": float between 0.0 and 1.0
    
    TEXT CHUNK:
    {text[:1200]}
    
    Respond with only the JSON object."""
    
            try:
                response = await self.client.chat.completions.create(
                    model="gpt-4o-mini",
                    messages=[{"role": "user", "content": prompt}],
                    response_format={"type": "json_object"},
                    temperature=0.1,
                    max_tokens=150,
                )
                import json
                result = json.loads(response.choices[0].message.content)
                return float(result.get("confidence", 0.5)) if result.get("can_answer") else 0.15
            except Exception as e:
                # Fail gracefully — don't let scoring errors block indexing
                print(f"Answerability scoring failed for chunk {chunk_id}: {e}")
                return 0.5  # Neutral default
    
        def score_redundancy(
            self,
            chunk_embedding: list[float],
            existing_embeddings: list[list[float]],
        ) -> float:
            """
            Computes redundancy as max cosine similarity against existing chunk embeddings.
            Returns a penalty value (higher = more redundant).
            """
            if not existing_embeddings:
                return 0.0
    
            vec = np.array(chunk_embedding)
            existing_matrix = np.array(existing_embeddings)
    
            # Cosine similarity (assumes embeddings are already normalized)
            similarities = existing_matrix @ vec / (
                np.linalg.norm(existing_matrix, axis=1) * np.linalg.norm(vec) + 1e-9
            )
            max_similarity = float(np.max(similarities))
    
            if max_similarity >= self.redundancy_threshold:
                return min(1.0, (max_similarity - self.redundancy_threshold) / (1 - self.redundancy_threshold))
            return 0.0
    
        async def score_chunk(
            self,
            chunk_id: str,
            text: str,
            chunk_embedding: list[float],
            existing_embeddings: list[list[float]],
            weights: dict = None,
        ) -> ChunkQualityScore:
            """
            Compute composite quality score for a single chunk.
            """
            weights = weights or {
                "semantic_density": 0.30,
                "answerability": 0.35,
                "context_sufficiency": 0.25,
                "redundancy_penalty": 0.10,
            }
    
            density = self.score_semantic_density(text)
            sufficiency = self.score_context_sufficiency(text)
            answerability = await self.score_answerability(text, chunk_id)
            redundancy = self.score_redundancy(chunk_embedding, existing_embeddings)
    
            # Redundancy is a penalty, so we subtract it
            composite = (
                density * weights["semantic_density"]
                + answerability * weights["answerability"]
                + sufficiency * weights["context_sufficiency"]
                - redundancy * weights["redundancy_penalty"]
            )
            composite = max(0.0, min(1.0, composite))
    
            return ChunkQualityScore(
                chunk_id=chunk_id,
                semantic_density=density,
                answerability=answerability,
                context_sufficiency=sufficiency,
                redundancy_penalty=redundancy,
                composite_score=round(composite, 4),
            )
    

    A note on LLM-based answerability scoring: Calling GPT-4o-mini for every chunk during indexing has real cost implications at scale. For a corpus of 100,000 chunks, even at $0.15/1M tokens, it's manageable — but you should cache these scores and only re-score chunks when their content changes. Hash the chunk text and store it alongside the score.

    Storing Quality Scores as Index Metadata

    Quality scores are most useful when they live inside your vector store as metadata, queryable at retrieval time. Here's the pattern for Qdrant:

    from qdrant_client import QdrantClient
    from qdrant_client.models import PointStruct, VectorParams, Distance
    
    async def upsert_chunk_with_quality(
        qdrant: QdrantClient,
        collection_name: str,
        chunk_id: str,
        text: str,
        embedding: list[float],
        quality_score: ChunkQualityScore,
        source_metadata: dict,
    ):
        """
        Stores a chunk with its quality score embedded in Qdrant metadata.
        This enables quality-aware filtering at retrieval time.
        """
        payload = {
            "text": text,
            "source": source_metadata.get("source", "unknown"),
            "document_id": source_metadata.get("document_id"),
            "chunk_index": source_metadata.get("chunk_index"),
            "quality": {
                "composite_score": quality_score.composite_score,
                "semantic_density": quality_score.semantic_density,
                "answerability": quality_score.answerability,
                "context_sufficiency": quality_score.context_sufficiency,
                "redundancy_penalty": quality_score.redundancy_penalty,
                "scoring_version": quality_score.scoring_version,
            },
            "retrieval_stats": {
                "times_retrieved": 0,
                "times_useful": 0,
                "times_skipped": 0,
                "last_retrieved_at": None,
            },
            "indexed_at": source_metadata.get("indexed_at"),
            "content_hash": hashlib.sha256(text.encode()).hexdigest(),
        }
    
        qdrant.upsert(
            collection_name=collection_name,
            points=[PointStruct(id=chunk_id, vector=embedding, payload=payload)],
        )
    

    With quality scores in metadata, you can immediately start filtering at retrieval time:

    from qdrant_client.models import Filter, FieldCondition, Range
    
    def build_quality_filter(min_quality: float = 0.45) -> Filter:
        """Only retrieve chunks that meet a minimum quality threshold."""
        return Filter(
            must=[
                FieldCondition(
                    key="quality.composite_score",
                    range=Range(gte=min_quality)
                )
            ]
        )
    

    This single filter immediately raises the floor of your retrieval quality. You stop wasting context window space on boilerplate, orphaned fragments, and near-duplicates.


    Phase 2: Instrumentation and Feedback Signal Capture

    Quality scoring tells you what you think will retrieve well. Feedback signals tell you what actually does retrieve well. The gap between those two things is where your improvement loop lives.

    Defining Your Signal Taxonomy

    Not all feedback signals are equal. A useful taxonomy for RAG systems:

    Explicit signals (user-generated, high confidence):

    • Thumbs up / thumbs down on a response
    • "Was this helpful?" binary ratings
    • Citation — user copies text from a retrieved chunk
    • Correction — user provides the correct answer

    Implicit signals (behavior-inferred, lower confidence but high volume):

    • Query reformulation — user asked a similar question again within the same session
    • Session continuation — user kept engaging vs. immediately left
    • Response length acceptance — did the user read the full response? (scroll depth if in UI)
    • Follow-up question type — clarification questions suggest the first answer was insufficient

    System signals (pipeline-generated, highest volume):

    • LLM self-reflection score — did the LLM indicate it couldn't find enough information?
    • Faithfulness score — was the response grounded in retrieved context or hallucinated?
    • Retrieval diversity — were all top-k results from the same document (bad) or spread across sources?
    from enum import Enum
    from datetime import datetime, UTC
    from typing import Optional
    from pydantic import BaseModel
    
    class FeedbackSignalType(str, Enum):
        EXPLICIT_POSITIVE = "explicit_positive"
        EXPLICIT_NEGATIVE = "explicit_negative"
        QUERY_REFORMULATION = "query_reformulation"
        SESSION_ABANDONMENT = "session_abandonment"
        LLM_INSUFFICIENT = "llm_insufficient"  # LLM said it couldn't answer
        LLM_CONFIDENT = "llm_confident"
        FAITHFULNESS_HIGH = "faithfulness_high"
        FAITHFULNESS_LOW = "faithfulness_low"
    
    class RetrievalFeedbackEvent(BaseModel):
        event_id: str
        session_id: str
        query: str
        query_embedding_hash: str  # hash of the query embedding for clustering
        retrieved_chunk_ids: list[str]
        chunk_scores_at_retrieval: dict[str, float]  # chunk_id -> similarity score
        signal_type: FeedbackSignalType
        signal_weight: float  # normalized 0-1 signal strength
        timestamp: datetime
        response_text: Optional[str] = None
        metadata: dict = {}
    
        class Config:
            use_enum_values = True
    

    Building the Instrumented Retriever

    The instrumented retriever wraps your base retrieval logic, adds timing, and emits feedback events without blocking the response path:

    import asyncio
    import uuid
    import hashlib
    from datetime import datetime, UTC
    from typing import Callable
    
    class InstrumentedRetriever:
        """
        A retriever wrapper that captures retrieval events and coordinates
        feedback collection for downstream processing.
        """
    
        def __init__(
            self,
            base_retriever,
            feedback_store,  # Your PostgreSQL, Redis, or event queue client
            faithfulness_scorer: Optional[Callable] = None,
        ):
            self.retriever = base_retriever
            self.feedback_store = feedback_store
            self.faithfulness_scorer = faithfulness_scorer
    
        async def retrieve_and_instrument(
            self,
            query: str,
            session_id: str,
            top_k: int = 5,
            quality_threshold: float = 0.45,
        ) -> tuple[list[dict], str]:
            """
            Returns retrieved chunks and a retrieval_id for linking
            subsequent feedback events to this specific retrieval.
            """
            retrieval_id = str(uuid.uuid4())
            query_embedding = await self._embed_query(query)
            query_hash = hashlib.md5(
                str(query_embedding[:10]).encode()
            ).hexdigest()
    
            chunks = await self.retriever.retrieve(
                query_embedding=query_embedding,
                top_k=top_k,
                filter=build_quality_filter(quality_threshold),
            )
    
            # Record which chunks were retrieved and at what similarity
            chunk_scores = {c["chunk_id"]: c["similarity"] for c in chunks}
    
            # Emit the retrieval event — do this asynchronously
            # so it doesn't block the user-facing response
            asyncio.create_task(
                self._emit_retrieval_event(
                    retrieval_id=retrieval_id,
                    session_id=session_id,
                    query=query,
                    query_embedding_hash=query_hash,
                    chunk_scores=chunk_scores,
                )
            )
    
            return chunks, retrieval_id
    
        async def record_llm_signal(
            self,
            retrieval_id: str,
            session_id: str,
            query: str,
            response: str,
            chunk_ids: list[str],
            chunk_scores: dict[str, float],
        ):
            """
            Automatically scored signal based on LLM response analysis.
            Run this after generation, before returning to user.
            """
            insufficient_phrases = [
                "i don't have enough information",
                "i couldn't find",
                "the provided context doesn't",
                "based on available information, i cannot",
                "i was unable to find specific",
            ]
    
            response_lower = response.lower()
            is_insufficient = any(phrase in response_lower for phrase in insufficient_phrases)
    
            signal_type = (
                FeedbackSignalType.LLM_INSUFFICIENT
                if is_insufficient
                else FeedbackSignalType.LLM_CONFIDENT
            )
    
            # Weight: insufficient signals are stronger than confident ones
            # because false negatives (missing info) are more costly than
            # false positives (having extra info available)
            weight = 0.9 if is_insufficient else 0.4
    
            event = RetrievalFeedbackEvent(
                event_id=str(uuid.uuid4()),
                session_id=session_id,
                query=query,
                query_embedding_hash="",
                retrieved_chunk_ids=chunk_ids,
                chunk_scores_at_retrieval=chunk_scores,
                signal_type=signal_type,
                signal_weight=weight,
                timestamp=datetime.now(UTC),
                response_text=response[:500],
            )
    
            await self.feedback_store.append_event(event)
    
        async def record_explicit_feedback(
            self,
            retrieval_id: str,
            session_id: str,
            query: str,
            chunk_ids: list[str],
            chunk_scores: dict[str, float],
            is_positive: bool,
        ):
            """Called when a user explicitly rates a response."""
            event = RetrievalFeedbackEvent(
                event_id=str(uuid.uuid4()),
                session_id=session_id,
                query=query,
                query_embedding_hash="",
                retrieved_chunk_ids=chunk_ids,
                chunk_scores_at_retrieval=chunk_scores,
                signal_type=(
                    FeedbackSignalType.EXPLICIT_POSITIVE
                    if is_positive
                    else FeedbackSignalType.EXPLICIT_NEGATIVE
                ),
                signal_weight=1.0,  # Explicit signals get full weight
                timestamp=datetime.now(UTC),
            )
            await self.feedback_store.append_event(event)
    
        async def _embed_query(self, query: str) -> list[float]:
            # Delegate to your embedding client
            raise NotImplementedError
    
        async def _emit_retrieval_event(self, **kwargs):
            # Internal — write to feedback store
            raise NotImplementedError
    

    The Feedback Store Schema

    Store feedback events in a relational database so you can aggregate, join, and analyze them efficiently. Here's a PostgreSQL schema:

    CREATE TABLE retrieval_feedback_events (
        event_id        UUID PRIMARY KEY,
        session_id      TEXT NOT NULL,
        query           TEXT NOT NULL,
        query_hash      TEXT NOT NULL,  -- For clustering similar queries
        retrieved_chunk_ids TEXT[] NOT NULL,
        chunk_scores    JSONB NOT NULL,  -- {chunk_id: similarity_score}
        signal_type     TEXT NOT NULL,
        signal_weight   FLOAT NOT NULL,
        response_text   TEXT,
        created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        metadata        JSONB DEFAULT '{}'
    );
    
    -- Materialized view: chunk-level aggregated signal
    CREATE MATERIALIZED VIEW chunk_retrieval_stats AS
    SELECT
        chunk_id,
        COUNT(*) as times_retrieved,
        SUM(CASE WHEN signal_type IN ('explicit_positive', 'llm_confident', 'faithfulness_high')
                 THEN signal_weight ELSE 0 END) as positive_signal_sum,
        SUM(CASE WHEN signal_type IN ('explicit_negative', 'llm_insufficient', 'faithfulness_low',
                                       'query_reformulation', 'session_abandonment')
                 THEN signal_weight ELSE 0 END) as negative_signal_sum,
        MAX(created_at) as last_retrieved_at
    FROM retrieval_feedback_events,
         UNNEST(retrieved_chunk_ids) AS chunk_id
    GROUP BY chunk_id;
    
    CREATE INDEX ON retrieval_feedback_events (query_hash);
    CREATE INDEX ON retrieval_feedback_events (created_at);
    CREATE INDEX ON retrieval_feedback_events USING GIN (retrieved_chunk_ids);
    

    Phase 3: The Retrieval Feedback Loop

    Now we connect the signals to action. The feedback loop has two modes: incremental (lightweight, runs frequently) and batch (heavier, runs periodically).

    Incremental Mode: Dynamic Reranking with Score Fusion

    In incremental mode, you don't touch the index at all. Instead, you fuse the static quality score and embedding similarity with a dynamic signal score computed from the feedback store at query time:

    class FeedbackAwareReranker:
        """
        Fuses embedding similarity, chunk quality score, and accumulated
        feedback signals into a final ranking score.
        """
    
        def __init__(self, feedback_store, signal_window_days: int = 30):
            self.feedback_store = feedback_store
            self.signal_window_days = signal_window_days
    
        async def rerank(
            self,
            query: str,
            candidates: list[dict],
            weights: dict = None,
        ) -> list[dict]:
            """
            Candidates should already have 'similarity', 'quality_score', and 'chunk_id' fields.
            """
            weights = weights or {
                "similarity": 0.50,
                "quality": 0.25,
                "feedback_signal": 0.25,
            }
    
            chunk_ids = [c["chunk_id"] for c in candidates]
            feedback_scores = await self._get_feedback_scores(chunk_ids)
    
            scored = []
            for chunk in candidates:
                cid = chunk["chunk_id"]
                feedback = feedback_scores.get(cid, {"score": 0.5, "confidence": 0.0})
    
                # Blend feedback score toward neutral when confidence is low
                # (i.e., when we have few observations for this chunk)
                blended_feedback = (
                    feedback["score"] * feedback["confidence"]
                    + 0.5 * (1 - feedback["confidence"])
                )
    
                final_score = (
                    chunk["similarity"] * weights["similarity"]
                    + chunk["quality_score"] * weights["quality"]
                    + blended_feedback * weights["feedback_signal"]
                )
    
                scored.append({**chunk, "final_score": round(final_score, 4)})
    
            return sorted(scored, key=lambda x: x["final_score"], reverse=True)
    
        async def _get_feedback_scores(
            self, chunk_ids: list[str]
        ) -> dict[str, dict]:
            """
            Fetches aggregated feedback for a list of chunk IDs.
            Uses Wilson score interval for statistical robustness.
            """
            raw_stats = await self.feedback_store.get_chunk_stats(
                chunk_ids=chunk_ids,
                window_days=self.signal_window_days,
            )
    
            result = {}
            for cid, stats in raw_stats.items():
                pos = stats["positive_signal_sum"]
                neg = stats["negative_signal_sum"]
                total = pos + neg
    
                if total < 5:
                    # Not enough data — low confidence, neutral score
                    result[cid] = {"score": 0.5, "confidence": min(total / 5.0, 1.0)}
                else:
                    # Wilson score interval lower bound (conservative estimate)
                    import math
                    z = 1.96  # 95% confidence
                    p_hat = pos / total
                    denominator = 1 + z**2 / total
                    center = (p_hat + z**2 / (2 * total)) / denominator
                    margin = (z * math.sqrt(p_hat*(1-p_hat)/total + z**2/(4*total**2))) / denominator
                    wilson_lower = center - margin
    
                    result[cid] = {
                        "score": round(wilson_lower, 4),
                        "confidence": min(1.0, total / 50.0),  # Full confidence at 50+ observations
                    }
    
            return result
    

    The Wilson score interval is important here. It prevents you from massively upranking a chunk that got three positive signals and zero negative ones — three observations are not statistically meaningful. The Wilson lower bound stays near 0.5 until you accumulate enough data to justify a stronger adjustment.

    Batch Mode: Index Optimization Jobs

    Batch optimization jobs run as background processes — nightly or weekly — and make structural changes to the index. There are three primary jobs:

    Job 1: Quality Score Refresh

    Chunks that have accumulated significant negative feedback should have their quality scores recalibrated. This is also the right time to re-score any chunks that haven't been scored yet (documents added before you deployed the scorer).

    class QualityScoreRefreshJob:
        """
        Identifies chunks whose quality scores need updating and recomputes them.
        Designed to run as a background job without disrupting live traffic.
        """
    
        def __init__(self, qdrant, scorer, feedback_store, batch_size: int = 100):
            self.qdrant = qdrant
            self.scorer = scorer
            self.feedback_store = feedback_store
            self.batch_size = batch_size
    
        async def run(self, collection_name: str, max_chunks: int = 5000):
            """
            Priority order for rescoring:
            1. Chunks with high negative feedback and no recent rescore
            2. Chunks with old or missing quality scores
            3. Chunks with content changes (hash mismatch)
            """
            candidates = await self._identify_rescore_candidates(
                collection_name, max_chunks
            )
            print(f"Quality refresh: {len(candidates)} chunks to rescore")
    
            for i in range(0, len(candidates), self.batch_size):
                batch = candidates[i : i + self.batch_size]
                await self._rescore_batch(collection_name, batch)
                await asyncio.sleep(0.1)  # Yield — don't saturate the event loop
    
        async def _identify_rescore_candidates(
            self, collection_name: str, max_chunks: int
        ) -> list[str]:
            """
            Queries Qdrant for chunks matching rescore criteria.
            In a real implementation, use scroll API with appropriate filters.
            """
            # Chunks with high negative feedback
            high_negative = await self.feedback_store.get_chunks_with_negative_ratio(
                threshold=0.6, min_observations=10, limit=max_chunks // 2
            )
    
            # Chunks with stale scores (scored more than 30 days ago)
            # This would come from a Qdrant scroll with date filter on quality.scored_at
            stale_scored = []  # Simplified for clarity
    
            return list(set(high_negative + stale_scored))[:max_chunks]
    
        async def _rescore_batch(self, collection_name: str, chunk_ids: list[str]):
            # Fetch chunk text and embeddings from Qdrant
            # Re-run scorer
            # Upsert updated quality metadata
            # This is straightforward but verbose — structure mirrors upsert_chunk_with_quality
            pass
    

    Job 2: Chunk Retirement and Replacement

    Some chunks are beyond rescoring — they're genuinely bad and no amount of quality adjustment will fix them. The retirement job identifies these and either removes them or flags them for human review:

    async def retire_low_quality_chunks(
        qdrant,
        feedback_store,
        collection_name: str,
        quality_floor: float = 0.20,
        min_negative_signals: int = 20,
        dry_run: bool = True,
    ) -> list[str]:
        """
        Identifies chunks that meet ALL retirement criteria:
        - Composite quality score below quality_floor
        - More than min_negative_signals negative feedback events
        - Retrieved at least 10 times (enough observations to be confident)
        - Not the only chunk from its source document
          (we don't want to completely remove a document from the index)
        """
        candidates = await feedback_store.get_retirement_candidates(
            quality_floor=quality_floor,
            min_negative_signals=min_negative_signals,
            min_retrievals=10,
        )
    
        # Safety check: don't retire chunks if they're the last chunk
        # from their source document
        safe_to_retire = []
        for chunk_id in candidates:
            doc_id = await _get_document_id(qdrant, collection_name, chunk_id)
            sibling_count = await _count_document_chunks(qdrant, collection_name, doc_id)
            if sibling_count > 1:
                safe_to_retire.append(chunk_id)
    
        if not dry_run:
            qdrant.delete(
                collection_name=collection_name,
                points_selector=safe_to_retire,
            )
    
        return safe_to_retire
    

    Always run retirement jobs in dry_run mode first. Log what would be deleted, review a sample manually, then enable the actual deletion. You should also write retired chunk IDs to a separate audit table before deletion — you'll want to know what was removed and when.

    Job 3: Chunk Splitting for High-Performing Dense Chunks

    This is the most sophisticated optimization: if a chunk is consistently retrieved but the LLM only uses part of it, it might be too large. Conversely, if a chunk is frequently retrieved alongside a specific sibling chunk, they might belong together.

    class ChunkSplitSuggester:
        """
        Analyzes usage patterns to suggest chunking improvements.
        Does NOT automatically split — outputs suggestions for review.
        """
    
        async def analyze(
            self,
            chunk_id: str,
            chunk_text: str,
            usage_stats: dict,
            co_retrieval_patterns: dict,
        ) -> dict:
            """
            co_retrieval_patterns: {chunk_id: frequency} of chunks retrieved
            alongside this one.
            """
            suggestions = []
    
            # Long chunks that frequently co-retrieve with themselves
            # (different queries hitting same chunk) might benefit from splitting
            word_count = len(chunk_text.split())
            if word_count > 400 and usage_stats.get("query_diversity_score", 0) > 0.7:
                suggestions.append({
                    "type": "consider_split",
                    "reason": "High query diversity against long chunk suggests multiple topics",
                    "word_count": word_count,
                })
    
            # Chunks almost always retrieved together should be merged candidates
            top_co_retrieved = sorted(
                co_retrieval_patterns.items(), key=lambda x: x[1], reverse=True
            )
            if top_co_retrieved and top_co_retrieved[0][1] > 0.85:
                suggestions.append({
                    "type": "consider_merge",
                    "reason": f"Retrieved together with {top_co_retrieved[0][0]} in >85% of cases",
                    "merge_candidate": top_co_retrieved[0][0],
                })
    
            return {"chunk_id": chunk_id, "suggestions": suggestions}
    

    Phase 4: Index Optimization in Production Without Downtime

    All of the above is useless if running it takes your RAG system offline. Production index optimization requires a deployment strategy that treats the index as a live system, not a batch artifact.

    The Blue/Green Index Pattern

    Maintain two collections in your vector store simultaneously: the "live" index serving traffic and a "staging" index being optimized. Swap them atomically when staging is ready:

    class BlueGreenIndexManager:
        """
        Manages a blue/green deployment pattern for vector index updates.
        The active alias always points to whichever collection is currently live.
        """
    
        def __init__(self, qdrant, collection_prefix: str = "rag_knowledge"):
            self.qdrant = qdrant
            self.prefix = collection_prefix
    
        def get_active_collection(self) -> str:
            """Reads current active collection from a simple key-value store."""
            # In practice, use Redis, a config file, or your DB
            # Here we check which collection exists and is marked active
            pass
    
        async def prepare_staging(self, source_collection: str) -> str:
            """
            Creates a new staging collection, copies all vectors from source,
            then applies optimization passes.
            """
            staging_name = f"{self.prefix}_staging_{uuid.uuid4().hex[:8]}"
    
            # Qdrant supports collection cloning via snapshot API
            # For other vector DBs, you'd copy points in batches
            await self._clone_collection(source_collection, staging_name)
            return staging_name
    
        async def promote_staging(self, staging_name: str):
            """
            Atomically swaps staging to live. Old live collection
            is kept for a rollback window before deletion.
            """
            old_live = self.get_active_collection()
            new_archive_name = f"{self.prefix}_archive_{uuid.uuid4().hex[:8]}"
    
            # Rename old live to archive (keep for rollback)
            await self._rename_collection(old_live, new_archive_name)
    
            # Rename staging to live
            await self._rename_collection(staging_name, f"{self.prefix}_live")
    
            # Schedule archive deletion after rollback window (e.g., 24h)
            asyncio.create_task(
                self._delete_after_delay(new_archive_name, delay_hours=24)
            )
    
        async def rollback(self, archive_name: str):
            """Emergency rollback to previous live collection."""
            current_live = self.get_active_collection()
            await self._rename_collection(archive_name, f"{self.prefix}_live")
            await self._rename_collection(current_live, f"{self.prefix}_rolled_back")
    

    Continuous Score Propagation Without Full Reindex

    For frequent metadata updates (quality score refreshes, retrieval stat updates) that don't require rebuilding embeddings, use a separate PostgreSQL table as your source of truth and query it as a sidecar to your vector store:

    class HybridMetadataStore:
        """
        Keeps fast-changing metadata (retrieval stats, feedback scores) in
        PostgreSQL while storing stable data (embeddings, text) in Qdrant.
        At retrieval time, merges both sources.
        """
    
        async def get_enriched_chunks(
            self, chunk_ids: list[str]
        ) -> dict[str, dict]:
            """Fetches base chunks from Qdrant and enriches with live PG metadata."""
    
            qdrant_data = await self._fetch_from_qdrant(chunk_ids)
            pg_metadata = await self._fetch_live_metadata(chunk_ids)
    
            enriched = {}
            for cid in chunk_ids:
                base = qdrant_data.get(cid, {})
                live = pg_metadata.get(cid, {})
                enriched[cid] = {**base, **live}  # Live metadata wins on conflict
    
            return enriched
    
        async def _fetch_live_metadata(self, chunk_ids: list[str]) -> dict:
            # SELECT chunk_id, composite_score, times_retrieved, feedback_score
            # FROM chunk_live_metadata WHERE chunk_id = ANY($1)
            pass
    

    Hands-On Exercise

    Objective: Build and test a minimal feedback loop on a real document corpus.

    Setup

    You'll need:

    • A Python environment with qdrant-client, openai, asyncpg, numpy, pydantic
    • A local Qdrant instance (docker run -p 6333:6333 qdrant/qdrant)
    • A PostgreSQL instance for the feedback store
    • A set of real documents — use the Hugging Face documentation or any technical corpus you have on hand

    Step 1: Score Your Existing Chunks

    If you have an existing RAG index, extract a sample of 500 chunks, run them through the ChunkQualityScorer, and analyze the distribution. What percentage score below 0.45? What are the most common failure modes in your corpus?

    async def audit_existing_index(qdrant, collection_name, scorer, sample_size=500):
        # Scroll through collection, sample chunks, score them
        # Output: histogram of composite scores, examples of low scorers
        pass
    

    Step 2: Instrument Your Retrieval

    Wrap your existing retriever with InstrumentedRetriever. For 48 hours, log all retrieval events and LLM signals without making any changes to retrieval behavior. This is your baseline.

    At the end of 48 hours, query your feedback store:

    SELECT
        signal_type,
        COUNT(*) as event_count,
        AVG(signal_weight) as avg_weight
    FROM retrieval_feedback_events
    GROUP BY signal_type
    ORDER BY event_count DESC;
    

    Step 3: Enable Quality Filtering

    Enable the build_quality_filter(min_quality=0.45) in your retriever. Run for another 48 hours and compare:

    • Average LLM_CONFIDENT signal rate (before vs. after)
    • Average query reformulation rate (before vs. after)
    • P50 and P95 retrieval latency (filtering sometimes makes retrieval faster because fewer candidates need to be processed)

    Step 4: Deploy the Feedback Reranker

    Enable FeedbackAwareReranker after you have at least 500 feedback events. The Wilson score confidence mechanism will ensure chunks without enough observations get neutral scores and aren't artificially promoted or demoted.

    Measure the improvement in explicit positive feedback rate over the following week.


    Common Mistakes & Troubleshooting

    Mistake 1: Scoring all dimensions with equal weight regardless of corpus type. Technical documentation is information-dense and context-dependent — context sufficiency should get higher weight. FAQ-style content is naturally self-contained — answerability weight should dominate. Tune weights for your specific corpus.

    Mistake 2: Over-retiring chunks based on insufficient feedback. If you retire a chunk after only 5 negative signals, you're being statistically irresponsible. The Wilson confidence mechanism protects against this in reranking, but your retirement job needs its own minimum observation threshold. A chunk that's been retrieved 3 times and got 2 negative signals is not worth retiring — it hasn't had enough exposure.

    Mistake 3: Not differentiating between retrieval failure and generation failure. When a user gives explicit negative feedback, it might be because the retrieval was wrong (wrong chunks surfaced) or because the generation was wrong (right chunks, bad synthesis). If you attribute all negative signals to retrieval quality, you'll over-penalize chunks that were actually relevant but surrounded by a bad prompt. Use faithfulness scoring to separate the two.

    Mistake 4: Letting the feedback loop create a popularity bias. Chunks about frequently-asked topics accumulate more positive signals and get systematically upranked, while chunks about rare but important topics get fewer observations and stay at neutral scores. This creates a feedback loop that makes common queries excellent and rare queries progressively worse. Combat this by including an "underexplored" bonus in your reranking for chunks with high quality scores but low retrieval counts — they may be undiscovered gems.

    def underexplored_bonus(times_retrieved: int, quality_score: float) -> float:
        """Give a small boost to high-quality chunks that haven't been retrieved much."""
        if times_retrieved < 10 and quality_score > 0.65:
            return 0.05 * (1 - times_retrieved / 10)
        return 0.0
    

    Mistake 5: Running batch optimization jobs during peak traffic. Your index optimization jobs read and write heavily. Schedule them during your traffic nadir. If you have global users and no nadir exists, use the blue/green pattern to isolate optimization from live traffic completely.

    Mistake 6: Not versioning your scoring logic. When you update the ChunkQualityScorer with a new version, all historical scores become incomparable. Always include scoring_version in your stored scores and filter comparisons to chunks scored with the same version. Re-scoring is cheap compared to the confusion of mixing score distributions.

    Mistake 7: Treating LLM self-reflection as ground truth. "I couldn't find enough information" from an LLM might mean the information genuinely isn't in your index, or it might mean your prompt is poorly calibrated, or it might be an artifact of the LLM being overly cautious. Weight these signals at 0.9 but don't treat them as absolute. Cross-reference with explicit feedback and reformulation rates.


    Architecture Considerations at Scale

    When your corpus grows past ~1M chunks, several things change:

    Redundancy scoring becomes expensive. Computing cosine similarity against a full embedding matrix during indexing doesn't scale. Switch to approximate nearest-neighbor approaches for redundancy detection — run a dedicated ANN query for each new chunk and flag candidates above 0.92 similarity. This is what your vector store is designed for.

    Feedback aggregation becomes a hot query. As feedback events accumulate (millions per month for active deployments), real-time aggregation becomes expensive. Move to a pre-aggregated model: run an hourly job that materializes chunk-level stats, and serve the reranker from that materialized view rather than computing fresh on every request. The 1-hour staleness is acceptable for feedback signals.

    Quality score drift monitoring. As your scoring version evolves, track the distribution of scores over time. Sudden shifts in mean quality score often indicate a corpus-level issue (bulk import of low-quality documents, embedding model version change) that needs human attention. Set up simple alerting: if mean composite quality drops more than 0.05 in 24 hours, alert the on-call engineer.


    Summary & Next Steps

    You've built the conceptual and practical foundation for a RAG system that improves from its own usage. The key architectural insight is that every retrieval event is a training signal, and the infrastructure to capture, process, and act on those signals is just as important as the initial indexing pipeline.

    Here's what you've built:

    • A multi-dimensional chunk quality scorer that evaluates semantic density, answerability, context sufficiency, and redundancy at index time
    • An instrumented retriever that captures both explicit and implicit feedback signals without blocking the user-facing response path
    • A feedback-aware reranker that fuses static quality scores with dynamic usage signals using statistically robust confidence weighting (Wilson score interval)
    • A blue/green index management strategy for running optimization jobs without downtime
    • Protection mechanisms against common failure modes: popularity bias, insufficient observation, version mixing, and bulk retirement errors

    Where to go from here:

    1. Faithfulness scoring: Integrate a dedicated faithfulness evaluator (Ragas, TruLens, or a custom LLM judge) to separate retrieval quality from generation quality in your feedback signals. This is the highest-leverage next step.

    2. Query clustering: You're already hashing query embeddings. The next step is clustering similar queries and analyzing which chunks serve which query clusters well. This enables query-type-specific optimization — your chunks for procedural questions ("how do I...") can be evaluated differently from conceptual questions ("what is...").

    3. Adaptive chunking: Close the loop fully by automatically re-chunking documents based on the split/merge suggestions from ChunkSplitSuggester, then running the full re-indexing pipeline in staging.

    4. Embedding model evaluation: The feedback infrastructure you've built is also a perfect evaluation harness for testing new embedding models. Run a new model in shadow mode — embed queries with both your current model and the candidate — and compare which produces better retrieval outcomes according to your feedback signals. No labeled dataset required.

    5. Multi-index routing: For large corpora covering multiple domains, consider routing queries to domain-specific sub-indexes rather than searching everything. Your query clustering data will reveal the natural domain boundaries.

    The difference between a RAG system that's frozen in time and one that compounds its own improvement is this feedback infrastructure. Build it early, instrument it thoroughly, and let the data tell you where to optimize.

    Learning Path: Building with LLMs

    Previous

    Implementing LLM Response Caching with Redis: Semantic Deduplication, TTL Strategies, and Cache Invalidation Patterns

    Related Articles

    AI & Machine Learning🔥 Expert

    Orchestrating AI Agents with Tool Use and Function Calling: A Practical Guide to Building LLM-Powered Automation That Interacts with External Systems

    25 min
    AI & Machine Learning⚡ Practitioner

    Implementing Late Chunking with Long-Context Embeddings to Preserve Document Structure in RAG

    22 min
    AI & Machine Learning⚡ Practitioner

    Implementing LLM Response Caching with Redis: Semantic Deduplication, TTL Strategies, and Cache Invalidation Patterns

    24 min

    On this page

    • Introduction
    • Prerequisites
    • Why Static RAG Fails at Scale
    • Phase 1: Automated Chunk Quality Scoring
    • Designing the Scoring Dimensions
    • Storing Quality Scores as Index Metadata
    • Phase 2: Instrumentation and Feedback Signal Capture
    • Defining Your Signal Taxonomy
    • Building the Instrumented Retriever
    • The Feedback Store Schema
    • Phase 3: The Retrieval Feedback Loop
    • Incremental Mode: Dynamic Reranking with Score Fusion
    • Batch Mode: Index Optimization Jobs
    • Phase 4: Index Optimization in Production Without Downtime
    • The Blue/Green Index Pattern
    • Continuous Score Propagation Without Full Reindex
    • Hands-On Exercise
    • Setup
    • Step 1: Score Your Existing Chunks
    • Step 2: Instrument Your Retrieval
    • Step 3: Enable Quality Filtering
    • Step 4: Deploy the Feedback Reranker
    • Common Mistakes & Troubleshooting
    • Architecture Considerations at Scale
    • Summary & Next Steps