← Back to DevBytes

Tracking User Feedback (Thumbs Up/Down) for Model Improvement

Tracking User Feedback (Thumbs Up/Down) for Model Improvement

User feedback in the form of thumbs up and thumbs down ratings is one of the most powerful, lightweight signals you can collect to improve machine learning models, especially those powering chatbots, search engines, recommendation systems, and generative AI applications. Unlike complex rating scales or lengthy surveys, binary feedback is frictionless — users can express satisfaction with a single click. This tutorial walks you through what feedback tracking is, why it matters, how to implement it end to end, and the best practices that separate toy implementations from production-grade systems.

What Is User Feedback Tracking?

Feedback tracking is the process of capturing explicit user judgments about the quality, relevance, or helpfulness of a model's output. The most common pattern is a thumbs up / thumbs down widget displayed alongside a generated response. Each interaction is recorded with enough metadata to later analyze trends, identify failure modes, and feed the signal back into model training or evaluation pipelines.

A well-designed feedback system captures more than just the binary vote. It typically stores the full context of the interaction, including the input prompt, the model output, model version, timestamp, user identifier (or anonymous session ID), and any optional free-text comment the user provides. This contextual data is what transforms a simple vote into actionable training signal.

Why It Matters

Automated metrics like BLEU, ROUGE, or accuracy on held-out test sets are useful during development, but they rarely capture the nuances of real user satisfaction. A response can be grammatically perfect and factually correct yet still feel unhelpful, condescending, or poorly formatted to the person reading it. User feedback bridges that gap between offline evaluation and real-world performance.

Designing the Feedback Data Model

Before writing any code, you need a clear data model. The goal is to store each feedback event with enough context to be useful for analysis and training without violating user privacy. Below is a schema suitable for a relational database like PostgreSQL.

Database Schema

CREATE TABLE interactions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id      UUID NOT NULL,
    user_id         UUID,
    prompt          TEXT NOT NULL,
    response        TEXT NOT NULL,
    model_name      VARCHAR(64) NOT NULL,
    model_version   VARCHAR(32) NOT NULL,
    temperature     FLOAT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE feedback (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    interaction_id  UUID NOT NULL REFERENCES interactions(id),
    rating          SMALLINT NOT NULL CHECK (rating IN (1, -1)),
    comment         TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (interaction_id)
);

CREATE INDEX idx_feedback_rating ON feedback(rating);
CREATE INDEX idx_feedback_created ON feedback(created_at);
CREATE INDEX idx_interactions_model ON interactions(model_name, model_version);

Notice the separation between interactions and feedback. Not every interaction will receive feedback, and you want to track that denominator to compute satisfaction rates accurately. The UNIQUE constraint on interaction_id ensures a user can only vote once per response, preventing duplicate submissions from double-clicks or retries.

Building the Backend API

The backend needs two endpoints: one to log interactions when they occur, and one to accept feedback votes. Here is a complete FastAPI implementation in Python.

FastAPI Feedback Service

from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, Field
from uuid import UUID
import asyncpg
import os

app = FastAPI(title="Feedback Tracking API")

# ---------- Models ----------

class InteractionCreate(BaseModel):
    session_id: UUID
    user_id: UUID | None = None
    prompt: str
    response: str
    model_name: str
    model_version: str
    temperature: float | None = None

class FeedbackCreate(BaseModel):
    interaction_id: UUID
    rating: int = Field(..., ge=-1, le=1, description="1 for thumbs up, -1 for thumbs down")
    comment: str | None = None

# ---------- Database ----------

async def get_db():
    conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
    try:
        yield conn
    finally:
        await conn.close()

# ---------- Endpoints ----------

@app.post("/interactions")
async def log_interaction(
    payload: InteractionCreate,
    db: asyncpg.Connection = Depends(get_db)
):
    row = await db.fetchrow(
        """
        INSERT INTO interactions
            (session_id, user_id, prompt, response, model_name, model_version, temperature)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        RETURNING id
        """,
        payload.session_id, payload.user_id, payload.prompt,
        payload.response, payload.model_name,
        payload.model_version, payload.temperature
    )
    return {"interaction_id": str(row["id"])}

@app.post("/feedback")
async def submit_feedback(
    payload: FeedbackCreate,
    db: asyncpg.Connection = Depends(get_db)
):
    if payload.rating == 0:
        raise HTTPException(400, "Rating must be 1 or -1")

    try:
        await db.execute(
            """
            INSERT INTO feedback (interaction_id, rating, comment)
            VALUES ($1, $2, $3)
            ON CONFLICT (interaction_id)
            DO UPDATE SET rating = EXCLUDED.rating,
                          comment = EXCLUDED.comment,
                          created_at = now()
            """,
            payload.interaction_id, payload.rating, payload.comment
        )
    except asyncpg.ForeignKeyViolationError:
        raise HTTPException(404, "Interaction not found")

    return {"status": "recorded"}

The ON CONFLICT clause in the feedback endpoint handles the case where a user changes their vote. Instead of rejecting the second submission, it updates the existing record. This is important because users often click thumbs down, realize they want to add a comment, and then resubmit.

Building the Frontend Widget

The frontend widget should be unobtrusive, accessible, and debounced to prevent accidental double submissions. Below is a self-contained implementation using vanilla JavaScript that you can drop into any web application.

HTML and JavaScript Widget

<div class="feedback-widget" id="feedback-{{interactionId}}">
  <button
    class="feedback-btn"
    data-rating="1"
    aria-label="Thumbs up - helpful response"
    onclick="submitFeedback('{{interactionId}}', 1, this)"
  >
    &#128077;
  </button>
  <button
    class="feedback-btn"
    data-rating="-1"
    aria-label="Thumbs down - unhelpful response"
    onclick="submitFeedback('{{interactionId}}', -1, this)"
  >
    &#128078;
  </button>
  <textarea
    class="feedback-comment"
    id="comment-{{interactionId}}"
    placeholder="Optional: tell us what went wrong"
    rows="2"
    style="display:none;"
  ></textarea>
  <button
    class="feedback-submit"
    id="submit-{{interactionId}}"
    style="display:none;"
    onclick="submitWithComment('{{interactionId}}', this)"
  >
    Submit
  </button>
</div>

<script>
const pendingVotes = new Map();

async function submitFeedback(interactionId, rating, btn) {
  // Debounce: ignore rapid double clicks
  if (pendingVotes.has(interactionId)) return;
  pendingVotes.set(interactionId, true);

  // Highlight selected button
  const widget = document.getElementById('feedback-' + interactionId);
  widget.querySelectorAll('.feedback-btn').forEach(b => {
    b.classList.remove('selected');
  });
  btn.classList.add('selected');

  // Show comment box for negative feedback
  if (rating === -1) {
    document.getElementById('comment-' + interactionId).style.display = 'block';
    document.getElementById('submit-' + interactionId).style.display = 'block';
    pendingVotes.delete(interactionId);
    return;
  }

  await sendFeedback(interactionId, rating, null);
  pendingVotes.delete(interactionId);
}

async function submitWithComment(interactionId, btn) {
  const comment = document.getElementById('comment-' + interactionId).value.trim();
  await sendFeedback(interactionId, -1, comment || null);
}

async function sendFeedback(interactionId, rating, comment) {
  try {
    const res = await fetch('/feedback', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ interaction_id: interactionId, rating, comment })
    });
    if (!res.ok) throw new Error('Failed to submit feedback');
    console.log('Feedback recorded');
  } catch (err) {
    console.error(err);
    // Silently fail — never block the user experience
  }
}
</script>

A key UX decision here is showing the comment box only after a thumbs-down click. Most users who give positive feedback do not want to write a paragraph, and forcing them past a text field reduces completion rates. Negative feedback, however, benefits enormously from a short explanation of what went wrong.

Analyzing the Collected Feedback

Once you have feedback flowing in, you need queries to turn raw votes into insights. Here are several analytical queries that cover the most common questions product and ML teams ask.

Satisfaction Rate by Model Version

SELECT
    i.model_name,
    i.model_version,
    COUNT(f.id) AS total_votes,
    COUNT(f.id) FILTER (WHERE f.rating = 1) AS thumbs_up,
    COUNT(f.id) FILTER (WHERE f.rating = -1) AS thumbs_down,
    ROUND(
        100.0 * COUNT(f.id) FILTER (WHERE f.rating = 1)
        / NULLIF(COUNT(f.id), 0),
        1
    ) AS satisfaction_pct
FROM interactions i
JOIN feedback f ON f.interaction_id = i.id
WHERE f.created_at >= now() - INTERVAL '7 days'
GROUP BY i.model_name, i.model_version
ORDER BY satisfaction_pct DESC;

Feedback Rate (Engagement Metric)

SELECT
    ROUND(
        100.0 * COUNT(f.id) / NULLIF(COUNT(i.id), 0),
        2
    ) AS feedback_rate_pct
FROM interactions i
LEFT JOIN feedback f ON f.interaction_id = i.id
WHERE i.created_at >= now() - INTERVAL '7 days';

The feedback rate tells you what percentage of users actually bother to vote. A rate below 1% is common and acceptable for passive widgets. If it drops to near zero, your widget may be poorly placed or visually invisible.

Top Failure Clusters from Negative Comments

SELECT
    LEFT(i.prompt, 80) AS prompt_preview,
    COUNT(*) AS downvote_count,
    ARRAY_AGG(DISTINCT f.comment) FILTER (WHERE f.comment IS NOT NULL) AS sample_comments
FROM interactions i
JOIN feedback f ON f.interaction_id = i.id
WHERE f.rating = -1
  AND f.created_at >= now() - INTERVAL '30 days'
GROUP BY LEFT(i.prompt, 80)
HAVING COUNT(*) >= 3
ORDER BY downvote_count DESC
LIMIT 20;

