Pedro Alonso

RAG Systems Deep Dive Part 4: Building the Web Interface

2 min read
RAG Systems Deep Dive Part 4: Building the Web Interface

In Part 3, we enhanced our RAG system with hybrid search, streaming, and caching. Now, let’s build a polished web interface to showcase these features using Next.js and modern React patterns.

1. Project Setup

If you’re starting fresh, create a new Next.js project:

Terminal window
npx create-next-app@latest rag-ui --typescript --tailwind --eslint
cd rag-ui
npm install @langchain/openai @langchain/community langchain lucide-react @radix-ui/react-progress

We’ll use the shadcn/ui component library for a polished look. Install it following their setup guide:

Terminal window
npx shadcn-ui@latest init

Install the components we’ll need:

Terminal window
npx shadcn-ui@latest add button card progress textarea alert

2. Building the Chat Interface

Let’s create a clean, responsive chat interface that supports:

  • Real-time streaming responses
  • Source attribution
  • Loading states
  • Error handling

Create app/components/chat.tsx:

'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Send, Loader2 } from 'lucide-react';
interface Message {
role: 'user' | 'assistant';
content: string;
sources?: Array<{
text: string;
score: number;
type: 'vector' | 'keyword';
}>;
}
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || isLoading) return;
const question = input.trim();
setInput('');
setError(null);
setIsLoading(true);
// Add user message immediately
setMessages(prev => [...prev, { role: 'user', content: question }]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
if (!response.ok) throw new Error('Failed to fetch response');
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
// Prepare for streaming
let assistantMessage = '';
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode and append the chunk
const chunk = new TextDecoder().decode(value);
assistantMessage += chunk;
// Update the last message
setMessages(prev => [
...prev.slice(0, -1),
{ role: 'assistant', content: assistantMessage }
]);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
}
return (
<div className="container max-w-4xl mx-auto p-4">
<Card className="mb-4">
<div className="h-[600px] overflow-y-auto p-4 space-y-4">
{messages.map((message, i) => (
<div
key={i}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`rounded-lg px-4 py-2 max-w-[80%] ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100'
}`}
>
{message.content}
{message.sources && (
<div className="mt-2 text-sm opacity-75">
{message.sources.map((source, i) => (
<div key={i} className="mt-1">
Source {i + 1}: {source.type} match (score: {source.score})
</div>
))}
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question..."
className="flex-1"
rows={1}
/>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</form>
</Card>
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}

3. API Implementation

Let’s create the API endpoint that connects our UI to the RAG system. Create app/api/chat/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { StreamingTextResponse, LangChainStream } from 'ai';
import { loadDocuments } from '@/lib/document-loader';
import { createEnhancedQueryEngine } from '@/lib/enhanced-query';
// Initialize the query engine
let queryEngine: any = null;
async function initializeEngine() {
if (queryEngine) return queryEngine;
console.log('Initializing query engine...');
const documents = await loadDocuments();
queryEngine = await createEnhancedQueryEngine(documents);
return queryEngine;
}
export async function POST(req: NextRequest) {
try {
const { question } = await req.json();
// Initialize engine if needed
const engine = await initializeEngine();
// Create a streaming response
const { stream, handlers } = LangChainStream();
// Process the query
engine.stream(
{ question },
{ callbacks: handlers }
);
// Return the stream
return new StreamingTextResponse(stream);
} catch (error) {
console.error('Chat API error:', error);
return NextResponse.json(
{ error: 'Failed to process query' },
{ status: 500 }
);
}
}

4. Page Integration

Update app/page.tsx to use our chat component:

import { Chat } from './components/chat';
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 py-12">
<div className="container max-w-4xl mx-auto px-4">
<h1 className="text-3xl font-bold text-center mb-8">
RAG-Powered Research Assistant
</h1>
<Chat />
</div>
</main>
);
}

5. Advanced Features

5.1 Source Attribution Component

Create a component to display document sources with relevance scores. Create app/components/source-card.tsx:

import { Card } from '@/components/ui/card';
interface SourceInfo {
content: string;
score: number;
method: 'vector' | 'keyword';
}
export function SourceCard({ sources }: { sources: SourceInfo[] }) {
return (
<Card className="mt-2 p-3 bg-gray-50">
<h3 className="text-sm font-medium mb-2">Sources:</h3>
<div className="space-y-2">
{sources.map((source, index) => (
<div key={index} className="text-sm">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs ${
source.method === 'vector'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}>
{source.method}
</span>
<span className="text-gray-500">
Score: {source.score.toFixed(2)}
</span>
</div>
<p className="mt-1 text-gray-700">{source.content}</p>
</div>
))}
</div>
</Card>
);
}

5.2 Error Boundary

Create app/components/error-boundary.tsx:

'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<Alert variant="destructive">
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>
{this.state.error?.message || 'An unknown error occurred'}
</AlertDescription>
</Alert>
);
}
return this.props.children;
}
}

6. Performance Optimizations

6.1 Response Caching

Implement a simple client-side cache for responses:

type CacheEntry = {
response: string;
timestamp: number;
};
class ResponseCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly ttl: number = 1000 * 60 * 5; // 5 minutes
set(question: string, response: string) {
this.cache.set(question, {
response,
timestamp: Date.now()
});
}
get(question: string): string | null {
const entry = this.cache.get(question);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.ttl) {
this.cache.delete(question);
return null;
}
return entry.response;
}
}
export const responseCache = new ResponseCache();

Add debouncing to prevent rapid-fire API calls:

import { useCallback } from 'react';
import debounce from 'lodash/debounce';
const debouncedSearch = debounce(async (question: string) => {
// Your search logic here
}, 300);
// In your component:
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
debouncedSearch(e.target.value);
}, []);

7. Testing

Create __tests__/chat.test.tsx for component testing:

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Chat } from '../app/components/chat';
describe('Chat Component', () => {
it('sends messages and displays responses', async () => {
render(<Chat />);
// Find input and submit
const input = screen.getByPlaceholderText('Ask a question...');
const submitButton = screen.getByRole('button');
// Type and submit a message
fireEvent.change(input, {
target: { value: 'What are the recent developments?' }
});
fireEvent.click(submitButton);
// Wait for response
await waitFor(() => {
expect(screen.getByText(/recent developments/i)).toBeInTheDocument();
});
});
it('handles errors gracefully', async () => {
// Mock failed API call
global.fetch = jest.fn().mockRejectedValue(new Error('API Error'));
render(<Chat />);
const input = screen.getByPlaceholderText('Ask a question...');
const submitButton = screen.getByRole('button');
fireEvent.change(input, { target: { value: 'Test question' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});

Conclusion

This implementation provides a robust, user-friendly interface for our RAG system with:

  • Real-time streaming responses
  • Clean error handling
  • Source attribution
  • Performance optimizations
  • Comprehensive testing

The complete example demonstrates how to build a production-ready RAG interface that’s both functional and user-friendly.

In the next part, we’ll explore advanced topics like:

  • Multi-document support
  • Advanced caching strategies
  • Performance monitoring
  • Deployment considerations