All articles
Build Guides

Build a Discord FAQ Bot That Answers From Your Docs (Supabase + OpenRouter)

FDE Coach EditorialJuly 24, 202610 min read

What We’re Building

A Discord bot that turns your documentation into a searchable knowledge base. Community members type /faq <question>, the bot retrieves the most relevant chunks from your docs via semantic search, and a free LLM on OpenRouter synthesizes a concise answer with citations—no hallucinated nonsense, no "I think."

Feature list:

  • /faq slash command that accepts any natural-language question
  • Semantic search over your docs using Supabase’s pgvector (free tier)
  • LLM-powered answer synthesis via OpenRouter’s free models (e.g., Gemini Flash 1.5, Llama 3.1 8B)
  • Citation links back to the source chunks so users can verify
  • Rate limiting so you don’t burn through free credits
  • Extensible ingestion pipeline: Markdown, HTML, plaintext, or Notion exports

This is the same pattern I use when a customer’s community is drowning in repeat questions and the docs are right there but nobody reads them. Ship this in an afternoon.

Architecture at a Glance

The flow is two lanes: ingestion (right side) and query (left side). Ingestion runs offline—you point a Python script at your docs folder, it chunks, embeds, and upserts into Supabase. The bot listens for /faq, embeds the question with the same model, runs a cosine similarity search, ships the top 3-5 chunks to OpenRouter, and posts the answer back to Discord.

Prerequisites (All Free-Tier)

  • Discord Bot Token: Discord Developer Portal → New Application → Bot → Reset Token. Free.
  • Supabase Account: supabase.com → New Project. Free tier gives you 500 MB database, pgvector enabled by default.
  • OpenRouter API Key: openrouter.ai/keys. Free credits on signup; free models available (Gemini Flash 1.5, Llama 3.1 8B, etc.).
  • Node.js 18+ and Python 3.10+ on your machine (or a free-tier cloud VM like Oracle Always Free).

No credit card required for any of these. If you hit a free-tier limit, you’re either running a massive community or you forgot to add rate limiting.

Step 1: Scaffold the Discord Bot

Create a new directory and init:

mkdir discord-faq-bot && cd discord-faq-bot
npm init -y
npm install discord.js dotenv openai

Wait—openai? Yes. OpenRouter exposes an OpenAI-compatible endpoint, so we can use the standard OpenAI client library pointed at OpenRouter’s base URL. This is the cleanest path.

Create .env:

DISCORD_TOKEN=your_discord_bot_token
SUPABASE_URL=https://xyzcompany.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
OPENROUTER_API_KEY=sk-or-v1-...
OPENROUTER_MODEL=google/gemini-flash-1.5
EMBEDDING_MODEL=openai/text-embedding-3-small

Note: EMBEDDING_MODEL is what we’ll use for both ingestion and query embedding. OpenRouter’s free tier supports several embedding models; text-embedding-3-small is cheap and solid. If you prefer all-free, use google/gemini-embedding-001 (check OpenRouter’s model list for current free embedding models).

Create bot.js:

const { Client, GatewayIntentBits, SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const { createClient } = require('@supabase/supabase-js');
const OpenAI = require('openai');
require('dotenv').config();

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

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);

const openai = new OpenAI({
  baseURL: 'https://openrouter.ai/api/v1',
  apiKey: process.env.OPENROUTER_API_KEY,
  defaultHeaders: {
    'HTTP-Referer': 'http://localhost',
    'X-Title': 'Discord FAQ Bot',
  },
});

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

client.login(process.env.DISCORD_TOKEN);

This is the skeleton. We’ll register the slash command and fill in the logic in Step 3.

Step 2: Ingest Docs into Supabase pgvector

We need a Python script (yes, Python—the chunking ecosystem is better here).

pip install supabase markdown langchain-text-splitters openai python-dotenv

Create ingest.py:

import os
import re
from pathlib import Path
from dotenv import load_dotenv
from supabase import create_client
from langchain_text_splitters import RecursiveCharacterTextSplitter
from openai import OpenAI

load_dotenv()

supabase = create_client(os.getenv('SUPABASE_URL'), os.getenv('SUPABASE_SERVICE_ROLE_KEY'))
openai = OpenAI(
    base_url='https://openrouter.ai/api/v1',
    api_key=os.getenv('OPENROUTER_API_KEY'),
)

