All articles
Build Guides

Build a Discord FAQ Bot with Supabase pgvector + Cloudflare Workers AI

FDE Coach EditorialJuly 21, 202610 min read

What We're Building

We'll build a Discord community bot that acts as a first-line support engineer. A user asks a question in a designated channel, the bot searches your documentation for the most relevant chunks, synthesizes a concise answer with an LLM, and posts it back—all in under two seconds. The entire stack runs on free tiers.

Feature list:

  • Slash command /ask to query your docs
  • RAG pipeline: chunk → embed → retrieve → generate
  • Source citation so users know exactly where the answer came from
  • Stateless serverless deployment (no cold-start headaches with proper setup)
  • Zero infrastructure cost under free-tier limits

Architecture

The flow is straightforward: the Discord bot is a thin relay. The Cloudflare Worker owns all intelligence—embedding the incoming question, hitting Supabase for vector similarity search, and calling an LLM to ground the response in the retrieved chunks. We keep the Worker stateless; Supabase holds the vector store.

Prerequisites

Every tool here has a generous free tier. Sign up for these before you start:

  • Node.js 18+ and npm (local dev)
  • Discord ApplicationDiscord Developer Portal. Create an app, add a bot, grab the token and client ID.
  • Supabasesupabase.com. Free tier gives you 500 MB database and pgvector extension.
  • Cloudflare Workersdash.cloudflare.com. Free tier: 100k requests/day, 10 ms CPU time per invocation. We'll also enable Workers AI for the embedding and LLM models.
  • Wrangler CLInpm install -g wrangler

Step 1: Project Setup and Dependencies

Create a monorepo with two packages: the Discord bot and the Cloudflare Worker.

mkdir discord-faq-bot && cd discord-faq-bot
mkdir bot worker
git init

Bot package (bot/package.json):

{
  "name": "discord-faq-bot",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "discord.js": "^14.14.1"
  }
}

Worker package (worker/package.json):

{
  "name": "faq-worker",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "deploy": "wrangler deploy",
    "dev": "wrangler dev"
  },
  "dependencies": {
    "@supabase/supabase-js": "^2.39.0"
  }
}

Run npm install in both directories.

Step 2: Embedding and Storing Your Docs in Supabase

We need a one-time ingestion script that reads your docs, chunks them, generates embeddings, and pushes everything to Supabase. This script runs locally (or in a CI pipeline) whenever your docs change.

First, enable the pgvector extension in your Supabase SQL editor:

create extension if not exists vector;

create table docs (
  id bigserial primary key,
  content text,
  metadata jsonb,
  embedding vector(768)  -- matches @cf/baai/bge-base-en-v1.5 dimension
);

create index on docs using ivfflat (embedding vector_cosine_ops) with (lists = 100);

Now create ingest.js in the project root:

import { createClient } from '@supabase/supabase-js';
import { readFileSync, readdirSync } from 'fs';
import { join, extname } from 'path';

const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY;
const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
const CF_API_TOKEN = process.env.CF_API_TOKEN;

const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);

