Search-UI Application Architecture
Comprehensive documentation of the Search-UI full-stack application architecture, designed to serve as a reference for building similar applications.
Table of Contents
- Overview
- Tech Stack
- Project Structure
- Backend Architecture
- Frontend Architecture
- Database Design
- Data Flow Patterns
- API Design Patterns
- Development Workflow
- Key Design Decisions
- Extensibility Guide
Overview
Search-UI is a full-stack application for intelligent content discovery combining:
- Keyword search (SQLite FTS5)
- Semantic search (vector embeddings)
- Visual search (image classification)
- Face recognition (person identification)
- Smart routing (LLM-powered query intent detection)
The architecture emphasizes:
- Offline-first: No cloud dependencies, works completely locally
- Hardware-aware: Gracefully scales from low-end to high-end systems
- Modular design: Single-responsibility modules for maintainability
- Extensibility: Easy to add new search methods and features
Tech Stack
Backend
| Component | Technology | Purpose |
|---|---|---|
| Framework | FastAPI | REST API server |
| Runtime | Python 3.11+ | Backend language |
| Database | SQLite + FTS5 | Full-text search |
| Vector DB | sqlite-vec | Semantic search |
| Embeddings | sentence-transformers | Text embeddings |
| Image ML | SigLIP (transformers) | Image classification |
| Face ML | DeepFace + ArcFace | Face recognition |
| LLM | llama.cpp / Ollama | Query routing |
Frontend
| Component | Technology | Purpose |
|---|---|---|
| Framework | React 19 | UI components |
| Build Tool | Vite 7 | Fast bundling & HMR |
| Styling | Plain CSS | Dark theme, no framework |
| State | useState hooks | Local component state |
Infrastructure
| Component | Technology | Purpose |
|---|---|---|
| Process Manager | tmux | Background dev servers |
| Video Processing | ffmpeg | Thumbnail generation |
| Package Manager | pip (backend), npm (frontend) | Dependencies |
Project Structure
Search-UI/
βββ README.md # User-facing documentation
βββ CLAUDE.md # Development workflow guide
βββ docs/ # Documentation files
β βββ ARCHITECTURE.md # This file
β
βββ frontend/ # React + Vite application
β βββ src/
β β βββ main.jsx # Entry point
β β βββ App.jsx # Main app with routing
β β βββ App.css # Application styles
β β βββ SearchPage.jsx # Primary search interface
β β βββ DownloadPage.jsx # Data management interface
β β βββ PersonsPage.jsx # Face recognition management
β β βββ SettingsPage.jsx # Configuration interface
β βββ package.json # Node dependencies
β βββ vite.config.js # Vite configuration
β βββ index.html # HTML template
β βββ dist/ # Production build (generated)
β
βββ backend/ # FastAPI application
β βββ main.py # FastAPI server & endpoints
β βββ utils.py # Logging & HTTP utilities
β βββ search.py # Hybrid search system
β βββ smart_search.py # Intelligent search orchestrator
β βββ llm_router.py # LLM-powered routing
β βββ rule_based_router.py # Fallback pattern matching
β βββ hardware_detection.py # System capability detection
β βββ llm_client.py # LLM backend abstraction
β βββ search_images.py # Image-based search
β βββ face_search.py # Face recognition
β βββ settings.py # Configuration management
β βββ requirements.txt # Python dependencies
β βββ venv/ # Python virtual environment
β βββ database.db # Main SQLite database
β βββ settings.db # Settings database
β
βββ scratchpad/ # Temporary scripts
βββ start-app.sh # Launch both servers
βββ stop-app.sh # Stop servers
Backend Architecture
Module Responsibilities
| Module | Lines | Purpose |
|---|---|---|
main.py |
~4500 | FastAPI app, all endpoints, request handling |
search.py |
~1650 | FTS5 + vector hybrid search implementation |
smart_search.py |
~970 | Intelligent search orchestrator |
search_images.py |
~800 | Image classification and visual search |
face_search.py |
~1200 | Face recognition system |
llm_router.py |
~400 | LLM-powered query routing |
rule_based_router.py |
~300 | Pattern-based fallback router |
hardware_detection.py |
~200 | System capability detection |
llm_client.py |
~250 | Ollama/llama.cpp abstraction |
settings.py |
~270 | Settings database management |
utils.py |
~100 | Shared utilities |
Endpoint Organization
Endpoints in main.py are grouped by domain:
# Core endpoints
GET / # Root
GET /api/hello # Health check
GET /docs # Swagger UI (auto-generated)
# Search endpoints
GET /api/search # Hybrid text search
GET /api/search-natural # Smart search (auto-routed)
GET /api/search-title # Title-only search
GET /api/search-scripture # Scripture reference search
POST /api/search-by-image # Visual similarity search
GET /api/search-face # Face recognition search
# Data management
POST /api/download-vod # Download metadata
POST /api/download-subtitles # Download content
POST /api/process-subtitles # Index content
GET /api/download-status # Check progress
GET /api/sync-all # Full synchronization
# Content processing
POST /api/download-video # Download video file
POST /api/process-video # Generate thumbnails
# Settings
GET /api/settings/series # Get settings
POST /api/settings/series # Update settings
# System
GET /api/system-capabilities # Hardware info
POST /api/set-ai-mode # Toggle AI features
Dependency Injection Pattern
# main.py
from search import SubtitleSearch
from smart_search import SmartSearch
from search_images import ImageSearch
from face_search import FaceSearch
# Lazy initialization for memory efficiency
_subtitle_search = None
_smart_search = None
def get_subtitle_search():
global _subtitle_search
if _subtitle_search is None:
_subtitle_search = SubtitleSearch()
return _subtitle_search
@app.get("/api/search")
async def search(q: str, method: str = "hybrid"):
search_engine = get_subtitle_search()
return search_engine.search(q, method)
Error Handling Pattern
from fastapi import HTTPException
from utils import log_message
@app.get("/api/endpoint")
async def endpoint(param: str):
try:
result = perform_operation(param)
return {"status": "success", "data": result}
except ValueError as e:
log_message(f"Validation error: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log_message(f"Unexpected error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
Frontend Architecture
Component Hierarchy
App.jsx (Router + Layout)
βββ SearchPage.jsx # Main search interface
β βββ Search input
β βββ Method selector
β βββ Filter controls
β βββ Results grid
β βββ Video player
β βββ Subtitle viewer
βββ DownloadPage.jsx # Data management
β βββ Progress indicators
β βββ Action buttons
βββ PersonsPage.jsx # Face management
β βββ Person list
β βββ Face tagging UI
βββ SettingsPage.jsx # Configuration
βββ AI mode selector
βββ Series settings
State Management Pattern
// SearchPage.jsx - Local state with useState
function SearchPage({ onNavigate }) {
// Search state
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Selection state
const [selectedResult, setSelectedResult] = useState(null);
const [subtitleEntries, setSubtitleEntries] = useState([]);
// Filter state
const [filters, setFilters] = useState({
dateFrom: null,
dateTo: null,
durationMin: null,
durationMax: null
});
// Async operations with proper cleanup
const handleSearch = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE_URL}/api/search?q=${query}`);
const data = await response.json();
setResults(data.results);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (/* JSX */);
}
API Integration Pattern
// Dynamic base URL for dev/prod
const API_BASE_URL = window.location.port === '5173'
? `http://${window.location.hostname}:8000` // Dev: Vite on 5173, FastAPI on 8000
: ''; // Prod: Same origin
// Fetch wrapper with error handling
async function apiCall(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`API call failed: ${endpoint}`, error);
throw error;
}
}
Cross-Page Navigation
// App.jsx - Navigation with parameters
function App() {
const [currentPage, setCurrentPage] = useState('search');
const [navigationParams, setNavigationParams] = useState({});
const handleNavigate = (page, params = {}) => {
setNavigationParams(params);
setCurrentPage(page);
};
return (
<div>
<nav>{/* Tab buttons */}</nav>
{currentPage === 'search' && (
<SearchPage
onNavigate={handleNavigate}
initialParams={navigationParams}
/>
)}
{/* Other pages */}
</div>
);
}
Database Design
Schema Overview
-- database.db
-- Full-text search (FTS5)
CREATE VIRTUAL TABLE subtitles_fts USING fts5(
natural_key, -- Unique content identifier
language, -- Language code (E, S, etc.)
content -- Full text content
);
-- Vector embeddings (sqlite-vec)
CREATE VIRTUAL TABLE subtitle_embeddings USING vec0(
natural_key TEXT,
language TEXT,
embedding bit[1024] -- Binary quantized (32x smaller than float32)
);
-- Structured data
CREATE TABLE scripture_references (
id INTEGER PRIMARY KEY AUTOINCREMENT,
natural_key TEXT NOT NULL,
language TEXT NOT NULL,
book TEXT NOT NULL,
chapter INTEGER NOT NULL,
verse_start INTEGER NOT NULL,
verse_end INTEGER,
timestamp REAL,
original_text TEXT,
normalized TEXT,
UNIQUE(natural_key, language, book, chapter, verse_start, verse_end, timestamp)
);
-- Face recognition
CREATE TABLE persons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE face_embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
natural_key TEXT NOT NULL,
frame_number INTEGER NOT NULL,
person_id INTEGER, -- NULL for unknown faces
embedding bit[512],
confidence REAL,
FOREIGN KEY (person_id) REFERENCES persons(id)
);
CREATE TABLE global_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Why SQLite?
- Offline-first: No external database server required
- Single-file: Easy backup and distribution
- FTS5 built-in: Native full-text search support
- sqlite-vec extension: Enables vector similarity search
- Fast local access: No network latency
- Zero configuration: Works out of the box
Binary Quantized Embeddings
# search.py - Why 1024-bit binary instead of 1024-dim float32?
# Float32: 1024 dimensions Γ 4 bytes = 4,096 bytes per embedding
# Binary: 1024 bits Γ· 8 = 128 bytes per embedding
# = 32x smaller storage, faster similarity computation
def binarize_embedding(embedding):
"""Convert float embedding to binary (threshold at 0)."""
return (embedding > 0).astype(np.uint8)
# Similarity via Hamming distance (bit operations)
# Slight accuracy loss, massive efficiency gain
Data Flow Patterns
Content Ingestion Pipeline
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. DOWNLOAD METADATA β
β POST /api/download-vod β
β β β
β External API β json/{language}/all_media_items.json β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. DOWNLOAD CONTENT β
β POST /api/download-subtitles β
β β β
β For each media item: β
β URL β subtitles/{language}/{natural_key}.vtt β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. PROCESS & INDEX β
β POST /api/process-subtitles β
β β β
β VTT β Parse β TXT β
β β β
β Insert into subtitles_fts (FTS5) β
β β β
β Generate embeddings β Insert into subtitle_embeddings β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Search Query Execution
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β USER QUERY β
β "teachings about faith" / [image upload] / [face image] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SMART SEARCH ORCHESTRATOR (smart_search.py) β
β β
β 1. Detect input type (text / image) β
β 2. Route via LLM (if available) or rules β
β 3. Generate SearchPlan: β
β - primary_method: "hybrid" β
β - secondary_methods: ["title"] β
β - intent: "topic_search" β
β - confidence: 0.92 β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PARALLEL EXECUTION β
β β
β βββββββββββββββββββ βββββββββββββββββββ β
β β Keyword Search β β Semantic Search β β
β β (FTS5) β β (Embeddings) β β
β ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ β
β ββββββββββββ¬ββββββββββ β
β β β
β Merge & Rank Results β
β (30% keyword + 70% semantic) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β RESPONSE β
β { β
β "results": [...], β
β "search_plan": { method, intent, confidence }, β
β "router_used": "llm", β
β "total_time_ms": 145 β
β } β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
API Design Patterns
Standard Response Format
// Success
{
"status": "success",
"data": { /* operation result */ },
"message": "Optional message"
}
// Error
{
"detail": "Error description"
}
// HTTP status code indicates error type (400, 404, 500, etc.)
Search Response Format
{
"results": [
{
"natural_key": "pub-example_E_1_VIDEO",
"title": "Example Title",
"score": 0.87,
"snippet": "...matching text with <em>highlights</em>...",
"category": "Category Name",
"subcategory": "Subcategory Name",
"publication_date": "2025-01-15",
"thumbnail": "https://example.com/thumb.jpg",
"source_method": "hybrid"
}
],
"count": 25,
"search_plan": {
"primary_method": "hybrid",
"secondary_methods": ["title"],
"intent": "topic_search",
"confidence": 0.92
},
"router_used": "llm",
"total_time_ms": 145
}
Progress Response Format
{
"status": "in_progress",
"step": "processing",
"current": 45,
"total": 320,
"percent": 14,
"message": "Processing item 45/320",
"elapsed_seconds": 120,
"estimated_remaining_seconds": 780
}
Query Parameters vs Body
# GET requests: Query parameters for filters
@app.get("/api/search")
async def search(
q: str, # Required
language: str = "E", # Optional with default
method: str = "hybrid", # Optional with default
limit: int = 200 # Optional with default
):
pass
# POST requests: Body for complex data
@app.post("/api/search-by-image")
async def search_by_image(file: UploadFile = File(...)):
pass
Development Workflow
Starting Development Servers
# Using tmux for background processes
# Start backend (IMPORTANT: --host 0.0.0.0 for network access)
tmux new -d -s backend \
"cd backend && source venv/bin/activate && \
uvicorn main:app --reload --host 0.0.0.0 2>&1 | tee ../backend.log"
# Start frontend
tmux new -d -s frontend \
"cd frontend && npm run dev -- --host 0.0.0.0 2>&1 | tee ../frontend.log"
# Monitor logs
tail -f backend.log frontend.log
# Stop servers
tmux kill-session -t backend
tmux kill-session -t frontend
Why --host 0.0.0.0?
Without it, the backend only accepts connections from localhost. With --host 0.0.0.0, it accepts connections from any network interface, which is required when:
- Accessing the app via machine's network IP (not localhost)
- Frontend and backend on different ports during development
Project Conventions
- Temporary Scripts: Store in
scratchpad/YYYY-MM-DD-HHmm-description.py - Script Results: Save to
scratchpad/YYYY-MM-DD-HHmm-description.result.txt - CSS Animations: Never use
transform: scale()on hover - Imports: Organize by stdlib, third-party, local modules
Key Design Decisions
Why Hybrid Search?
| Method | Strengths | Weaknesses |
|---|---|---|
| Keyword (FTS5) | Fast, exact matches, proper nouns | Misses synonyms, typos |
| Semantic | Conceptual understanding, synonyms | Slower, needs embeddings |
| Hybrid | Best of both | More complex |
Default weights: 30% keyword, 70% semantic
Why Three-Tier Routing?
1. LLM Router (Primary)
- Best accuracy for complex queries
- Understands nuance and context
- Requires LLM availability
2. Rule-Based Router (Fallback)
- Pattern matching for common cases
- Always available, no dependencies
- Handles scripture, visual keywords, etc.
3. Direct Method Override
- User explicitly selects method
- Bypasses routing entirely
Why Local-First?
- Privacy: No data leaves user's machine
- Reliability: Works without internet
- Speed: No network latency
- Cost: No API fees or cloud costs
- Control: User owns all data
Why Lazy Loading?
# ML models loaded on first use, not at startup
_subtitle_search = None
def get_subtitle_search():
global _subtitle_search
if _subtitle_search is None:
# Load model only when first needed
_subtitle_search = SubtitleSearch()
return _subtitle_search
Benefits:
- Faster app startup
- Lower memory if feature unused
- Graceful degradation if model fails to load
Extensibility Guide
Adding a New Search Method
- Define the method in
rule_based_router.py:
class SearchMethod(Enum):
KEYWORD = "keyword"
SEMANTIC = "semantic"
HYBRID = "hybrid"
NEW_METHOD = "new_method" # Add here
- Implement the search in appropriate module:
# new_search.py
class NewSearch:
def search(self, query: str) -> List[Dict]:
# Implementation
pass
- Add to smart search in
smart_search.py:
def _execute_search_plan(self, plan: SearchPlan):
if plan.primary_method == SearchMethod.NEW_METHOD:
return self._new_search.search(plan.query)
- Create endpoint in
main.py:
@app.get("/api/search-new")
async def search_new(q: str):
return get_new_search().search(q)
- Add UI option in
SearchPage.jsx:
<select value={searchMethod} onChange={e => setSearchMethod(e.target.value)}>
<option value="hybrid">Hybrid</option>
<option value="new_method">New Method</option>
</select>
Adding a New Frontend Page
- Create component in
frontend/src/NewPage.jsx - Add to App.jsx:
import NewPage from './NewPage';
// In tab navigation
<button onClick={() => setCurrentPage('new')}>New</button>
// In render
{currentPage === 'new' && <NewPage />}
Adding a New API Endpoint
# main.py
@app.post("/api/new-feature")
async def new_feature(
param1: str,
param2: int = 10,
body: Optional[Dict] = None
):
"""
New feature endpoint.
- param1: Required parameter description
- param2: Optional parameter with default
- body: Optional JSON body
"""
try:
result = process_new_feature(param1, param2, body)
return {"status": "success", "data": result}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log_message(f"Error in new_feature: {e}")
raise HTTPException(status_code=500, detail="Internal error")
Quick Reference
Backend Commands
# Activate virtual environment
cd backend && source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Run server (development)
uvicorn main:app --reload --host 0.0.0.0
# Run server (production)
uvicorn main:app --host 0.0.0.0 --workers 4
Frontend Commands
# Install dependencies
cd frontend && npm install
# Run development server
npm run dev -- --host 0.0.0.0
# Build for production
npm run build
# Preview production build
npm run preview
API Documentation
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
Conclusion
This architecture provides a solid foundation for building intelligent, offline-capable applications with:
- Modular backend with clear separation of concerns
- Simple frontend using React best practices
- Flexible search combining multiple methods
- Hardware-aware ML integration
- Easy extensibility for new features
Use this documentation as a reference when building similar applications, adapting the patterns and components to your specific needs.