Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
AI & Machine Learning

Building a Feedback Collection and Human Preference Dataset Pipeline for Continuous LLM Application Improvement

Most LLM applications ship and stagnate. This lesson shows you how to instrument your app to capture user feedback, structure preference pairs for DPO and RLHF workflows, and build a complete pipeline that turns raw signals into training data — so your model actually gets better over time.

⚡ Practitioner22 min readAug 19, 2026Updated Aug 19, 2026
Building a Feedback Collection and Human Preference Dataset Pipeline for Continuous LLM Application Improvement
On this page
  • Prerequisites
  • Why Feedback Data Is Different from Regular Training Data
  • Designing Your Feedback Collection Architecture
  • Layer 1: Instrumenting Your LLM Application
  • Capturing Explicit Feedback
  • Capturing Implicit Signals
  • Layer 2: Building the Preference Pair Constructor
  • Layer 3: The Human Annotation Interface
  • The Annotation Queue
  • The Annotator Interface (Backend)
  • Layer 4: The Preference Dataset Store
  • Database Schema
  • Dataset Export for Fine-tuning
  • Closing the Loop: Using Your Data
  • Continuous Evaluation
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Building a Feedback Collection and Human Preference Dataset Pipeline for Continuous LLM Application Improvement

    Your LLM application shipped. Users are interacting with it daily. And somewhere in the gap between what the model produces and what users actually want, you're losing trust, quality, and retention — you just can't see it yet.

    Most teams treat LLM deployment as a finish line. In reality, it's the starting gun for a continuous improvement loop. The models that get better over time — the ones that feel increasingly "right" to users — are backed by systematic feedback collection, structured preference data, and disciplined pipelines for turning that signal into model or prompt improvements. Teams that skip this end up with stale prompts, drift they can't diagnose, and no ground truth to evaluate against.

    By the end of this lesson, you'll have built a complete feedback collection and human preference dataset pipeline: from capturing raw signals at the application layer, through structuring preference pairs, to storing and versioning the data in a format ready for fine-tuning or evaluation. You'll understand how to instrument your application, design a preference annotation interface, and maintain dataset quality over time.

    What you'll learn:

    • How to capture implicit and explicit user feedback signals at the application layer without disrupting UX
    • How to structure preference data as comparison pairs compatible with RLHF and DPO fine-tuning workflows
    • How to build a lightweight annotation interface for human labelers
    • How to store, version, and audit your preference dataset using a schema designed for real production use
    • How to close the loop by connecting collected data back to evaluation and prompt/model improvement cycles

    Prerequisites

    You should be comfortable with Python and have working knowledge of building LLM-powered applications (API calls, prompt templating, basic RAG). Familiarity with SQL or a document store is helpful. You don't need to have done RLHF or DPO training before — we'll set up the data pipeline that feeds those processes, not the training itself.


    Why Feedback Data Is Different from Regular Training Data

    Before we instrument anything, it's worth understanding why preference data is structurally different from standard labeled datasets — because that difference shapes every design decision downstream.

    A standard training dataset has inputs and outputs: a question and its correct answer, a document and its summary label. Human preference data doesn't assert that any single response is correct. Instead, it captures relative quality: given prompt P, response A is preferred over response B by a human rater. This pairwise structure is what makes the data useful for techniques like Reinforcement Learning from Human Feedback (RLHF) and Direct Preference Optimization (DPO).

    The data looks like this:

    {
      "prompt": "Explain the difference between precision and recall to a non-technical stakeholder.",
      "chosen": "Precision is about whether what we flagged was actually a problem. Recall is about whether we caught all the problems. High precision means fewer false alarms. High recall means fewer things slipping through.",
      "rejected": "Precision is TP/(TP+FP) and recall is TP/(TP+FN) where TP is true positives, FP is false positives, and FN is false negatives."
    }
    

    The "chosen" response isn't necessarily perfect. It's just better than "rejected" for this prompt in this context. That's a crucial distinction — your pipeline doesn't need to collect gold-standard outputs. It needs to collect honest comparative judgments.

    This also means your feedback pipeline has two separate concerns:

    1. Signal collection — getting raw feedback from users or annotators
    2. Preference structuring — turning that raw signal into valid comparison pairs

    Most teams conflate these and end up with data that's hard to use. We'll keep them separate.


    Designing Your Feedback Collection Architecture

    The architecture has three layers: the instrumentation layer inside your application, the ingestion layer that receives and stores raw signals, and the structuring layer that transforms raw signals into preference pairs.

    Here's the high-level flow:

    LLM Application
        │
        ├── Captures: completions, metadata, user context
        ├── Exposes: feedback UI (thumbs, ratings, edits)
        │
        ▼
    Feedback Ingestion API
        │
        ├── Validates and stores raw events
        │
        ▼
    Preference Dataset Builder
        │
        ├── Pairs candidates (same prompt, different responses)
        ├── Routes to annotation queue
        │
        ▼
    Preference Store (versioned)
        │
        └── Feeds: evaluations, fine-tuning, prompt analysis
    

    Let's build each layer in turn.


    Layer 1: Instrumenting Your LLM Application

    The first thing to instrument is your completion logging. Every response your application generates needs a persistent record that can be referenced later when feedback arrives.

    Here's a practical completion logger you can drop into an existing FastAPI or Flask application:

    import uuid
    import time
    import json
    from dataclasses import dataclass, asdict, field
    from typing import Optional
    import boto3  # or swap for your storage client
    import hashlib
    
    @dataclass
    class CompletionRecord:
        completion_id: str
        session_id: str
        user_id: Optional[str]
        prompt: str
        prompt_version: str          # track which prompt template was active
        model: str                   # e.g., "gpt-4o", "claude-3-5-sonnet"
        response: str
        latency_ms: int
        input_tokens: int
        output_tokens: int
        temperature: float
        application_context: dict    # feature flags, RAG sources used, etc.
        timestamp: float = field(default_factory=time.time)
        
        def prompt_hash(self) -> str:
            """Canonical hash of the prompt for grouping variants."""
            return hashlib.sha256(self.prompt.encode()).hexdigest()[:16]
    
    
    class CompletionLogger:
        def __init__(self, storage_backend):
            self.storage = storage_backend
        
        def log(self, record: CompletionRecord) -> str:
            record_dict = asdict(record)
            record_dict["prompt_hash"] = record.prompt_hash()
            self.storage.put(
                key=f"completions/{record.timestamp:.0f}/{record.completion_id}",
                value=json.dumps(record_dict)
            )
            return record.completion_id
        
        def get(self, completion_id: str) -> Optional[dict]:
            return self.storage.get(completion_id)
    
    
    def log_completion(
        prompt: str,
        response: str,
        model: str,
        session_id: str,
        prompt_version: str,
        latency_ms: int,
        token_counts: dict,
        temperature: float = 0.7,
        user_id: Optional[str] = None,
        context: dict = None
    ) -> str:
        record = CompletionRecord(
            completion_id=str(uuid.uuid4()),
            session_id=session_id,
            user_id=user_id,
            prompt=prompt,
            prompt_version=prompt_version,
            model=model,
            response=response,
            latency_ms=latency_ms,
            input_tokens=token_counts.get("input", 0),
            output_tokens=token_counts.get("output", 0),
            temperature=temperature,
            application_context=context or {}
        )
        return logger.log(record)
    

    The prompt_version field is non-negotiable. When you're doing analysis six weeks later, you need to know whether quality differences across completions were due to model changes, prompt changes, or just natural variance. Track it from day one.

    The application_context dictionary is where you capture anything situational: which RAG chunks were retrieved, which user persona is active, whether the user is on a free or paid tier. This becomes essential for slicing your preference data later.

    Capturing Explicit Feedback

    Now instrument your UI feedback mechanisms. The most common are thumbs up/down, star ratings, and free-text corrections. Here's the API endpoint and event schema:

    from fastapi import FastAPI, HTTPException
    from pydantic import BaseModel, validator
    from typing import Literal, Optional
    import time
    import uuid
    
    app = FastAPI()
    
    class FeedbackEvent(BaseModel):
        completion_id: str
        session_id: str
        user_id: Optional[str] = None
        feedback_type: Literal["thumbs", "rating", "edit", "flag"]
        
        # For thumbs feedback
        thumbs_value: Optional[Literal["up", "down"]] = None
        
        # For star rating (1-5)
        rating: Optional[int] = None
        
        # For edit feedback — user rewrote the response
        edited_response: Optional[str] = None
        edit_comment: Optional[str] = None
        
        # For flagging problematic content
        flag_reason: Optional[Literal["factually_wrong", "harmful", "off_topic", "too_verbose", "too_brief", "other"]] = None
        
        # Optional free text always
        comment: Optional[str] = None
        
        @validator("rating")
        def rating_in_range(cls, v):
            if v is not None and not (1 <= v <= 5):
                raise ValueError("Rating must be between 1 and 5")
            return v
    
    
    class FeedbackStore:
        """Simplified — swap for your actual database."""
        def __init__(self, db_connection):
            self.db = db_connection
        
        def insert(self, event: FeedbackEvent, feedback_id: str):
            self.db.execute("""
                INSERT INTO feedback_events 
                (feedback_id, completion_id, session_id, user_id, 
                 feedback_type, thumbs_value, rating, edited_response,
                 edit_comment, flag_reason, comment, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (
                feedback_id,
                event.completion_id,
                event.session_id,
                event.user_id,
                event.feedback_type,
                event.thumbs_value,
                event.rating,
                event.edited_response,
                event.edit_comment,
                event.flag_reason,
                event.comment,
                time.time()
            ))
    
    
    @app.post("/feedback")
    async def collect_feedback(event: FeedbackEvent):
        feedback_id = str(uuid.uuid4())
        
        # Verify the completion exists before accepting feedback
        completion = completion_logger.get(event.completion_id)
        if not completion:
            raise HTTPException(status_code=404, detail="Completion not found")
        
        feedback_store.insert(event, feedback_id)
        
        return {"feedback_id": feedback_id, "status": "recorded"}
    

    Capturing Implicit Signals

    Explicit thumbs-up signals are gold, but users don't always click them. Implicit signals — copy events, session abandonment, regeneration requests — are often more abundant and sometimes more honest.

    class ImplicitSignalEvent(BaseModel):
        completion_id: str
        session_id: str
        signal_type: Literal[
            "copy_to_clipboard",    # strong positive
            "regenerate_requested", # negative
            "continued_conversation", # positive — user built on the response
            "session_abandoned",    # negative if shortly after response
            "response_shared",      # strong positive
            "scroll_depth",         # how far into a long response user read
        ]
        signal_metadata: dict = {}  # e.g., {"scroll_percent": 0.3, "time_to_abandon_seconds": 4}
    
    
    @app.post("/implicit-signal")
    async def collect_implicit_signal(event: ImplicitSignalEvent):
        # Store with lower weight than explicit signals
        signal_id = str(uuid.uuid4())
        implicit_store.insert(event, signal_id, weight=0.3)
        return {"signal_id": signal_id}
    

    Weight your signals carefully. A user copying a response to clipboard is a genuinely strong positive signal. A session being abandoned is ambiguous — the user might have gotten what they needed. Assign weights in your schema now so you can tune them later without restructuring your data.


    Layer 2: Building the Preference Pair Constructor

    Raw feedback events are not preference pairs. To make the data usable for fine-tuning and evaluation, you need to construct pairwise comparisons. This is where most teams either don't think hard enough — and produce noisy, unusable pairs — or overthink it and never ship.

    The core idea: for any given prompt (or prompt hash), if you have multiple responses with different feedback signals, you can construct pairs where the higher-feedback response is "chosen" and the lower-feedback response is "rejected."

    from dataclasses import dataclass
    from typing import Optional
    import json
    
    @dataclass
    class PreferencePair:
        pair_id: str
        prompt: str
        prompt_hash: str
        prompt_version: str
        chosen: str
        chosen_completion_id: str
        chosen_feedback_signal: str      # what made us pick this as chosen
        chosen_signal_score: float
        rejected: str
        rejected_completion_id: str
        rejected_feedback_signal: str
        rejected_signal_score: float
        construction_method: str         # "implicit", "explicit", "annotator"
        quality_tier: str                # "high", "medium", "low"
        metadata: dict
        created_at: float
    
    
    class PreferencePairConstructor:
        
        # Explicit thumbs-up/down carries the most weight
        SIGNAL_WEIGHTS = {
            "thumbs_up": 1.0,
            "thumbs_down": -1.0,
            "rating_5": 0.9,
            "rating_4": 0.6,
            "rating_3": 0.1,
            "rating_2": -0.5,
            "rating_1": -0.9,
            "edit_submitted": -0.7,      # user had to fix it
            "copy_to_clipboard": 0.5,
            "regenerate_requested": -0.6,
            "continued_conversation": 0.4,
            "session_abandoned_early": -0.3,
        }
        
        def score_completion(self, completion_id: str) -> tuple[float, list[str]]:
            """
            Aggregate all feedback signals for a completion into a single score.
            Returns (score, list_of_signals_used).
            """
            events = self.feedback_store.get_events_for_completion(completion_id)
            implicit = self.implicit_store.get_signals_for_completion(completion_id)
            
            total_score = 0.0
            signals_used = []
            
            for event in events:
                if event.feedback_type == "thumbs":
                    key = f"thumbs_{event.thumbs_value}"
                elif event.feedback_type == "rating":
                    key = f"rating_{event.rating}"
                elif event.feedback_type == "edit":
                    key = "edit_submitted"
                else:
                    continue
                
                weight = self.SIGNAL_WEIGHTS.get(key, 0.0)
                total_score += weight
                signals_used.append(key)
            
            for signal in implicit:
                weight = self.SIGNAL_WEIGHTS.get(signal.signal_type, 0.0) * 0.3
                total_score += weight
                signals_used.append(f"implicit:{signal.signal_type}")
            
            return total_score, signals_used
        
        def construct_pairs_for_prompt(self, prompt_hash: str) -> list[PreferencePair]:
            """
            Given a prompt hash, find all completions for that prompt,
            score them, and construct valid preference pairs.
            """
            completions = self.completion_store.get_by_prompt_hash(prompt_hash)
            
            if len(completions) < 2:
                return []  # Need at least two responses to compare
            
            scored = []
            for completion in completions:
                score, signals = self.score_completion(completion["completion_id"])
                scored.append((score, signals, completion))
            
            # Sort descending by score
            scored.sort(key=lambda x: x[0], reverse=True)
            
            pairs = []
            # Pair highest with lowest, second-highest with second-lowest, etc.
            # Only create pairs where there's a meaningful score gap
            top = scored[:len(scored)//2]
            bottom = scored[len(scored)//2:]
            
            for (high_score, high_signals, high_comp), (low_score, low_signals, low_comp) in zip(top, reversed(bottom)):
                
                score_gap = high_score - low_score
                
                if score_gap < 0.5:
                    # Gap too small — this pair would introduce noise
                    quality_tier = "low"
                elif score_gap < 1.2:
                    quality_tier = "medium"
                else:
                    quality_tier = "high"
                
                pair = PreferencePair(
                    pair_id=str(uuid.uuid4()),
                    prompt=high_comp["prompt"],
                    prompt_hash=prompt_hash,
                    prompt_version=high_comp["prompt_version"],
                    chosen=high_comp["response"],
                    chosen_completion_id=high_comp["completion_id"],
                    chosen_feedback_signal=", ".join(high_signals),
                    chosen_signal_score=high_score,
                    rejected=low_comp["response"],
                    rejected_completion_id=low_comp["completion_id"],
                    rejected_feedback_signal=", ".join(low_signals),
                    rejected_signal_score=low_score,
                    construction_method="implicit" if all("implicit" in s for s in high_signals) else "explicit",
                    quality_tier=quality_tier,
                    metadata={
                        "model_chosen": high_comp["model"],
                        "model_rejected": low_comp["model"],
                        "prompt_version": high_comp["prompt_version"],
                    },
                    created_at=time.time()
                )
                pairs.append(pair)
            
            return pairs
    

    Don't pair responses across different prompt versions. If your prompt template changed between two completions, any quality difference could be due to the prompt, not the model behavior. Your prompt_version field prevents this contamination — filter strictly.


    Layer 3: The Human Annotation Interface

    Implicit signals are abundant but noisy. For your highest-stakes preference pairs — cases where you'll use data for fine-tuning — you want human judgment. Let's build a lightweight annotation queue and interface.

    The Annotation Queue

    from enum import Enum
    
    class AnnotationStatus(str, Enum):
        PENDING = "pending"
        IN_PROGRESS = "in_progress"
        COMPLETE = "complete"
        DISPUTED = "disputed"
        SKIPPED = "skipped"
    
    
    class AnnotationQueue:
        """
        Manages assignment of preference pairs to human annotators.
        Supports redundant annotation (same pair to multiple annotators)
        for high-quality pairs.
        """
        
        def __init__(self, db, target_annotations_per_pair: int = 2):
            self.db = db
            self.target_n = target_annotations_per_pair
        
        def enqueue(self, pair: PreferencePair, priority: str = "normal"):
            self.db.execute("""
                INSERT INTO annotation_queue
                (pair_id, priority, annotations_needed, annotations_received, status, created_at)
                VALUES (?, ?, ?, 0, 'pending', ?)
            """, (pair.pair_id, priority, self.target_n, time.time()))
        
        def get_next_for_annotator(self, annotator_id: str) -> Optional[dict]:
            """
            Fetch the next pair for an annotator, skipping pairs they've already annotated.
            Prioritize 'high' priority items and items closest to completion.
            """
            result = self.db.execute("""
                SELECT q.pair_id, p.*
                FROM annotation_queue q
                JOIN preference_pairs p ON q.pair_id = p.pair_id
                WHERE q.status = 'pending'
                  AND q.pair_id NOT IN (
                      SELECT pair_id FROM annotations WHERE annotator_id = ?
                  )
                ORDER BY 
                    CASE q.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
                    q.annotations_received DESC
                LIMIT 1
            """, (annotator_id,)).fetchone()
            
            return dict(result) if result else None
        
        def submit_annotation(
            self, 
            pair_id: str, 
            annotator_id: str, 
            chosen_id: str,  # annotator picks which completion_id is better
            confidence: int,  # 1-3 scale: 1=uncertain, 2=confident, 3=very confident
            reasoning: Optional[str] = None,
            quality_issues: Optional[list] = None
        ):
            annotation_id = str(uuid.uuid4())
            
            self.db.execute("""
                INSERT INTO annotations
                (annotation_id, pair_id, annotator_id, chosen_completion_id,
                 confidence, reasoning, quality_issues, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """, (
                annotation_id, pair_id, annotator_id, chosen_id,
                confidence, reasoning, json.dumps(quality_issues or []), time.time()
            ))
            
            # Update queue progress
            self.db.execute("""
                UPDATE annotation_queue 
                SET annotations_received = annotations_received + 1,
                    status = CASE 
                        WHEN annotations_received + 1 >= annotations_needed THEN 'complete'
                        ELSE status
                    END
                WHERE pair_id = ?
            """, (pair_id,))
            
            # Check for inter-annotator agreement
            self._check_agreement(pair_id)
        
        def _check_agreement(self, pair_id: str):
            """Flag pairs where annotators disagree for review."""
            annotations = self.db.execute("""
                SELECT chosen_completion_id, COUNT(*) as cnt
                FROM annotations WHERE pair_id = ?
                GROUP BY chosen_completion_id
            """, (pair_id,)).fetchall()
            
            if len(annotations) > 1:
                # Disagreement — mark as disputed
                self.db.execute("""
                    UPDATE annotation_queue SET status = 'disputed' WHERE pair_id = ?
                """, (pair_id,))
    

    The Annotator Interface (Backend)

    For the annotation UI itself, you can use Streamlit for internal tooling without building a full frontend. Here's the backend logic that feeds it:

    import streamlit as st
    
    def render_annotation_interface(annotator_id: str, queue: AnnotationQueue):
        st.title("Response Quality Annotation")
        
        pair_data = queue.get_next_for_annotator(annotator_id)
        
        if not pair_data:
            st.success("Queue empty — great work!")
            return
        
        st.markdown("### Prompt")
        st.text_area("", value=pair_data["prompt"], height=150, disabled=True, key="prompt_display")
        
        st.markdown("---")
        st.markdown("**Which response is better for this prompt?**")
        
        col1, col2 = st.columns(2)
        
        # Randomize which response appears left/right to avoid position bias
        import random
        responses = [
            ("A", pair_data["chosen_completion_id"], pair_data["chosen"]),
            ("B", pair_data["rejected_completion_id"], pair_data["rejected"])
        ]
        random.shuffle(responses)
        
        with col1:
            st.markdown(f"**Response {responses[0][0]}**")
            st.text_area("", value=responses[0][2], height=300, disabled=True, key="resp_left")
        
        with col2:
            st.markdown(f"**Response {responses[1][0]}**")
            st.text_area("", value=responses[1][2], height=300, disabled=True, key="resp_right")
        
        chosen_label = st.radio(
            "Better response:",
            options=[responses[0][0], responses[1][0], "Neither / Skip"],
            horizontal=True
        )
        
        confidence = st.slider("How confident are you?", 1, 3, 2,
                               help="1=uncertain, 2=confident, 3=very confident")
        
        reasoning = st.text_area("Optional: Why did you choose this response?", height=80)
        
        quality_issues = st.multiselect(
            "Any quality issues with either response?",
            ["Factual errors", "Hallucinated content", "Inappropriate tone",
             "Too verbose", "Incomplete", "Harmful content"]
        )
        
        if st.button("Submit", type="primary"):
            if chosen_label == "Neither / Skip":
                queue.skip(pair_data["pair_id"], annotator_id)
                st.rerun()
            else:
                idx = 0 if chosen_label == responses[0][0] else 1
                chosen_completion_id = responses[idx][1]
                
                queue.submit_annotation(
                    pair_id=pair_data["pair_id"],
                    annotator_id=annotator_id,
                    chosen_id=chosen_completion_id,
                    confidence=confidence,
                    reasoning=reasoning or None,
                    quality_issues=quality_issues
                )
                st.rerun()
    

    Always randomize left/right presentation. Studies on human annotation consistently show position bias — annotators slightly favor responses presented first or on the left. Randomize per-session and record which position each response appeared in so you can check for this in your data quality analysis.


    Layer 4: The Preference Dataset Store

    Your final dataset needs to be versioned, filterable, and exportable in the formats that fine-tuning frameworks expect. Here's a schema and export layer that handles both.

    Database Schema

    -- Core preference pairs table
    CREATE TABLE preference_pairs (
        pair_id TEXT PRIMARY KEY,
        prompt TEXT NOT NULL,
        prompt_hash TEXT NOT NULL,
        prompt_version TEXT NOT NULL,
        chosen TEXT NOT NULL,
        chosen_completion_id TEXT NOT NULL,
        chosen_signal_score REAL,
        rejected TEXT NOT NULL,
        rejected_completion_id TEXT NOT NULL,
        rejected_signal_score REAL,
        construction_method TEXT NOT NULL,  -- 'implicit', 'explicit', 'annotator'
        quality_tier TEXT NOT NULL,         -- 'high', 'medium', 'low'
        dataset_version TEXT,               -- assigned when exported
        is_validated BOOLEAN DEFAULT FALSE,
        is_excluded BOOLEAN DEFAULT FALSE,
        exclusion_reason TEXT,
        metadata JSON,
        created_at REAL NOT NULL
    );
    
    -- Human annotation results (post-reconciliation)
    CREATE TABLE validated_preferences (
        validation_id TEXT PRIMARY KEY,
        pair_id TEXT REFERENCES preference_pairs(pair_id),
        final_chosen_completion_id TEXT NOT NULL,
        annotator_agreement_rate REAL,      -- e.g., 1.0 if all agreed
        total_annotations INT,
        avg_confidence REAL,
        validation_method TEXT,             -- 'consensus', 'expert_review', 'auto'
        created_at REAL NOT NULL
    );
    
    -- Dataset versions for reproducibility
    CREATE TABLE dataset_versions (
        version_id TEXT PRIMARY KEY,
        version_tag TEXT UNIQUE NOT NULL,   -- e.g., 'v1.2.0-customer-support'
        pair_count INT,
        quality_filter TEXT,                -- JSON: filters applied
        created_at REAL NOT NULL,
        notes TEXT
    );
    
    CREATE INDEX idx_pairs_prompt_hash ON preference_pairs(prompt_hash);
    CREATE INDEX idx_pairs_quality ON preference_pairs(quality_tier, is_excluded);
    CREATE INDEX idx_pairs_version ON preference_pairs(dataset_version);
    

    Dataset Export for Fine-tuning

    Different fine-tuning frameworks want different formats. Here's an exporter that handles the most common ones:

    import json
    from pathlib import Path
    from datasets import Dataset  # HuggingFace datasets
    import pandas as pd
    
    class PreferenceDatasetExporter:
        
        def __init__(self, db):
            self.db = db
        
        def query_pairs(
            self,
            quality_tiers: list = ["high", "medium"],
            construction_methods: list = ["annotator", "explicit"],
            prompt_versions: list = None,
            min_signal_gap: float = 0.5,
            exclude_disputed: bool = True,
            limit: int = None
        ) -> list[dict]:
            
            query = """
                SELECT p.*, v.final_chosen_completion_id, v.annotator_agreement_rate
                FROM preference_pairs p
                LEFT JOIN validated_preferences v ON p.pair_id = v.pair_id
                WHERE p.is_excluded = FALSE
                  AND p.quality_tier IN ({quality_placeholders})
                  AND p.construction_method IN ({method_placeholders})
                  AND (p.chosen_signal_score - p.rejected_signal_score) >= ?
            """.format(
                quality_placeholders=",".join("?" * len(quality_tiers)),
                method_placeholders=",".join("?" * len(construction_methods))
            )
            
            params = quality_tiers + construction_methods + [min_signal_gap]
            
            if prompt_versions:
                query += f" AND p.prompt_version IN ({','.join('?' * len(prompt_versions))})"
                params += prompt_versions
            
            if exclude_disputed:
                query += """
                    AND p.pair_id NOT IN (
                        SELECT pair_id FROM annotation_queue WHERE status = 'disputed'
                    )
                """
            
            if limit:
                query += f" LIMIT {limit}"
            
            return [dict(row) for row in self.db.execute(query, params).fetchall()]
        
        def to_dpo_format(self, pairs: list[dict]) -> list[dict]:
            """
            Export in TRL/DPO format compatible with HuggingFace TRL library.
            """
            records = []
            for pair in pairs:
                # If human-validated, use the human's choice; otherwise use signal-based
                if pair.get("final_chosen_completion_id"):
                    if pair["final_chosen_completion_id"] == pair["chosen_completion_id"]:
                        chosen, rejected = pair["chosen"], pair["rejected"]
                    else:
                        chosen, rejected = pair["rejected"], pair["chosen"]
                else:
                    chosen, rejected = pair["chosen"], pair["rejected"]
                
                records.append({
                    "prompt": pair["prompt"],
                    "chosen": chosen,
                    "rejected": rejected,
                })
            return records
        
        def to_openai_messages_format(self, pairs: list[dict]) -> list[dict]:
            """
            Export in OpenAI fine-tuning messages format.
            Only uses 'chosen' — treats preference data as SFT data.
            Useful when you want to fine-tune on preferred responses only.
            """
            records = []
            for pair in pairs:
                chosen = pair["chosen"]
                if pair.get("final_chosen_completion_id") and \
                   pair["final_chosen_completion_id"] == pair["rejected_completion_id"]:
                    chosen = pair["rejected"]
                
                records.append({
                    "messages": [
                        {"role": "user", "content": pair["prompt"]},
                        {"role": "assistant", "content": chosen}
                    ]
                })
            return records
        
        def export(
            self,
            output_path: str,
            format: str = "dpo",
            version_tag: str = None,
            **query_kwargs
        ) -> str:
            pairs = self.query_pairs(**query_kwargs)
            
            if format == "dpo":
                records = self.to_dpo_format(pairs)
            elif format == "openai_sft":
                records = self.to_openai_messages_format(pairs)
            else:
                raise ValueError(f"Unknown format: {format}")
            
            path = Path(output_path)
            path.mkdir(parents=True, exist_ok=True)
            
            # Always write JSONL
            output_file = path / f"preferences_{version_tag or 'latest'}.jsonl"
            with open(output_file, "w") as f:
                for record in records:
                    f.write(json.dumps(record) + "\n")
            
            # Write version manifest
            manifest = {
                "version": version_tag,
                "format": format,
                "pair_count": len(records),
                "query_params": query_kwargs,
                "created_at": time.time()
            }
            with open(path / "manifest.json", "w") as f:
                json.dump(manifest, f, indent=2)
            
            print(f"Exported {len(records)} pairs to {output_file}")
            return str(output_file)
    
    
    # Usage example — export high-quality DPO pairs for the v2 prompt
    exporter = PreferenceDatasetExporter(db)
    exporter.export(
        output_path="./datasets/customer_support/",
        format="dpo",
        version_tag="v1.0.0",
        quality_tiers=["high"],
        construction_methods=["annotator"],
        min_signal_gap=1.0
    )
    

    Closing the Loop: Using Your Data

    Collecting data without acting on it is expensive bookkeeping. Here's how to actually use what you've built.

    Continuous Evaluation

    Your preference dataset doubles as an evaluation benchmark. Once you have 200+ validated pairs, you can run your current model against them automatically:

    def evaluate_against_preferences(
        model_client,
        preference_pairs: list[dict],
        judge_model: str = "gpt-4o"
    ) -> dict:
        """
        Use an LLM judge to assess whether the current model produces
        responses that would be preferred over the 'rejected' baseline.
        """
        wins, losses, ties = 0, 0, 0
        
        for pair in preference_pairs:
            # Generate a fresh response with the current model
            current_response = model_client.complete(pair["prompt"])
            
            # Ask judge to compare current response vs the 'rejected' baseline
            judge_prompt = f"""
    You are evaluating two responses to the same prompt.
    
    Prompt: {pair["prompt"]}
    
    Response A: {current_response}
    Response B: {pair["rejected"]}
    
    Which response is better? Answer with exactly one of: A, B, or TIE.
    Reason briefly.
    """
            judgment = model_client.complete(judge_prompt, model=judge_model)
            
            if "Response A" in judgment or judgment.strip().startswith("A"):
                wins += 1
            elif "Response B" in judgment or judgment.strip().startswith("B"):
                losses += 1
            else:
                ties += 1
        
        total = len(preference_pairs)
        return {
            "win_rate": wins / total,
            "loss_rate": losses / total,
            "tie_rate": ties / total,
            "total_evaluated": total
        }
    

    This gives you a win rate against your historical "rejected" baseline — a number you can track over time as you improve your prompts or fine-tune your model.

    Track your win rate against a frozen baseline. If you keep updating what "rejected" means, you lose the ability to compare across time. Pick a representative sample of your worst historical responses and freeze it as a standing benchmark.


    Hands-On Exercise

    Build a minimal version of this pipeline end-to-end using a local SQLite database and a fictional customer support application.

    Scenario: You're building a customer support assistant for a SaaS product. Users ask questions like "How do I export my data to CSV?" and your LLM generates answers. Some users give thumbs up/down.

    Tasks:

    1. Create the SQLite schema for completion_records, feedback_events, and preference_pairs.

    2. Write a script that seeds the database with 20 synthetic completions across 5 distinct prompts. Vary the responses in quality (some are verbose and accurate, some are brief and accurate, some are inaccurate). Assign plausible feedback events (thumbs up/down) to each completion.

    3. Use the PreferencePairConstructor to generate pairs for each prompt hash. Print each pair with its quality tier.

    4. Export the resulting pairs in DPO format to a .jsonl file. Open it and verify it looks correct.

    5. Inspect your output: which prompt produced the clearest preference pairs? Which ones were too close to call (low quality tier)? What would you need to do to resolve those ambiguous ones?

    Stretch goal: Modify the annotation queue to detect when the same user submits feedback twice on the same completion (a sign of accidental duplicate clicks) and deduplicate automatically.


    Common Mistakes & Troubleshooting

    Pairing responses from different prompt versions. If your prompt template changes between two completions, any preference signal you extract is confounded. You can't tell if users preferred response B because the model was better or because the new prompt was clearer. Always filter pairs to share the same prompt_version.

    Treating all thumbs-down equally. A user who thumbs-down a response immediately after reading it is different from one who scrolls to the bottom and then thumbs-down. Capture response interaction timing when you can, and use it to weight the signal.

    Not randomizing annotator response order. If response A always appears on the left, your annotators will develop a systematic preference for "left" responses. This poisons your dataset silently. Randomize and record the presentation order in your annotation schema.

    Building pairs with too little signal gap. If completion A has a score of 0.2 and completion B has a score of 0.0, the gap is so small that noise dominates. Use a min_signal_gap threshold — start conservatively at 0.8 and relax it only when you need more data volume.

    Not versioning your dataset. Fine-tuning is expensive. If you can't reproduce the exact dataset that produced a given model checkpoint, you can't debug regressions. Write your manifest file every time you export. Store it with the model artifact.

    Letting disputed pairs silently enter your training data. Inter-annotator disagreement is a signal that the pair is genuinely ambiguous. Ambiguous pairs add noise to fine-tuning. Route disputed pairs to a senior reviewer or exclude them — don't let them slip in automatically.

    Collecting feedback only from power users. If you have a feedback button that requires clicking through to a response detail page, only your most engaged users will use it. This biases your dataset toward expert-level preferences. Instrument in-line feedback at the response level, visible to all users, to capture a more representative distribution.


    Summary & Next Steps

    You've built a complete preference data pipeline: instrumented completion logging, multi-signal feedback collection, preference pair construction with quality scoring, a human annotation queue with inter-annotator agreement checking, versioned dataset storage, and export to DPO-compatible format.

    The pipeline you built follows a deliberate architecture — each layer has a single responsibility, and data moves from raw signal to structured preference pair through explicit transformation steps. This makes it auditable, debuggable, and extensible as your needs grow.

    Here's where to go next:

    Improve your reward modeling. Your score_completion function is a simple weighted sum. For a production system, you can train a small reward model on your validated preference pairs that scores completions more accurately than a hand-crafted formula.

    Set up scheduled exports and drift alerts. Once your pipeline is running, schedule weekly exports and track your preference data statistics over time: number of new pairs per week, ratio of explicit to implicit signal, annotation agreement rate. Sharp drops in any of these usually indicate a bug or a product change you need to investigate.

    Connect to your prompt engineering workflow. When you analyze which prompts generate the most "rejected" completions, you've identified your highest-priority prompt improvement targets. Build a dashboard that surfaces this — it becomes the agenda for your weekly prompt review.

    Explore DPO fine-tuning. With a high-quality JSONL dataset in hand, the HuggingFace TRL library makes it straightforward to run a DPO fine-tuning job on an open-source base model. Your next step is learning how to set up that training pipeline and evaluate the resulting checkpoint against your frozen evaluation benchmark.

    The feedback loop is built. Now feed it.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Building with LLMs

    Previous

    Chunking Strategies for RAG: How to Split Documents by Size, Sentence, and Semantic Meaning

    Related Insights

    AI & Machine LearningPractitioner

    Grounding AI Responses with Business Context: Role Prompting, Domain Framing, and Contextual Priming for Data Teams

    25 min
    AI & Machine LearningFoundation

    Indexing Strategies for RAG: How to Build and Update Your Vector Store as Documents Change

    17 min
    AI & Machine LearningFoundation

    Chunking Strategies for RAG: How to Split Documents by Size, Sentence, and Semantic Meaning

    18 min

    On this page

    • Prerequisites
    • Why Feedback Data Is Different from Regular Training Data
    • Designing Your Feedback Collection Architecture
    • Layer 1: Instrumenting Your LLM Application
    • Capturing Explicit Feedback
    • Capturing Implicit Signals
    • Layer 2: Building the Preference Pair Constructor
    • Layer 3: The Human Annotation Interface
    • The Annotation Queue
    • The Annotator Interface (Backend)
    • Layer 4: The Preference Dataset Store
    • Database Schema
    • Dataset Export for Fine-tuning
    • Closing the Loop: Using Your Data
    • Continuous Evaluation
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps