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:
- The states of a RAG answer, and what to show in each.
- How to display citations people can understand and check.
- How to design refusals and errors.
- How the frontend consumes a streamed answer.
- Accessibility rules for chat answers.
- 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
| State | What the user sees | Design notes |
|---|---|---|
| Idle | Input box, two or three example questions | Examples teach what the assistant can answer |
| Retrieving | A short skeleton, submit disabled | Prevents double submits; keep it under a second |
| Streaming | Words appear as they arrive; a Stop button | First words fast matter more than total time |
| Answered | Text, citation chips, feedback buttons | Chips appear after the done event |
| Refused | "I couldn't find that…" plus next steps | A designed state, not an error |
| Error | What went wrong, and Retry | Never 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
| Do | Don't |
|---|---|
| Show the document title and section | Show only "S1" |
| Let people open the exact passage and its date | Link to a 3,000-word page with no highlight |
| Show chips after the citation check | Show citations the checker rejected |
| Keep chips small and below the text | Interrupt 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:
- Send the question with
fetch(POST,accept: text/event-stream). - Read the body stream, split it into events, and update state per event type.
- Append
tokentext to the answer; keepsourcesin state; render chips ondone. - 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
doneevent. - 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.