EMBEDDING_MODEL = os.getenv('EMBEDDING_MODEL', 'openai/text-embedding-3-small')
CHUNK_SIZE = 800
CHUNK_OVERLAP = 100
DOCS_DIR = './docs'

splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " ", ""],
)

def embed_text(text: str) -> list[float]:
    resp = openai.embeddings.create(model=EMBEDDING_MODEL, input=text)
    return resp.data[0].embedding

def ingest_file(filepath: Path):
    with open(filepath, 'r', encoding='utf-8') as f:
        content = f.read()
    chunks = splitter.split_text(content)
    for i, chunk in enumerate(chunks):
        embedding = embed_text(chunk)
        supabase.table('doc_chunks').insert({
            'content': chunk,
            'embedding': embedding,
            'source': str(filepath),
            'chunk_index': i,
        }).execute()
    print(f"Ingested {len(chunks)} chunks from {filepath}")

if __name__ == '__main__':
    for f in Path(DOCS_DIR).rglob('*.md'):
        ingest_file(f)

Before running this, you need the doc_chunks table in Supabase. Go to your Supabase SQL editor and run:

create extension if not exists vector;

create table doc_chunks (
  id bigserial primary key,
  content text not null,
  embedding vector(1536),
  source text not null,
  chunk_index int default 0,
  created_at timestamptz default now()
);

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

If you’re using a 768-dim embedding model (like Gemini’s), change vector(1536) to vector(768). Check your model’s output dimensions.

Drop your Markdown docs into ./docs/ and run:

python ingest.py

You’ll see chunk counts per file. Verify in Supabase’s table editor that rows appear with embeddings populated.

Step 3: Wire Up the /faq Command

Back in bot.js, register the slash command and implement the retrieval logic.

Add this after the ready event:

const commands = [
  new SlashCommandBuilder()
    .setName('faq')
    .setDescription('Ask a question about our docs')
    .addStringOption(option =>
      option.setName('question')
        .setDescription('Your question')
        .setRequired(true)
    )
];

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

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

  try {
    // 1. Embed the question
    const embedResp = await openai.embeddings.create({
      model: process.env.EMBEDDING_MODEL,
      input: question,
    });
    const queryEmbedding = embedResp.data[0].embedding;

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

    if (error || !chunks?.length) {
      await interaction.editReply('No relevant docs found. Try rephrasing your question.');
      return;
    }

    // 3. Synthesize (Step 4)
    const answer = await synthesizeAnswer(question, chunks);
    await interaction.editReply(answer);
  } catch (err) {
    console.error(err);
    await interaction.editReply('Something went wrong. Check the logs.');
  }
});

We need the match_doc_chunks RPC function in Supabase. Run this SQL:

create or replace function match_doc_chunks (
  query_embedding vector(1536),
  match_threshold float,
  match_count int
)
returns table (
  id bigint,
  content text,
  source text,
  similarity float
)
language plpgsql
as $$
begin
  return query
  select
    doc_chunks.id,
    doc_chunks.content,
    doc_chunks.source,
    1 - (doc_chunks.embedding <=> query_embedding) as similarity
  from doc_chunks
  where 1 - (doc_chunks.embedding <=> query_embedding) > match_threshold
  order by doc_chunks.embedding <=> query_embedding
  limit match_count;
end;
$$;

Again, adjust vector(1536) to your model’s dimension.

Step 4: Synthesize with OpenRouter

Add the synthesizeAnswer function to bot.js:

async function synthesizeAnswer(question, chunks) {
  const context = chunks
    .map((c, i) => `[${i + 1}] (${c.source}) ${c.content}`)
    .join('\n\n');

  const systemPrompt = `You are a helpful documentation bot. Answer the user's question using ONLY the provided context chunks. If the context doesn't contain the answer, say "I couldn't find that in the docs." Always cite your sources using the bracketed numbers. Keep answers concise—under 200 words.`;

  const completion = await openai.chat.completions.create({
    model: process.env.OPENROUTER_MODEL,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
    ],
    max_tokens: 400,
    temperature: 0.3,
  });

  let response = completion.choices[0].message.content;

  // Append source list for transparency
  const sources = chunks
    .map((c, i) => `[${i + 1}] ${c.source}`)
    .join('\n');

  return `${response}\n\n**Sources:**\n${sources}`;
}

That’s it. The bot now:

  1. Embeds the question
  2. Retrieves top-5 semantically similar chunks
  3. Feeds them to a free LLM with strict instructions to cite
  4. Returns the answer plus source links

