RAG Interface Design

By the Syncfusion DevTutorials team · Last updated

Designing the interface of a RAG assistant: answer states, streaming, citation chips, refusal and error states, reading server-sent events in the frontend, accessibility and feedback that feeds evaluation.

Two RAG applications can use the same retrieval process, prompt, and language model but still provide very different user experiences.

One application may keep the user waiting, display a long block of text, and show source labels such as [S1] without explaining them. Another may begin displaying the answer quickly, present sources as clear links, and allow the user to open the exact passage supporting each claim.

A well-designed RAG application should also respond clearly when the available documents do not contain enough information. Instead of guessing, it can explain that no supported answer was found and suggest another action, such as changing the question, opening a related document, or contacting support.

Users cannot see the retrieval and generation pipeline working behind the interface. They judge the system by whether it is clear, responsive, trustworthy, and easy to use.

The main idea: a RAG answer passes through clear states: idle, retrieving, streaming, answered, refused and error. Design each one. Stream text as it arrives, show citations as readable, clickable chips only after they are checked, treat "I don't know" as a designed state with next steps, make everything accessible (live regions, keyboard, reduced motion), and collect feedback that flows back into evaluation. On the frontend, consume the backend's server-sent events and render each event type.

A Real-World Example

Brightgrid’s first support assistant displayed a loading symbol until the complete answer was ready. It then showed the answer with source labels such as [S1] and [S2], without explaining what they meant.

Users reported three main problems:

Answers appeared slowly. Source labels were difficult to understand. When information could not be found, the assistant did not suggest what to do next.

Brightgrid improved the interface without changing the retrieval system:

Streaming: The answer begins appearing as it is generated instead of waiting for the complete response. Clear source buttons: A label such as [S1] is displayed as a button with the source title. Selecting the button shows the supporting passage and its date. Helpful refusal messages: When the available documents do not contain an answer, the assistant explains this clearly and provides useful options, such as searching the help centre or contacting support. User feedback: Users can mark an answer as helpful or unhelpful. The application saves the related trace ID so the team can investigate the exact retrieval and generation steps. Important failed questions can then be added to the gold set. Accessibility: New answer text is announced by screen readers, source buttons can be used with a keyboard, and unnecessary animation is reduced for users who prefer less motion.

These changes make the assistant easier to understand, check, and use.

What You Will Learn

In this lesson, you will learn:

  1. The states of a RAG answer, and what to show in each.
  2. How to display citations people can understand and check.
  3. How to design refusals and errors.
  4. How the frontend consumes a streamed answer.
  5. Accessibility rules for chat answers.
  6. How to collect feedback that improves the system.

This chapter, Development Workflows, shows how RAG fits into real product work: UI/UX design and frontend in this lesson, the backend service in the next, and security and testing for an internal engineering assistant in the last. The RAG Lab has a new App view tab that turns your last answer into a real chat interface and shows the API stream behind it, and a second knowledge base, the Engineering handbook.

Answer States

Design Every State

Answer states as a small state machine

// The states a RAG answer can be in, and what the UI should show for each.
const states = {
  idle: "Input with example questions",
  retrieving: "Searching the help center… (skeleton, submit disabled)",
  streaming: "Text appears word by word; a Stop button is shown",
  answered: "Answer + citation chips + feedback buttons",
  refused: '"I couldn\'t find that" + next steps (search, contact)',
  error: "What went wrong + Retry",
};

function next(state, event) {
  const table = {
    idle: { submit: "retrieving" },
    retrieving: { sources: "streaming", fail: "error" },
    streaming: { done: "answered", doneRefused: "refused", fail: "error" },
    error: { retry: "retrieving" },
  };
  return table[state]?.[event] ?? state;
}

let s = "idle";
for (const e of ["submit", "sources", "done"]) {
  s = next(s, e);
  console.log(`${e.padEnd(8)} -> ${s.padEnd(11)} ${states[s]}`);
}

Expected output:

submit   -> retrieving  Searching the help center… (skeleton, submit disabled)
sources  -> streaming   Text appears word by word; a Stop button is shown
done     -> answered    Answer + citation chips + feedback buttons
StateWhat the user seesDesign notes
IdleInput box, two or three example questionsExamples teach what the assistant can answer
RetrievingA short skeleton, submit disabledPrevents double submits; keep it under a second
StreamingWords appear as they arrive; a Stop buttonFirst words fast matter more than total time
AnsweredText, citation chips, feedback buttonsChips appear after the done event
Refused"I couldn't find that…" plus next stepsA designed state, not an error
ErrorWhat went wrong, and RetryNever show a stack trace or a raw status code

See the answer as a real chat interface

Ask the question, then open App view. Press Replay the stream to watch the streaming state, then the answered state with citation chips.

Citations People Can Use

From Markers to Chips

Raw [S1] markers mean nothing to users. Turn them into readable chips that name the document and section, and let people open the exact passage:

Turn markers into chips

// Parse a streamed answer into text and citation chips for the UI.
const answer =
  "You can return any product within 30 days [S1]. Refunds take 5 business days [S2].";
const sources = [
  { id: "S1", title: "Returns and refunds policy", section: "How long you have" },
  { id: "S2", title: "Returns and refunds policy", section: "Refunds" },
];

const parts = answer.split(/(\[S\d+\])/).filter(Boolean);
const rendered = parts
  .map((p) => (/^\[S\d+\]$/.test(p) ? `(${p.slice(2, -1)})` : p))
  .join("");
const chips = [...new Set(answer.match(/S\d+/g))].map((id) => {
  const s = sources.find((x) => x.id === id);
  return s ? `${id.slice(1)} · ${s.title} > ${s.section}` : `${id}: NOT SHOWN (invalid)`;
});
console.log(rendered);
console.log("Chips:");
console.log(chips.map((c) => "  " + c).join("\n"));

Expected output:

You can return any product within 30 days (1). Refunds take 5 business days (2).
Chips:
  1 · Returns and refunds policy > How long you have
  2 · Returns and refunds policy > Refunds
DoDon't
Show the document title and sectionShow only "S1"
Let people open the exact passage and its dateLink to a 3,000-word page with no highlight
Show chips after the citation checkShow citations the checker rejected
Keep chips small and below the textInterrupt every sentence with long links

Citations are a trust feature. Show the updated date in the passage view: "updated 2026-03-01" tells users the answer is current, and helps them spot outdated sources you missed.

Refusals and Errors

"I Don't Know" Is a Feature

A RAG system that refuses correctly is doing its job (see Unanswerable Queries). The interface decides whether a refusal feels helpful or like a dead end:

  • Say it plainly: "I couldn't find that in the help center."
  • Offer next steps: search all articles, rephrase, contact a person.
  • Don't use red or an error icon: nothing broke.

Ask the question above, then open App view. The refusal appears as its own state, with next steps, in amber instead of red.

Errors

Errors are different: something really failed (the network, the model, the index). Say what happened in plain words and offer Retry. Log the trace id so support can find it.

Frontend Streaming

Server-Sent Events

Most RAG backends stream answers with server-sent events (SSE): a long HTTP response where each event has a type and a JSON payload. A typical stream sends sources first, then many token events, then done with the checked citations:

Read an SSE stream

// Consuming a server-sent events stream in the frontend (simulated).
const streamText = `event: sources
data: [{"id":"S1","title":"Returns policy"}]

event: token
data: {"text":"You can return "}

event: token
data: {"text":"within 30 days [S1]."}

event: done
data: {"citations":["S1"],"refused":false}

`;

let answer = "";
let sources = [];
let done = null;
for (const block of streamText.split("\n\n").filter((b) => b.trim())) {
  const event = block.match(/^event: (\w+)/m)[1];
  const data = JSON.parse(block.match(/^data: (.*)$/m)[1]);
  if (event === "sources") {
    sources = data;
  } else if (event === "token") {
    answer += data.text;
  } else if (event === "done") {
    done = data;
  }
  console.log(`${event.padEnd(7)} | answer so far: "${answer}"`);
}
console.log(
  `Show ${done.citations.length} chip(s) from ${sources.length} source(s) after done.`,
);

Expected output:

sources | answer so far: ""
token   | answer so far: "You can return "
token   | answer so far: "You can return within 30 days [S1]."
done    | answer so far: "You can return within 30 days [S1]."
Show 1 chip(s) from 1 source(s) after done.

In a React app, the pattern is:

  1. Send the question with fetch (POST, accept: text/event-stream).
  2. Read the body stream, split it into events, and update state per event type.
  3. Append token text to the answer; keep sources in state; render chips on done.
  4. Let the user Stop: abort the request with an AbortController.

Never render model output as raw HTML. A retrieved document could contain markup or a script, and the model could repeat it. Render the answer as text (or sanitized markdown), and build chips from your own source data, not from text the model wrote.

Accessibility

Check a chat component for accessibility

// Accessibility checks for a chat answer component.
const component = {
  liveRegion: "polite",
  citationsAreButtons: true,
  focusAfterAnswer: "answer heading",
  respectsReducedMotion: false,
  feedbackHasLabels: true,
  refusalUsesColorOnly: false,
};

const checks = [
  ["Answer in an aria-live polite region", component.liveRegion === "polite"],
  ["Citations are real buttons or links", component.citationsAreButtons],
  ["Focus moves sensibly after an answer", Boolean(component.focusAfterAnswer)],
  ["Typing animation respects reduced motion", component.respectsReducedMotion],
  ["Feedback buttons have text labels", component.feedbackHasLabels],
  ["Refusal is not shown by color alone", !component.refusalUsesColorOnly],
];
for (const [name, ok] of checks) {
  console.log(`${ok ? "pass" : "FAIL"}  ${name}`);
}

Expected output:

pass  Answer in an aria-live polite region
pass  Citations are real buttons or links
pass  Focus moves sensibly after an answer
FAIL  Typing animation respects reduced motion
pass  Feedback buttons have text labels
pass  Refusal is not shown by color alone
  • Put the answer in an aria-live="polite" region, so screen readers announce it without interrupting.
  • Make chips, Stop, Retry and feedback real buttons with clear labels.
  • Keep a visible focus order: question, answer, chips, feedback.
  • Respect prefers-reduced-motion: no blinking caret or typing effect for people who turn motion off.
  • Never show meaning by color alone: a refusal says so in words.

Feedback

Close the Loop

Feedback buttons are cheap to build and valuable: each vote is joined to the trace (see Production Operations) and every "No" is a candidate test case for the gold set (see RAG Evaluation).

Turn feedback into test cases

// Feedback events the UI sends back, joined to the trace for later evaluation.
const feedback = [
  { traceId: "t-101", vote: "up", question: "How long do refunds take?" },
  {
    traceId: "t-102",
    vote: "down",
    question: "Can I return a gift?",
    reason: "wrong policy",
  },
  {
    traceId: "t-103",
    vote: "down",
    question: "Do you ship to Canada?",
    reason: "should refuse",
  },
  { traceId: "t-104", vote: "up", question: "Reset my lamp" },
];

const down = feedback.filter((f) => f.vote === "down");
console.log(
  `Helpful rate: ${Math.round(((feedback.length - down.length) / feedback.length) * 100)}%`,
);
console.log("Add to the gold set:");
for (const f of down) {
  console.log(`  ${f.traceId} "${f.question}" (${f.reason})`);
}

Expected output:

Helpful rate: 50%
Add to the gold set:
  t-102 "Can I return a gift?" (wrong policy)
  t-103 "Do you ship to Canada?" (should refuse)

Give feedback in the App view

Common mistakes

  • One spinner until the end. Stream the answer; first words matter most.
  • Raw [S1] markers. Show titled chips that open the passage.
  • Citations before they are checked. Render chips after done.
  • Refusals that look like errors. Design them with next steps.
  • Rendering model output as HTML. Render text or sanitized markdown.
  • Ignoring accessibility. Live regions, buttons, focus, reduced motion.

Check Your Understanding

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • Design every state: idle, retrieving, streaming, answered, refused, error.
  • Stream answers; render citation chips with titles after the done event.
  • A refusal is a helpful state with next steps; an error offers Retry.
  • The frontend reads SSE events (sources, token, done) and never renders model output as raw HTML.
  • Build in accessibility: live region, buttons, focus, reduced motion, not color alone.
  • Feedback joined to traces feeds the gold set.

Next: RAG Service Backend, the API that powers this interface.