Building a RAG-Powered AI Assistant for My Personal Website
I added a floating chat widget to my personal website that answers questions about blog posts, projects, and my background using retrieval-augmented generation (RAG). The widget runs on GitHub Pages and proxies API calls through Cloudflare Workers, keeping the frontend entirely static while keeping costs under $5 monthly.
Requirements
The chat widget needed to:
- Answer questions using RAG against my blog content
- Handle sensitive or off-topic questions without crashing
- Work on static hosting with no backend database
- Keep implementation simple enough to iterate on
Architecture
The system chains four components together:
┌─────────────────────────────────────────────────────────────────┐
│ Browser (Frontend) │
├─────────────────────────────────────────────────────────────────┤
│ Chat Widget ──────▶ Search Client │
│ │ ├─ BM25 Keyword Search (local) │
│ │ └─ Voy WASM Semantic Search │
│ │ │ │
│ │◀───────────────────────┘ (Top 3 chunks as context) │
└───────┼─────────────────────────────────────────────────────────┘
│ POST /api/chat { messages, context }
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (yuxu.ge/api/*) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /api/embedding │ │ /api/chat │ │
│ │ (query vector) │ │ system_prompt │ │
│ └────────┬────────┘ │ + RAG context │ │
│ │ └────────┬────────┘ │
└───────────┼──────────────────────────┼──────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ OpenAI API │
│ text-embedding-3-small (512d) gpt-4o-mini │
└─────────────────────────────────────────────────────────────────┘
The frontend widget runs in the browser, sends user queries to Cloudflare Workers, which calls out to OpenAI's embedding and completion APIs. Search results feed into the system prompt to ground the chat response.
Implementation
Chat Widget (Frontend)
The widget is a self-contained JavaScript module that injects a floating bubble UI:
export class ChatWidget {
constructor() {
this.messages = [];
this.isOpen = false;
this.searchClient = null;
}
async sendMessage(text) {
// Get RAG context from search client
let context = '';
if (this.searchClient?.isReady()) {
const results = await this.searchClient.search(text, 3);
context = results.map(r => `[${r.title}]\n${r.text}`).join('\n\n');
}
// Call chat API with context
const response = await fetch('https://yuxu.ge/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: this.messages,
context,
}),
});
const data = await response.json();
return data.reply;
}
}
Key decisions:
- Markdown rendering: Handles
**bold**and[links](url)with simple regex, no dependencies - CSS-in-JS injection: All styles are dynamic, avoiding external stylesheets
- localStorage persistence: Conversation history survives page reloads within a 24-hour window; cleared automatically and manually via trash icon
Hybrid Search for RAG
My website already had a search system combining two strategies:
- BM25 keyword search: Inverted index for exact term matching
- Voy WASM semantic search: Pre-computed embeddings and vector similarity
- RRF fusion: Reciprocal Rank Fusion merges both rankings
The chat widget reuses this infrastructure:
const [keywordResults, semanticResults] = await Promise.all([
this.keywordSearch(query, limit * 2),
this.semanticSearch(query, limit * 2),
]);
// Merge using RRF
for (const result of keywordResults) {
rrfScores[result.url] = keywordWeight / (k + result.rank);
}
for (const result of semanticResults) {
rrfScores[result.url] += semanticWeight / (k + result.rank);
}
This avoided rebuilding a separate search index and kept latency low since embeddings were already computed.
Cloudflare Worker (API Proxy)
Two endpoints handle the chat flow:
/api/embedding — Converts query text to vector for semantic search:
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
dimensions: 512,
}),
});
/api/chat — Returns chat completion with RAG context:
const systemMessage = context
? `${env.system_prompt}\n\n## Related blog content:\n${context}`
: env.system_prompt;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
}),
});
Sensitive Topic Filtering
For a personal website, the assistant should freely discuss the site owner but refuse political or off-topic questions. This logic lives in the system prompt, stored as a Cloudflare environment variable:
## About the website owner
Yuxu Ge is the website owner. You can freely discuss:
- Professional background, technical experience, projects
- Blog content, technical opinions
- Public information (education, work history)
## Conversation boundaries
Politely decline and redirect for:
- Political figures, government policies, geopolitical disputes
- Religious or ideological debates
When declining: "This is beyond my scope as a technical assistant.
Shall we discuss something technical instead?"
## Public contact info (can share)
- Email: [email protected]
- GitHub: https://github.com/geyuxu
- LinkedIn: https://linkedin.com/in/yuxuge
Early attempts were too aggressive, blocking even questions about myself. The fix: explicitly whitelist allowed topics rather than blacklist forbidden ones.
Conversation History with localStorage
Persisting history in the browser rather than Cloudflare KV was pragmatic:
const CHAT_CONFIG = {
storageKey: 'chat_history',
historyTTL: 24 * 60 * 60 * 1000, // 24 hours
};
// Save after each successful response
saveHistory() {
const data = {
messages: this.messages.slice(-20),
timestamp: Date.now(),
};
localStorage.setItem(CHAT_CONFIG.storageKey, JSON.stringify(data));
}
// Load on widget initialization
loadHistory() {
const raw = localStorage.getItem(CHAT_CONFIG.storageKey);
if (!raw) return;
const data = JSON.parse(raw);
// Check TTL expiration
if (Date.now() - data.timestamp > CHAT_CONFIG.historyTTL) {
localStorage.removeItem(CHAT_CONFIG.storageKey);
return;
}
this.messages = data.messages;
this.renderHistory();
}
- One-time visitors: Most users have single conversations, so server-side storage adds complexity without benefit
- No user identification needed: localStorage is sufficient
- Zero marginal cost: KV storage, even cheap, is unnecessary overhead
- Trade-off: Cache clearing deletes history; cross-device sync unavailable
Lessons Learned
Environment variables over hardcoded prompts: Storing
system_prompton Cloudflare allows prompt iteration without code deployment.Reuse existing infrastructure: Building RAG on top of a hybrid search system saved significant effort versus implementing a new index.
Whitelist over blacklist: Explicit topic allowlists are more maintainable and less prone to over-filtering than broad restrictions.
Simple regex suffices: Basic markdown parsing handles the most common formatting needs without pulling in heavy dependencies.
Start with the simplest persistence layer: localStorage works for single-device, short-term history. Only escalate if requirements change.
Cost Analysis
Using gpt-4o-mini and text-embedding-3-small:
- Embedding: ~$0.00002 per query
- Chat: ~$0.0001–0.0005 per response (varies with context length)
- Estimated: < $5 monthly for moderate traffic
These are projected costs based on model pricing, not measured from actual usage.
What's Next
Potential improvements: streaming responses for better UX, usage analytics, image understanding for blog screenshots. The implementation is open source in my website repository.