Optimizing Your Document Q&A Application
Overview
Performance optimization is crucial for providing a smooth user experience in your Document Q&A application. This tutorial covers various techniques to optimize both the frontend and backend components, ensuring fast response times and efficient resource usage.
Frontend Optimization
1. Component Memoization
Use React's memoization features to prevent unnecessary re-renders:
// Before optimization
const QuestionItem = ({ question, answer }) => {
return (
<div className="qa-item">
<p className="question">{question}</p>
<p className="answer">{answer}</p>
</div>
);
};
// After optimization with React.memo
import React from 'react';
const QuestionItem = React.memo(({ question, answer }) => {
return (
<div className="qa-item">
<p className="question">{question}</p>
<p className="answer">{answer}</p>
</div>
);
});
// For components with complex props, provide a custom comparison function
const areEqual = (prevProps, nextProps) => {
return prevProps.question === nextProps.question &&
prevProps.answer === nextProps.answer;
};
const QuestionItem = React.memo(({ question, answer }) => {
// Component implementation
}, areEqual);2. Optimizing Hooks
Use the useCallback and useMemo hooks to optimize performance:
// Before optimization
const QuestionInput = ({ documentId }) => {
const [question, setQuestion] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
// Submit logic
};
const filteredHistory = chatHistory.filter(item => item.type !== 'error');
return (
// Component JSX
);
};
// After optimization
const QuestionInput = ({ documentId }) => {
const [question, setQuestion] = useState('');
// Memoize the submit handler
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
// Submit logic
}, [documentId, question]); // Only recreate if these dependencies change
// Memoize expensive computations
const filteredHistory = useMemo(() => {
return chatHistory.filter(item => item.type !== 'error');
}, [chatHistory]); // Only recompute when chatHistory changes
return (
// Component JSX
);
};3. Code Splitting
Use dynamic imports to split your code and load components only when needed:
// src/app/page.js
import dynamic from 'next/dynamic';
// Import components dynamically
const FileUpload = dynamic(() => import('../components/FileUpload'), {
loading: () => <p>Loading file upload component...</p>,
});
const QuestionInput = dynamic(() => import('../components/QuestionInput'), {
loading: () => <p>Loading question input component...</p>,
});
export default function Home() {
const [documentId, setDocumentId] = useState(null);
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Document Q&A</h1>
{!documentId ? (
<div>
<h2 className="text-xl mb-4">Upload a Document</h2>
<FileUpload onDocumentUploaded={setDocumentId} />
</div>
) : (
<div>
<h2 className="text-xl mb-4">Ask Questions</h2>
<QuestionInput documentId={documentId} />
</div>
)}
</div>
);
}4. Optimizing Images
Use Next.js Image component for optimized image loading:
// Before optimization
<img src="/logo.png" alt="Logo" width={200} height={50} />
// After optimization
import Image from 'next/image';
<Image
src="/logo.png"
alt="Logo"
width={200}
height={50}
priority={true} // For important above-the-fold images
loading="lazy" // For below-the-fold images
/>API Optimization
1. Request Debouncing
Implement debouncing to prevent excessive API calls:
// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set debouncedValue to value after the specified delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes or component unmounts
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage in a component
import { useDebounce } from '../hooks/useDebounce';
const SearchComponent = () => {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500); // 500ms delay
useEffect(() => {
if (debouncedSearchTerm) {
// Make API call with debouncedSearchTerm
searchAPI(debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
};2. Caching API Responses
Implement caching to avoid redundant API calls:
// src/lib/api-cache.js
class APICache {
constructor(maxSize = 100) {
this.cache = new Map();
this.maxSize = maxSize;
}
set(key, value, ttl = 5 * 60 * 1000) { // Default TTL: 5 minutes
// If cache is full, remove the oldest entry
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, {
value,
expiry: Date.now() + ttl,
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if the entry has expired
if (entry.expiry < Date.now()) {
this.cache.delete(key);
return null;
}
return entry.value;
}
clear() {
this.cache.clear();
}
}
const apiCache = new APICache();
export default apiCache;
// Usage with API client
import apiCache from './api-cache';
import apiClient from './api-client';
export async function fetchWithCache(url, params = {}, ttl = 5 * 60 * 1000) {
// Create a cache key based on the URL and params
const cacheKey = `${url}:${JSON.stringify(params)}`;
// Check if we have a cached response
const cachedResponse = apiCache.get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
// If not cached, make the API call
const response = await apiClient.get(url, { params });
// Cache the response
apiCache.set(cacheKey, response.data, ttl);
return response.data;
}Document Processing Optimization
1. File Size Optimization
Implement client-side file compression before uploading:
// src/utils/file-compression.js
import { PDFDocument } from 'pdf-lib';
export async function compressPDF(file) {
// Read the file as ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Load the PDF document
const pdfDoc = await PDFDocument.load(arrayBuffer);
// Compress the PDF
const compressedPdfBytes = await pdfDoc.save({
useObjectStreams: true,
addDefaultPage: false,
});
// Create a new File object with the compressed data
const compressedFile = new File(
[compressedPdfBytes],
file.name,
{ type: file.type }
);
return compressedFile;
}
// Usage in FileUpload component
import { compressPDF } from '../utils/file-compression';
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return;
const file = acceptedFiles[0];
// Compress PDF files before uploading
let fileToUpload = file;
if (file.type === 'application/pdf') {
setIsCompressing(true);
try {
fileToUpload = await compressPDF(file);
console.log(`Compressed file from ${file.size} to ${fileToUpload.size} bytes`);
} catch (err) {
console.error('Compression error:', err);
// Fall back to the original file
fileToUpload = file;
} finally {
setIsCompressing(false);
}
}
// Continue with upload process
// ...
}, []);2. Chunked Uploads
Implement chunked uploads for large files:
// src/utils/chunked-upload.js
export async function uploadInChunks(file, uploadUrl, chunkSize = 1024 * 1024) {
const totalChunks = Math.ceil(file.size / chunkSize);
let uploadedChunks = 0;
let uploadId = null;
// Initialize upload
const initResponse = await fetch(`${uploadUrl}/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: file.name,
fileSize: file.size,
fileType: file.type,
totalChunks,
}),
});
const initData = await initResponse.json();
uploadId = initData.uploadId;
// Upload chunks
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('uploadId', uploadId);
formData.append('chunkIndex', chunkIndex.toString());
await fetch(`${uploadUrl}/chunk`, {
method: 'POST',
body: formData,
});
uploadedChunks++;
const progress = Math.round((uploadedChunks / totalChunks) * 100);
// Update progress
}
// Complete upload
const completeResponse = await fetch(`${uploadUrl}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uploadId,
}),
});
return await completeResponse.json();
}
// Usage in FileUpload component
import { uploadInChunks } from '../utils/chunked-upload';
const handleUpload = async (file) => {
setIsUploading(true);
setError(null);
try {
const response = await uploadInChunks(
file,
`${process.env.NEXT_PUBLIC_API_URL}/upload`
);
if (onDocumentUploaded) {
onDocumentUploaded(response.documentId);
}
} catch (err) {
console.error('Upload error:', err);
setError('Failed to upload document');
} finally {
setIsUploading(false);
}
};Backend Optimization
1. Response Streaming
Implement response streaming for long-running operations:
# api/routes/qa.py
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from ..services.llm_service import get_llm_response_stream
router = APIRouter()
@router.post("/ask/stream")
async def ask_question_stream(request: Request):
data = await request.json()
document_id = data.get("document_id")
question = data.get("question")
# Validate inputs
if not document_id or not question:
return {"error": "Missing required parameters"}
# Return a streaming response
return StreamingResponse(
get_llm_response_stream(document_id, question),
media_type="text/event-stream"
)
# api/services/llm_service.py
async def get_llm_response_stream(document_id, question):
# Get document content
document = await get_document(document_id)
# Set up LLM client
client = get_llm_client()
# Stream the response
async for chunk in client.completions.create(
model="gpt-3.5-turbo",
prompt=f"Document: {document}
Question: {question}
Answer:",
stream=True,
max_tokens=500
):
if chunk.choices[0].text:
# Yield in the format expected by EventSource
yield f"data: {chunk.choices[0].text}
"
# Signal the end of the stream
yield "data: [DONE]
"2. Caching Document Processing
Implement caching for document processing results:
# api/services/document_service.py
import hashlib
import json
from functools import lru_cache
# LRU cache for document processing
@lru_cache(maxsize=100)
def process_document_cached(document_id, content_hash):
# The content_hash parameter ensures the cache is invalidated if the document changes
# Process the document and return the result
# ...
return processed_result
async def process_document(document_id):
# Get document content
document = await get_document(document_id)
# Generate a hash of the document content
content_hash = hashlib.md5(document.encode()).hexdigest()
# Use the cached version if available
return process_document_cached(document_id, content_hash)3. Asynchronous Processing
Implement asynchronous processing for long-running tasks:
# api/routes/documents.py
from fastapi import APIRouter, BackgroundTasks, UploadFile, File
from ..services.document_service import process_document_async
router = APIRouter()
@router.post("/upload")
async def upload_document(
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
# Save the file temporarily
file_path = await save_upload_file(file)
# Generate a document ID
document_id = generate_document_id()
# Start processing in the background
background_tasks.add_task(
process_document_async,
document_id,
file_path
)
# Return immediately with the document ID
return {
"document_id": document_id,
"status": "processing",
"message": "Document upload received and processing started"
}
@router.get("/documents/{document_id}/status")
async def get_document_status(document_id: str):
# Check the processing status
status = await get_processing_status(document_id)
return {
"document_id": document_id,
"status": status.state,
"progress": status.progress,
"error": status.error
}Next Steps
Now that you've optimized your Document Q&A application, you can: