All articles
Build Guides

Build a Discord Community FAQ Bot with RAG on Qdrant Free Tier

FDE Coach EditorialAugust 8, 202611 min read

What We're Building

A Discord bot that sits in your community server and answers questions by actually reading your documentation. No hard-coded responses, no brittle keyword matching. When someone asks a question, the bot embeds the query, fetches semantically relevant chunks from Qdrant, and feeds them as context to a free LLM via OpenRouter. The result is a grounded, citation-ready answer that evolves as you update your knowledge base.

Feature list:

  • Listens for a !faq command or mentions in designated channels
  • Embeds questions using Cloudflare Workers AI (bge-base-en-v1.5)
  • Retrieves top-k chunks from a Qdrant free-tier cluster
  • Generates answers with OpenRouter's free models (Mistral 7B, Gemma 2 9B, or Llama 3.2)
  • Sources citations back to the original documentation
  • Runs entirely on free tiers — zero cost at moderate volume

This pattern isn't just for Discord. Once you see how the pieces fit, you can drop the same retrieval pipeline into Slack bots, internal tools, or customer-facing chat widgets. If you've already built a codebase Q&A tool, you'll recognize the RAG skeleton — we're just swapping the interface layer. For a deeper dive on the retrieval side, check out Build a Codebase Q&A Tool with LlamaIndex and Supabase pgvector.

Architecture and Data Flow

Three independent pieces work together:

  1. Ingestion pipeline — a one-off (or cron) script that chunks your docs, embeds them via the Cloudflare Worker, and upserts into Qdrant.
  2. Cloudflare Worker — a thin HTTP endpoint that accepts text and returns a 768-dimensional embedding. We isolate this so the Discord bot never touches an embedding model directly.
  3. Discord bot — the orchestrator. It receives a message, calls the Worker for the query embedding, hits Qdrant for relevant chunks, builds a prompt, and streams the answer back.

This separation keeps each component replaceable. Want to swap embeddings? Change the Worker. Want a different vector store? Point the bot elsewhere. The interfaces are plain HTTP and gRPC.

Prerequisites and Free-Tier Setup

Everything here stays within free limits for hobby-scale usage. Here's what you need:

ServiceFree Tier LimitSign-Up Link
Cloudflare Workers100k requests/day, 10ms CPU/requestdash.cloudflare.com
Cloudflare Workers AI10k neurons/day (embeddings are cheap)Same account, enable in dashboard
Qdrant Cloud1GB storage, 1 clustercloud.qdrant.io
OpenRouter~200 free requests/day for free modelsopenrouter.ai
Discord ApplicationUnlimited bots, 100 serversdiscord.com/developers
Node.jsLocal runtimev18+

Accounts to create:

  1. Cloudflare account — enable Workers AI from the dashboard under "AI > Workers AI". Note your account ID.
  2. Qdrant Cloud — create a free cluster, copy the URL and API key from the "Cluster" page.
  3. OpenRouter — generate an API key at openrouter.ai/keys.
  4. Discord Developer Portal — create a new application, add a bot user, copy the token and client ID.

Project Scaffold and Dependencies

mkdir discord-faq-bot
cd discord-faq-bot
npm init -y
npm install discord.js @qdrant/js-client-rest openai dotenv
npm install -D wrangler

Project structure:

├── worker/
│   ├── wrangler.toml
│   └── src/
│       └── index.js
├── ingest/
│   └── ingest.js
├── bot/
│   └── index.js
├── data/
│   └── docs/          # your markdown knowledge base
├── .env
└── package.json

Create .env:

DISCORD_TOKEN=your_discord_bot_token
DISCORD_CLIENT_ID=your_client_id
QDRANT_URL=https://your-cluster.qdrant.tech
QDRANT_API_KEY=your_qdrant_api_key
OPENROUTER_API_KEY=your_openrouter_key
CF_WORKER_URL=http://localhost:8787  # change after deploy

Ingesting Your Knowledge Base into Qdrant

Before the bot can answer anything, Qdrant needs your documentation as vectors. The ingestion script reads markdown files, splits them into overlapping chunks, embeds each chunk, and upserts into a collection.

Create ingest/ingest.js:

import { QdrantClient } from '@qdrant/js-client-rest';
import { readFileSync, readdirSync } from 'fs';
import { join, extname } from 'path';
import 'dotenv/config';

const COLLECTION_NAME = 'faq_knowledge';
const CHUNK_SIZE = 500;
const CHUNK_OVERLAP = 50;