Running the Bot End-to-End

  1. Make sure your .env is populated correctly.
  2. Run the ingestion script first: python ingest.py
  3. Register slash commands (one-time): Add this to the bottom of bot.js before client.login:
const { REST, Routes } = require('discord.js');
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
(async () => {
  try {
    await rest.put(Routes.applicationCommands('YOUR_APPLICATION_ID'), { body: commands });
    console.log('Slash commands registered');
  } catch (err) {
    console.error(err);
  }
})();

Replace YOUR_APPLICATION_ID with your Discord app’s ID (from the Developer Portal).

  1. Start the bot: node bot.js
  2. Invite the bot to your server (OAuth2 URL Generator in Developer Portal → bot + applications.commands scopes).
  3. Type /faq How do I reset my password? and watch it work.

Extensions That Actually Matter

Rate limiting. Wrap the /faq handler in a simple in-memory rate limiter (e.g., 5 requests per user per minute). Free-tier OpenRouter has rate limits; don’t let one user burn your credits. A Map with timestamps is enough.

Hybrid search. Pure semantic search sometimes misses exact keyword matches. Add a tsvector column to doc_chunks and combine BM25 scores with cosine similarity. Supabase supports ts_rank natively.

Re-ingestion on doc changes. Set up a GitHub Action that runs ingest.py whenever your docs repo gets a push. Truncate the table first or upsert by source + chunk_index.

Multi-source support. Your docs might live in Notion, Confluence, or a static site. Write a simple adapter that normalizes everything to Markdown before chunking. Same pipeline, different inputs.

Answer quality feedback. Add 👍/👎 reactions to the bot’s reply. Store feedback in Supabase to tune your chunk size, retrieval threshold, or prompt over time.

If you’ve built similar retrieval pipelines before, you’ll appreciate how the Build a Resume Tailoring Agent That Rewrites Your CV for Any JD Using Gemini's Free Tier uses the same embedding + LLM pattern for a completely different problem. The primitives are identical.

Common Pitfalls

Mismatched embedding dimensions. If your table is vector(1536) but you embed with a 768-dim model, inserts will fail. Check your model’s docs and match the SQL.

OpenRouter credits exhaustion. Free models are free, but OpenRouter still tracks usage. If you get 402 errors, you’ve hit the limit. Add rate limiting, or switch to a different free model.

Chunk boundaries splitting code blocks. The RecursiveCharacterTextSplitter with Markdown-aware separators helps, but code fences sometimes get split. Increase chunk_overlap or pre-process docs to keep code blocks intact.

Discord message length limits. If your context + answer exceeds 2000 characters, Discord will reject the reply. The max_tokens: 400 and under 200 words prompt guard against this, but monitor it.

Slash command not appearing. Make sure you’ve invited the bot with the applications.commands scope and waited up to an hour for Discord to propagate global commands. Use guild-specific commands for instant testing.

For a deeper dive on productionizing LLM pipelines, the Petals: Running Large Language Models at Home with a BitTorrent-Style Network article shows how the same retrieval pattern scales when you’re running models locally instead of via API.

FAQ

Why use OpenRouter instead of calling models directly? One API key, one OpenAI-compatible endpoint, access to dozens of free models. When a model gets rate-limited or deprecated, you swap OPENROUTER_MODEL in .env and nothing else changes. No vendor lock-in.

Can I use this for private docs that shouldn’t leave my infrastructure? This guide sends embeddings and chunks to OpenRouter’s API. If your docs are sensitive, swap OpenRouter for a local Ollama instance. The architecture is the same—just change the base URL. The Build a Smart Clipboard That Summarizes and Translates Anything You Copy with Ollama guide walks through a local-only setup.

How many docs can the free Supabase tier handle? The free tier includes 500 MB of database storage. A 1536-dim embedding is ~6 KB per row. At 800-token chunks (~600 words), you’ll store roughly 80,000 chunks before hitting the limit. That’s a lot of documentation.

What if my docs are in PDFs? Extract text first (PyMuPDF, pdfplumber), then feed the text through the same chunking pipeline. The ingestion script doesn’t care where the text came from.

How do I update the docs without downtime? Re-run ingest.py. It inserts new chunks; old ones remain. For a clean slate, truncate the table first. In production, use an upsert by source + chunk_index to avoid duplicates.

#discord-bot#rag#supabase#community

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