Build a Discord FAQ Bot with n8n, Supabase pgvector, and Gemini
What We're Building
A Discord bot that acts as a first-line support engineer for your community. Members ask questions in a designated channel, the bot pings an n8n webhook, which retrieves the most relevant chunks from your documentation stored in Supabase's pgvector, feeds them to Google Gemini, and returns a concise, cited answer—all on free-tier infrastructure.
Feature list:
- Slash-command or mention-triggered Q&A in Discord
- Hybrid search: semantic vector search over your docs with optional keyword fallback
- Source citations appended to every answer
- Rate limiting and channel gating to prevent abuse
- Full audit trail of questions and answers in Supabase
Architecture Overview
The flow is linear but asynchronous. The Discord bot never waits for n8n to finish—it acknowledges the question immediately, then edits the reply once the webhook responds. n8n orchestrates retrieval and generation, keeping your bot code thin and your logic visually debuggable.
Prerequisites
Everything here runs on free tiers. You'll need:
- Discord Application + Bot Token – Discord Developer Portal. Free. Create an application, add a bot, grab the token.
- Supabase Project – supabase.com. Free tier includes 500 MB database and pgvector. Create a project, note the
Project URLandservice_rolekey. - Google Gemini API Key – aistudio.google.com. Free tier: 1,500 requests/day with Gemini 1.5 Flash. Generate an API key.
- n8n – Self-host via n8n.cloud free tier (limited) or run locally with Docker. We'll use the Docker approach for full control:
docker run -d --name n8n -p 5678:5678 \
-v n8n_data:/home/node/.n8n \
-e N8N_SECURE_COOKIE=false \
n8nio/n8n
- Node.js 20+ – For the Discord bot. nodejs.org.
Step 1: Supabase Vector Store Setup
Enable pgvector in your Supabase project. Go to the SQL Editor and run:
create extension if not exists vector with schema extensions;
create table docs (
id bigserial primary key,
content text not null,
metadata jsonb default '{}'::jsonb,
embedding vector(768)
);
create or replace function match_docs (
query_embedding vector(768),
match_count int default 5,
filter jsonb default '{}'::jsonb
) returns table (
id bigint,
content text,
metadata jsonb,
similarity float
) language plpgsql as $$
begin
return query
select
docs.id,
docs.content,
docs.metadata,
1 - (docs.embedding <=> query_embedding) as similarity
from docs
where metadata @> filter
order by docs.embedding <=> query_embedding
limit match_count;
end;
$$;
This gives you a docs table and a match_docs function for cosine similarity search. The embedding dimension (768) matches Google's text-embedding-004 model.
Step 2: Ingest Documentation into pgvector
You need embeddings for every chunk of your docs. We'll use a Node.js script that reads markdown files, chunks them, generates embeddings via Gemini, and upserts into Supabase.
mkdir doc-ingest && cd doc-ingest
npm init -y
npm install @supabase/supabase-js @google/generative-ai markdown-it
Create ingest.js:
import { createClient } from '@supabase/supabase-js';
import { GoogleGenerativeAI } from '@google/generative-ai';
import fs from 'node:fs';
import path from 'node:path';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const embedModel = genAI.getGenerativeModel({ model: 'text-embedding-004' });
function chunkMarkdown(md, maxLen = 1000) {
const sections = md.split(/##\s/);
const chunks = [];
for (const section of sections) {
if (section.length < 50) continue;
let remaining = `## ${section}`;
while (remaining.length > maxLen) {
const splitAt = remaining.lastIndexOf('.', maxLen);
const cut = splitAt > 0 ? splitAt : maxLen;
chunks.push(remaining.slice(0, cut + 1));
remaining = remaining.slice(cut + 1);
}
if (remaining.length > 0) chunks.push(remaining);
}
return chunks;
}
async function embedAndStore(chunks) {
for (const chunk of chunks) {
const result = await embedModel.embedContent(chunk);
const embedding = result.embedding.values;
await supabase.from('docs').insert({
content: chunk,
embedding,
metadata: { source: 'docs', ingested_at: new Date().toISOString() }
});
}
console.log(`Stored ${chunks.length} chunks`);
}
const docsDir = process.argv[2] || './docs';
const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
let allChunks = [];
for (const file of files) {
const md = fs.readFileSync(path.join(docsDir, file), 'utf-8');
allChunks.push(...chunkMarkdown(md));
}
await embedAndStore(allChunks);
Run it:
SUPABASE_URL=https://xxx.supabase.co \
SUPABASE_SERVICE_KEY=eyJ... \
GEMINI_API_KEY=AIza... \
node ingest.js ./my-docs
This chunks your markdown by ## headings, embeds each chunk, and stores them. The Gemini embedding API is fast and free for the first 1,500 requests/day.
Step 3: n8n Webhook and Workflow
Open n8n at http://localhost:5678. Create a new workflow.
Node 1: Webhook
- Add a Webhook node. Set HTTP Method to POST, Path to
/discord-query, Response Mode to "Last Node". - This is the endpoint your Discord bot will call.
Node 2: HTTP Request (Embedding)
- Add an HTTP Request node. Method POST.
- URL:
https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key={{$env.GEMINI_API_KEY}} - Body (JSON):
{
"model": "models/text-embedding-004",
"content": { "parts": [{ "text": "{{$json.body.question}}" }] }
}
- This converts the user's question into an embedding.
Node 3: Supabase (Vector Search)
- Add a Supabase node (install the node from n8n's community nodes if missing:
n8n-nodes-supabase). - Operation: "Execute Query".
- Query:
SELECT * FROM match_docs('{{$json.embedding.values}}'::vector, 5);
- This returns the top 5 most relevant doc chunks.
Node 4: HTTP Request (Gemini Chat)
- Another HTTP Request node. Method POST.
- URL:
https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={{$env.GEMINI_API_KEY}} - Body (JSON, using an expression):
{
"contents": [{
"parts": [{
"text": "You are a helpful support bot. Answer the user's question using ONLY the context below. If the context doesn't contain the answer, say you don't know. Always cite sources by their metadata.\n\nContext:\n{{$node['Supabase'].json.map(doc => doc.content).join('\\n---\\n')}}\n\nQuestion: {{$json.body.question}}"
}]
}]
}
Node 5: Respond to Webhook
- Add a Respond to Webhook node.
- Body:
{
"answer": "{{$node['Gemini Chat'].json.candidates[0].content.parts[0].text}}",
"sources": {{JSON.stringify($node['Supabase'].json.map(doc => ({content: doc.content.slice(0,200), similarity: doc.similarity})))} }
}
Save and activate the workflow. n8n gives you a production webhook URL (or use http://localhost:5678/webhook/discord-query for local testing with ngrok).
If you're running n8n locally behind a firewall, expose it with ngrok:
ngrok http 5678
Copy the ngrok URL—your Discord bot will POST to https://abc123.ngrok.io/webhook/discord-query.
Step 4: Discord Bot with Discord.js
Create a new directory and initialize:
mkdir discord-bot && cd discord-bot
npm init -y
npm install discord.js axios
Create bot.js:
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from 'discord.js';
import axios from 'axios';
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent]
});
const N8N_WEBHOOK = process.env.N8N_WEBHOOK_URL;
const ALLOWED_CHANNEL = process.env.ALLOWED_CHANNEL_ID;
client.once('ready', async () => {
console.log(`Logged in as ${client.user.tag}`);
const commands = [
new SlashCommandBuilder()
.setName('ask')
.setDescription('Ask a question from the docs')
.addStringOption(opt =>
opt.setName('question').setDescription('Your question').setRequired(true)
)
];
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
await rest.put(Routes.applicationCommands(client.user.id), { body: commands });
console.log('Slash commands registered');
});
client.on('interactionCreate', async interaction => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'ask') return;
if (ALLOWED_CHANNEL && interaction.channelId !== ALLOWED_CHANNEL) {
return interaction.reply({ content: 'Please use the designated Q&A channel.', ephemeral: true });
}
const question = interaction.options.getString('question');
await interaction.deferReply();
try {
const { data } = await axios.post(N8N_WEBHOOK, { question, userId: interaction.user.id }, { timeout: 30000 });
const answer = data.answer || 'Sorry, I could not find an answer.';
const sources = data.sources?.map(s => `> ${s.content}... (${Math.round(s.similarity * 100)}% match)`).join('\n') || '';
await interaction.editReply({
content: `${answer}\n\n**Sources:**\n${sources}`
});
} catch (err) {
console.error(err);
await interaction.editReply('Something went wrong. Try again later.');
}
});
client.login(process.env.DISCORD_TOKEN);
Run it:
DISCORD_TOKEN=your_bot_token \
N8N_WEBHOOK=https://abc123.ngrok.io/webhook/discord-query \
ALLOWED_CHANNEL_ID=123456789 \
node bot.js
Invite the bot to your server with the bot and applications.commands scopes. Use /ask in the designated channel.
Step 5: Testing the Full Loop
- Send a test question in Discord:
/ask How do I reset my password? - The bot acknowledges with "Thinking..." (deferred reply).
- n8n receives the POST, generates an embedding, queries Supabase, sends context to Gemini.
- Gemini returns an answer (or "I don't know" if docs don't cover it).
- The bot edits the reply with the answer and source snippets.
Monitor n8n's execution log for debugging. Each node shows input/output—invaluable when a step fails silently.
Extensions and Production Hardening
Once the basic loop works, harden it:
- Rate limiting in n8n: Add a Code node that checks a Supabase table for recent requests per user. Reject if >5 questions/minute.
- Hybrid search: In the Supabase node, add a fallback
websearch_to_tsqueryoncontentwhen vector similarity is below 0.7. - Caching: Store frequent Q&A pairs in Supabase. Before generating embeddings, check a
cachetable with an exact-match or fuzzy-match on the question. - Logging: Insert every Q&A into a
qa_logtable for later fine-tuning data. - Multi-source: Extend the
metadataJSONB to includedoc_titleandsection. The bot can cite "From Authentication docs, Password Reset section". - Feedback loop: Add reaction collectors in Discord (👍/👎). Store feedback to improve chunking or prompt.
For a deeper dive on shipping LLM features in production environments—especially when customers have strict compliance requirements—see our case study on deploying LLM features at risk-averse enterprises.
Common Pitfalls
- Embedding dimension mismatch: Gemini
text-embedding-004outputs 768-dimensional vectors. If you change models, update thevector(768)type and thematch_docssignature. - n8n timeout: The default webhook timeout is 30 seconds. Gemini can take 3-5 seconds; Supabase is fast. If your docs are huge, increase the timeout in n8n's webhook node settings.
- ngrok rate limits: Free ngrok has connection limits. For anything beyond testing, deploy n8n on a $5 VPS with a real domain and nginx reverse proxy.
- Discord message length: Answers + sources can exceed Discord's 2000-character limit. Split into multiple messages or truncate source snippets aggressively.
- Prompt injection: Users can try "Ignore previous instructions..." in their questions. Your prompt instructs Gemini to use ONLY the provided context, but consider adding a pre-filter in n8n that rejects questions containing obvious injection patterns.
If you enjoy wiring autonomous agents to structured data, you'll appreciate our guide on building a SQL analyst agent that answers natural-language questions over Postgres with Gemini.
FAQ
Q: Why n8n instead of coding the whole pipeline in the Discord bot? A: Separation of concerns. n8n gives you a visual debugger, retry logic, and webhook management out of the box. When your retrieval logic changes (e.g., adding a re-ranker), you don't redeploy the bot.
Q: Can I use OpenAI instead of Gemini?
A: Yes. Swap the HTTP Request nodes to OpenAI's /v1/embeddings and /v1/chat/completions. The free tier is more limited, but the pattern is identical.
Q: How do I update the docs without re-ingesting everything?
A: Add a doc_id or file hash to metadata. On re-ingest, delete rows with that doc_id and insert fresh chunks. A simple cron job or GitHub Action can trigger this.
Q: What if my docs are 10,000+ pages?
A: Chunking and retrieval still work, but you'll want to add a keyword index (GIN on content with tsvector) and use hybrid search. Also consider a re-ranking step: retrieve top 20 with vector search, then re-rank the top 5 with a cross-encoder.
Q: How do I debug when the bot gives wrong answers? A: Open n8n's execution history. Inspect the output of the Supabase node—are the retrieved chunks relevant? If not, your chunking or embedding strategy needs tuning. If the chunks are relevant but the answer is wrong, tweak the Gemini prompt to be more forceful about using only context.
For more patterns on shipping prototypes fast and debugging without direct environment access, check out the FDE weekly workflow from messy problem to shipped prototype.
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