const qdrant = new QdrantClient({
  url: process.env.QDRANT_URL,
  apiKey: process.env.QDRANT_API_KEY,
});

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

async function embed(texts) {
  const res = await fetch(`${process.env.CF_WORKER_URL}/embed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ texts }),
  });
  if (!res.ok) throw new Error(`Embed failed: ${res.status}`);
  const data = await res.json();
  return data.embeddings;
}

async function main() {
  // Ensure collection exists
  await qdrant.createCollection(COLLECTION_NAME, {
    vectors: { size: 768, distance: 'Cosine' },
  }).catch(() => {}); // ignore if exists

  const docsDir = join(process.cwd(), 'data', 'docs');
  const files = readdirSync(docsDir).filter(f => extname(f) === '.md');
  
  let pointId = 0;
  for (const file of files) {
    const content = readFileSync(join(docsDir, file), 'utf-8');
    const chunks = chunkText(content);
    const embeddings = await embed(chunks);
    
    const points = chunks.map((chunk, i) => ({
      id: pointId + i,
      vector: embeddings[i],
      payload: { text: chunk, source: file, chunk_index: i },
    }));
    
    await qdrant.upsert(COLLECTION_NAME, { points });
    pointId += chunks.length;
    console.log(`Ingested ${file}: ${chunks.length} chunks`);
  }
  console.log('Ingestion complete.');
}

main();

Drop your markdown files into data/docs/. Run it:

node ingest/ingest.js

The Cloudflare Worker: Embedding Endpoint

This Worker is a minimal proxy to Cloudflare Workers AI. It batches embedding requests and returns vectors. Deploy it once and both ingestion and the bot call it.

Create worker/src/index.js:

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') {
      return new Response('POST only', { status: 405 });
    }
    const { texts } = await request.json();
    if (!Array.isArray(texts) || texts.length === 0) {
      return new Response('Missing texts array', { status: 400 });
    }
    
    const embeddings = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
      text: texts,
    });
    
    return new Response(JSON.stringify({ embeddings: embeddings.data }), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

Create worker/wrangler.toml:

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

[ai]
binding = "AI"

Deploy:

cd worker
npx wrangler deploy
# Note the *.workers.dev URL — update CF_WORKER_URL in .env

The Discord Bot Core: Listen, Retrieve, Answer

This is the orchestrator. It uses Discord.js for the gateway connection, Qdrant JS client for search, and OpenRouter's OpenAI-compatible endpoint for generation.

Create bot/index.js:

import { Client, GatewayIntentBits } from 'discord.js';
import { QdrantClient } from '@qdrant/js-client-rest';
import OpenAI from 'openai';
import 'dotenv/config';

const COLLECTION_NAME = 'faq_knowledge';

const qdrant = new QdrantClient({
  url: process.env.QDRANT_URL,
  apiKey: process.env.QDRANT_API_KEY,
});

const openai = new OpenAI({
  baseURL: 'https://openrouter.ai/api/v1',
  apiKey: process.env.OPENROUTER_API_KEY,
});

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

async function embedQuery(text) {
  const res = await fetch(`${process.env.CF_WORKER_URL}/embed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ texts: [text] }),
  });
  const data = await res.json();
  return data.embeddings[0];
}

async function retrieveContext(query) {
  const vector = await embedQuery(query);
  const results = await qdrant.search(COLLECTION_NAME, {
    vector,
    limit: 5,
    with_payload: true,
  });
  return results.map(r => ({
    text: r.payload.text,
    source: r.payload.source,
    score: r.score,
  }));
}

async function generateAnswer(question, contextChunks) {
  const context = contextChunks
    .map((c, i) => `[${i + 1}] (from ${c.source}): ${c.text}`)
    .join('\n\n');
  
  const systemPrompt = `You are a helpful community FAQ bot. Answer the user's question using ONLY the provided context chunks. If the context doesn't contain the answer, say "I don't have enough information to answer that." Cite sources using the [N] notation.`;
  
  const completion = await openai.chat.completions.create({
    model: 'mistralai/mistral-7b-instruct:free',
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
    ],
    max_tokens: 500,
    temperature: 0.3,
  });
  
  return completion.choices[0].message.content;
}

client.on('messageCreate', async (message) => {
  if (message.author.bot) return;
  if (!message.content.startsWith('!faq')) return;
  
  const question = message.content.replace('!faq', '').trim();
  if (!question) {
    await message.reply('Usage: `!faq your question here`');
    return;
  }
  
  await message.channel.sendTyping();
  
  try {
    const chunks = await retrieveContext(question);
    if (chunks.length === 0) {
      await message.reply('No relevant documentation found.');
      return;
    }
    
    const answer = await generateAnswer(question, chunks);
    const sources = [...new Set(chunks.map(c => c.source))].join(', ');
    await message.reply(`${answer}\n\n📚 Sources: ${sources}`);
  } catch (err) {
    console.error(err);
    await message.reply('Something went wrong. Check the logs.');
  }
});