async function embed(text) {
  const res = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/ai/run/@cf/baai/bge-base-en-v1.5`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ text })
    }
  );
  const json = await res.json();
  return json.result.data;
}

function chunkText(text, maxChars = 800, overlap = 100) {
  const chunks = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + maxChars, text.length);
    chunks.push(text.slice(start, end));
    start += maxChars - overlap;
  }
  return chunks;
}

async function main() {
  const docsDir = './docs';
  const files = readdirSync(docsDir).filter(f => ['.md', '.txt', '.mdx'].includes(extname(f)));
  
  for (const file of files) {
    const content = readFileSync(join(docsDir, file), 'utf-8');
    const chunks = chunkText(content);
    
    for (let i = 0; i < chunks.length; i++) {
      const embedding = await embed(chunks[i]);
      await supabase.from('docs').insert({
        content: chunks[i],
        metadata: { file, chunk_index: i },
        embedding
      });
      console.log(`Ingested ${file} chunk ${i}`);
    }
  }
}

main().catch(console.error);

Run it with node ingest.js. Your docs are now vectorized and searchable.

Step 3: The Cloudflare Worker for RAG Inference

Create worker/src/index.js:

import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = 'https://xxxxx.supabase.co';
const SUPABASE_ANON_KEY = 'xxxxx';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const { query } = await request.json();
    if (!query) {
      return new Response(JSON.stringify({ error: 'Missing query' }), { status: 400 });
    }

    // 1. Embed the query
    const embeddingRes = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: query });
    const queryEmbedding = embeddingRes.data;

    // 2. Vector search in Supabase
    const { data: chunks, error } = await supabase.rpc('match_docs', {
      query_embedding: queryEmbedding,
      match_threshold: 0.7,
      match_count: 5
    });

    if (error || !chunks?.length) {
      return new Response(JSON.stringify({ answer: "I couldn't find relevant docs for that question." }), {
        headers: { 'Content-Type': 'application/json' }
      });
    }

    // 3. Build prompt with retrieved context
    const context = chunks.map(c => c.content).join('\n\n---\n\n');
    const prompt = `You are a helpful documentation assistant. Answer the user's question using ONLY the context below. If the answer isn't in the context, say "I don't have enough information." Cite the source file names when possible.

Context:
${context}

User question: ${query}

Answer:`;

    // 4. Generate answer with LLM
    const llmRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
      prompt,
      max_tokens: 300
    });

    const sources = [...new Set(chunks.map(c => c.metadata.file))];

    return new Response(JSON.stringify({
      answer: llmRes.response,
      sources
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

We need that match_docs RPC function in Supabase. Run this SQL:

create or replace function match_docs (
  query_embedding vector(768),
  match_threshold float,
  match_count int
)
returns table (
  id bigint,
  content text,
  metadata jsonb,
  similarity float
)
language sql stable
as $$
  select
    docs.id,
    docs.content,
    docs.metadata,
    1 - (docs.embedding <=> query_embedding) as similarity
  from docs
  where 1 - (docs.embedding <=> query_embedding) > match_threshold
  order by similarity desc
  limit match_count;
$$;

Worker configuration (worker/wrangler.toml):

name = "faq-worker"
main = "src/index.js"
compatibility_date = "2024-12-01"

[ai]
binding = "AI"

Deploy with npx wrangler deploy. Note the worker URL—you'll need it for the bot.

Step 4: Wiring Up the Discord Bot

Create bot/index.js:

import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';

const TOKEN = process.env.DISCORD_TOKEN;
const CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const WORKER_URL = process.env.WORKER_URL; // e.g., https://faq-worker.your-subdomain.workers.dev

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages]
});

// Register slash command on startup
const commands = [
  new SlashCommandBuilder()
    .setName('ask')
    .setDescription('Ask a question about our docs')
    .addStringOption(option =>
      option.setName('question')
        .setDescription('Your question')
        .setRequired(true)
    )
].map(cmd => cmd.toJSON());

const rest = new REST({ version: '10' }).setToken(TOKEN);
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });

client.on('ready', () => {
  console.log(`Logged in as ${client.user.tag}`);
});

client.on('interactionCreate', async interaction => {
  if (!interaction.isCommand() || interaction.commandName !== 'ask') return;

  await interaction.deferReply(); // Bot shows "thinking..."

  const question = interaction.options.getString('question');

  try {
    const res = await fetch(`${WORKER_URL}/query`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: question })
    });
    const { answer, sources } = await res.json();

    const sourceLinks = sources?.length
      ? `\n\n**Sources:** ${sources.map(s => `\`${s}\``).join(', ')}`
      : '';

    await interaction.editReply(`${answer}${sourceLinks}`);
  } catch (err) {
    console.error(err);
    await interaction.editReply('Something went wrong. Try again in a moment.');
  }
});

client.login(TOKEN);

This bot is deliberately minimal. It defers the reply immediately so Discord doesn't time out, calls the Worker, and formats the response with source attribution.

Step 5: Running the Bot

Set environment variables (use a .env file or export them):

export DISCORD_TOKEN=your_bot_token
export DISCORD_CLIENT_ID=your_app_client_id
export WORKER_URL=https://faq-worker.your-subdomain.workers.dev

Start the bot:

cd bot && node index.js

Invite the bot to your server using the OAuth2 URL Generator in the Discord Developer Portal (scope: bot + applications.commands). Once it joins, type /ask How do I set up webhooks? and watch it respond with a grounded answer.

For production, deploy the bot on a free platform like Fly.io (free allowance includes 3 shared-cpu VMs) or keep it running on a Raspberry Pi. The Worker handles the heavy lifting, so the bot process is just a relay.

Sensible Extensions

  • HyDE (Hypothetical Document Embeddings): Before vector search, ask the LLM to generate a hypothetical answer to the user's question, then embed that for retrieval. Often improves recall for vague queries.
  • Feedback loop: Add 👍/👎 reactions. Store the interaction in a feedback table and use it to fine-tune chunking or prompt templates.
  • Multi-source RAG: Pull docs from Notion, GitHub READMEs, and a PDF manual. The ingestion script is the only thing that changes—the Worker stays the same. For ideas on integrating multiple data sources, see our guide on building a lead-enrichment agent which follows a similar multi-source retrieval pattern.
  • Streaming responses: Discord supports editing messages. Have the Worker stream tokens back and the bot update the reply incrementally for a snappier feel.
  • Rate limiting: Add a simple in-memory counter in the Worker to prevent abuse (the free tier has daily limits you'll want to guard).

Common Pitfalls

  1. Embedding dimension mismatch. @cf/baai/bge-base-en-v1.5 outputs 768-dim vectors. If you change the embedding model, update the vector(768) column definition and the index.
  2. Supabase anon key vs service key. The Worker uses the anon key for RLS-protected queries (safe to expose client-side). The ingestion script uses the service key (never expose this). Make sure your match_docs function is accessible with the anon key by granting execute: grant execute on function match_docs to anon;
  3. Discord interaction timeout. Discord expects an initial response within 3 seconds. That's why we deferReply() immediately. If your Worker is cold, the first request might take 2-4 seconds—test and consider a cron job to keep it warm.
  4. Chunking strategy matters. Fixed-size 800-char chunks with 100-char overlap is a starting point. For technical docs, consider semantic chunking (split on ## headings) to keep coherent sections together. The chunking logic is where you'll spend most of your tuning time—much like the SQL analyst agent where query decomposition makes or breaks accuracy.
  5. Cloudflare Worker CPU limits. Free tier gives 10ms CPU per request. The AI binding calls (embedding + LLM) don't count against CPU time—they're external API calls. Your actual JS execution should stay well under the limit.

FAQ

Q: Why not use LangChain or LlamaIndex for the RAG pipeline? A: You absolutely can. LlamaIndex's CloudflareWorkersAI integration is excellent if you want a framework to manage chunking, embedding, and retrieval abstractions. We went framework-free here to keep the deployable artifact tiny and the code fully transparent. If your ingestion pipeline grows complex (re-ranking, hybrid search, metadata filtering), pulling in LlamaIndex for the ingestion side is a smart move.

Q: How many docs can I store on the free tier? Supabase's free tier includes 500 MB of database storage. A 768-dim vector is ~3 KB. You can store roughly 150,000 chunks before hitting the limit—enough for thousands of pages of documentation.

Q: What if my docs change frequently? Run the ingestion script on a schedule (GitHub Actions with a cron trigger works well). For a more event-driven approach, trigger ingestion via a webhook from your CMS. The pattern is similar to the automation pipelines we discuss in our FDE rapid-prototyping playbook.

Q: Can I use OpenAI embeddings instead of Cloudflare? Yes. Swap the embedding call in both the Worker and ingestion script. You'll need to handle API keys and potentially pay for usage. Cloudflare Workers AI is free for up to 10k requests/day, which is why we chose it for a zero-cost setup.

Q: The bot answers questions that aren't in the docs. How do I stop hallucinations? Tighten the match_threshold in the match_docs RPC call (try 0.75–0.8). Also strengthen the system prompt: add explicit instructions to say "I don't have enough information" and consider adding a second LLM call that classifies whether the retrieved chunks are actually relevant before generating the answer.

Q: How do I measure if this is actually helping my community? Track the ratio of /ask invocations to new support threads created. If the bot deflects questions effectively, you'll see thread creation drop. This is a classic time-to-value metric that Forward Deployed Engineers obsess over—the bot shrinks the gap between a user's question and a useful answer.

#Discord bot#RAG#vector search#community tools#serverless

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now