Article · 2026-01-28

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:

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:

Hybrid Search for RAG

My website already had a search system combining two strategies:

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();
}

Lessons Learned

  1. Environment variables over hardcoded prompts: Storing system_prompt on Cloudflare allows prompt iteration without code deployment.

  2. Reuse existing infrastructure: Building RAG on top of a hybrid search system saved significant effort versus implementing a new index.

  3. Whitelist over blacklist: Explicit topic allowlists are more maintainable and less prone to over-filtering than broad restrictions.

  4. Simple regex suffices: Basic markdown parsing handles the most common formatting needs without pulling in heavy dependencies.

  5. 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:

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.

© 2026 Yuxu Ge ·