client.login(process.env.DISCORD_TOKEN);

Running the Bot Locally

  1. Ensure the Cloudflare Worker is running (or deployed and CF_WORKER_URL points to it).
  2. Start the bot:
node bot/index.js
  1. Invite the bot to your test server using the OAuth2 URL generator in Discord Developer Portal (scopes: bot, applications.commands; permissions: Send Messages, Read Message History).
  2. In any channel the bot can see, type !faq How do I reset my password?

The bot will typing-indicate, retrieve chunks, and reply with a sourced answer. First request may be slow as OpenRouter cold-starts the free model (2-5 seconds). Subsequent requests are faster.

Sensible Extensions

Slash commands instead of !faq: Register a /faq slash command with Discord's API for a cleaner UX. Discord.js supports this natively with REST.put().

Channel whitelisting: Only respond in #help or #faq channels. Add a config array and check message.channel.name before processing.

Conversation memory: Store the last N messages per channel and include them in the prompt. This lets users ask follow-ups like "what about billing?" without repeating context.

Re-ingestion webhook: Expose a secured endpoint that triggers ingest.js when your docs repo pushes to main. Combine with GitHub Actions for a full CI/CD pipeline.

Model fallback: If OpenRouter's Mistral is overloaded, fall back to google/gemma-2-9b-it:free or meta-llama/llama-3.2-3b-instruct:free. Wrap the generation call in a try-catch with a model list.

For a different take on retrieval pipelines, Build a Multi-Agent Research Assistant with Groq's Free Mixtral and SerpAPI Fallback shows how to layer multiple retrieval sources when your knowledge base isn't enough.

Common Pitfalls and Debugging

"Embedding dimension mismatch" on upsert: Qdrant collection was created with the wrong vector size. BGE-base-en-v1.5 outputs 768 dimensions. Delete the collection and recreate with size: 768.

OpenRouter returns empty responses: Free models have rate limits and occasionally return null when overloaded. Add a retry with exponential backoff or switch models. Check openrouter.ai/status for model availability.

Cloudflare Worker times out: The free tier has a 10ms CPU limit, but Workers AI calls don't count toward CPU time. If you're seeing timeouts, you're likely on the Workers Free plan without Workers AI enabled. Enable it in the dashboard under AI.

Discord bot doesn't see messages: You're missing the MessageContent privileged intent. Enable it in Discord Developer Portal > Bot > Privileged Gateway Intents.

Qdrant free cluster disappears: Free clusters are deleted after 14 days of inactivity. Hit it at least once every two weeks — a simple health-check ping from the bot on startup is enough.

FAQ

Q: How many documents can I store on Qdrant's free tier? A: 1GB of vector storage. With 768-dim vectors and ~500-byte payloads, that's roughly 200k-300k chunks. More than enough for most community knowledge bases.

Q: Can I use a different embedding model? A: Yes. Swap the model in the Cloudflare Worker to any supported by Workers AI (e.g., @cf/baai/bge-small-en-v1.5 for 384-dim, faster and smaller). Just update the Qdrant collection vector size accordingly.

Q: What if OpenRouter's free models are too slow? A: The free models run on shared infrastructure and can be slow during peak hours. For production communities, consider OpenRouter's paid models ($0.06-$0.20 per 1M tokens) or self-host a quantized model on a cheap VPS. The architecture doesn't change — just swap the model string.

Q: How do I add more documents after initial ingestion? A: Re-run ingest.js. It uses auto-incrementing point IDs, so you'll get duplicates unless you clear the collection first. Better approach: add a source-based delete before upsert, or use deterministic IDs (hash the source + chunk index).

Q: Can this work with PDFs or web pages? A: Absolutely. The ingestion script only cares about text. Add a PDF parser (pdf-parse) or a web scraper (cheerio) before the chunking step. The rest of the pipeline stays identical.

Q: Is this pattern FDE-relevant? A: This is the exact kind of integration work FDEs ship daily — stitching together free APIs, understanding rate limits, and delivering a working prototype that solves a real user need. If you want to sharpen the skills that make you dangerous in customer conversations, check out The Highest-Leverage Skills for an FDE in the AI Era.

#discord#faq#rag#qdrant#cloudflare

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