RAG Systems Deep Dive Part 4: Building the Web Interface
RAG Systems Deep Dive Series
Part 4 of 4- 1 RAG Systems Deep Dive Part 1: Core Concepts and Architecture
- 2 RAG Systems Deep Dive Part 2: Practical Implementation with LangChain
- 3 RAG Systems Deep Dive Part 3: Advanced Features and Performance Optimization
- 4 RAG Systems Deep Dive Part 4: Building the Web Interface You are here
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:
npx create-next-app@latest rag-ui --typescript --tailwind --eslintcd rag-uinpm install @langchain/openai @langchain/community langchain lucide-react @radix-ui/react-progressWe’ll use the shadcn/ui component library for a polished look. Install it following their setup guide:
npx shadcn-ui@latest initInstall the components we’ll need:
npx shadcn-ui@latest add button card progress textarea alert2. 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 enginelet 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();6.2 Debounced Search
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
Related Articles
RAG Systems Deep Dive Part 3: Advanced Features and Performance Optimization
Learn about RAG Systems Deep Dive Part 3: Advanced Features and Performance Optimization
RAG Systems Deep Dive Part 1: Core Concepts and Architecture
Learn about RAG Systems Deep Dive Part 1: Core Concepts and Architecture
RAG Systems Deep Dive Part 2: Practical Implementation with LangChain
Learn about RAG Systems Deep Dive Part 2: Practical Implementation with LangChain
Extending LLM Capabilities with Custom Tools: Beyond the Knowledge Cutoff
Learn about Extending LLM Capabilities with Custom Tools: Beyond the Knowledge Cutoff