This query groups negative feedback by the beginning of the prompt, helping you spot recurring topics or phrasings that consistently produce bad outputs. In practice, you may want to replace the naive LEFT() grouping with embedding-based clustering for more meaningful groupings.

Feeding Feedback Back Into Training

Collecting feedback is only half the job. The real value comes from using it to improve the model. There are two primary approaches: supervised fine-tuning on high-quality positive examples, and preference-based optimization using paired positive and negative responses.

Exporting Preference Pairs for DPO

import json
import asyncpg
import os

async def export_preference_pairs(output_path: str, min_pairs: int = 100):
    """
    Export (prompt, chosen, rejected) triples for Direct Preference Optimization.
    For each prompt that received both a thumbs-up and a thumbs-down response,
    we create a preference pair.
    """
    conn = await asyncpg.connect(os.getenv("DATABASE_URL"))

    rows = await conn.fetch(
        """
        WITH ranked AS (
            SELECT
                i.prompt,
                i.response,
                f.rating,
                ROW_NUMBER() OVER (
                    PARTITION BY i.prompt, f.rating
                    ORDER BY f.created_at DESC
                ) AS rn
            FROM interactions i
            JOIN feedback f ON f.interaction_id = i.id
            WHERE f.created_at >= now() - INTERVAL '90 days'
        )
        SELECT
            p.prompt,
            p.response AS chosen,
            n.response AS rejected
        FROM ranked p
        JOIN ranked n ON p.prompt = n.prompt
                       AND p.rating = 1
                       AND n.rating = -1
        WHERE p.rn = 1 AND n.rn = 1
        """
    )

    pairs = [dict(row) for row in rows]

    if len(pairs) < min_pairs:
        print(f"Only {len(pairs)} pairs found, need at least {min_pairs}")
        return

    with open(output_path, "w") as f:
        for pair in pairs:
            f.write(json.dumps(pair) + "\n")

    print(f"Exported {len(pairs)} preference pairs to {output_path}")
    await conn.close()

# Run the export
import asyncio
asyncio.run(export_preference_pairs("preference_pairs.jsonl"))

Each line in the output file is a JSON object with prompt, chosen, and rejected fields, ready to be consumed by a DPO training script. The query selects the most recent positive and negative response per prompt, ensuring freshness and avoiding stale preferences from older model versions.

Best Practices

Privacy and Data Minimization

Feedback data can contain sensitive information, especially the free-text comments and the original prompts. Always clearly communicate your data retention policy to users. Consider automatically redacting or hashing personally identifiable information before storage. If your application handles health, financial, or legal queries, consult your compliance team before logging prompt text.

Avoid Feedback Bias

Users who leave feedback are not representative of your entire user base. Dissatisfied users are generally more motivated to click thumbs down than satisfied users are to click thumbs up. This means raw satisfaction percentages will understate true satisfaction. Always look at both the satisfaction rate among voters and the overall feedback rate to understand the full picture.

Version Everything

Always store the model name and version alongside each interaction. Without this, you cannot attribute changes in feedback to specific model updates, and you cannot run meaningful A/B comparisons. If you use feature flags or prompt templates that change over time, log those identifiers too.

Handle Vote Changes Gracefully

Users sometimes click the wrong button or change their minds after reading the response more carefully. Allow vote updates rather than locking in the first choice. The ON CONFLICT upsert pattern shown earlier handles this cleanly at the database level.

Do Not Block the User Experience

Feedback submission should be fire-and-forget from the user's perspective. If the network request fails, log it silently and move on. Never show error dialogs or prevent the user from continuing their workflow because a feedback vote failed to record. You can retry in the background or accept the loss of a single data point.

Set Realistic Thresholds Before Acting

A single thumbs-down vote does not mean your model is broken. Establish minimum sample sizes before drawing conclusions. A good rule of thumb is to require at least 30 feedback events on a given prompt cluster before treating it as a statistically meaningful signal. For model-level comparisons, aim for hundreds or thousands of votes to achieve confidence intervals narrow enough to justify a rollout decision.

Combine With Implicit Signals

Explicit thumbs up and down votes are valuable but sparse. Combine them with implicit signals like response regeneration rate, copy-to-clipboard events, time spent reading, and follow-up question patterns. A response that users copy frequently but never explicitly upvote is still a strong positive signal.

Conclusion

Tracking user feedback through thumbs up and thumbs down widgets is a deceptively simple practice with outsized impact on model quality. By carefully designing your data model, building a frictionless frontend experience, analyzing the collected signal rigorously, and closing the loop by feeding preferences back into training, you create a continuous improvement cycle that no amount of offline benchmarking can replicate. The implementation details matter — debouncing double clicks, allowing vote changes, storing model versions, and respecting user privacy are what separate a production system from a weekend prototype. Start small with the basic schema and API from this tutorial, then layer in clustering, implicit signals, and automated retraining pipelines as your feedback volume grows. The most important step is simply to start collecting: every vote is a data point your model can learn from.

— Ad —

Google AdSense will appear here after approval

← Back to all articles