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: