.png&w=3840&q=100)
I Built a RAG Chatbot for My Portfolio Without a Vector Database (BM25 + Next.js + Vercel AI SDK)
No embeddings. No vector database. About 40 lines of retrieval code. This is how the AI assistant on this site works, including the bugs I found when I tested it properly.
In my post on vectorless RAG I argued that vector embeddings are often the wrong default. This post puts that argument into production. The chat assistant on my portfolio answers questions about my experience, skills and projects, and it retrieves context with BM25 — the same keyword-ranking algorithm behind classic search engines — instead of a vector database.
Here's the full build: how the data is chunked, how BM25 ranks it, how the answer is streamed, how the endpoint is protected, and the retrieval mistakes I only caught by running real questions against it.
Why no vector database?
For this use case, embeddings would add cost and moving parts without improving answers:
- The corpus is tiny. My resume becomes 25 chunks averaging about 50 words each. You can score all of them on every request in well under a millisecond.
- Questions use the same words as the data. People ask about "React", "Next.js", "Startbit" or "blockchain" — exact terms that keyword search handles well and embeddings sometimes blur.
- No extra infrastructure. No embedding API call per query, no index to rebuild when the resume changes, no vector store to pay for or keep in sync.
- It's debuggable. When an answer is wrong, I can print the scores and see exactly why a chunk ranked where it did.
If you have thousands of documents, or users who describe things in very different words from your content, vectors (or a hybrid) start to earn their place. For a portfolio, a docs site of a few hundred pages, or a product FAQ, BM25 is a strong default.
The architecture
The whole thing is one Next.js route handler:
- Chunk the resume data into small labelled passages (once, at module load).
- Retrieve the top 5 chunks for the user's latest message with BM25.
- Generate an answer with the Vercel AI SDK's
streamText, putting the chunks in the system prompt. - Stream the text back to a small React hook that renders it as it arrives.
Step 1: Chunk by meaning, not by character count
Generic RAG tutorials split documents every N characters. My data is already structured — a TypeScript object with profile, skills, experience, projects and education — so each chunk is one meaningful unit: one job, one project, one skill category.
1interface Chunk {
2 text: string
3 section: string
4}
5
6function buildChunks(): Chunk[] {
7 const chunks: Chunk[] = []
8
9 chunks.push({
10 text: `Name: ${resumeData.profile.name}. Title: ${resumeData.profile.title}. Summary: ${resumeData.profile.summary}`,
11 section: 'profile',
12 })
13
14 for (const [category, items] of Object.entries(resumeData.skills)) {
15 chunks.push({ text: `Skills — ${category}: ${(items as string[]).join(', ')}`, section: 'skills' })
16 }
17
18 for (const exp of resumeData.experiences) {
19 chunks.push({
20 text: `Experience: ${exp.role} at ${exp.company} (${exp.dateRange}), ${exp.location}. ${exp.achievements.join(' ')}`,
21 section: 'experience',
22 })
23 }
24
25 for (const proj of resumeData.projects) {
26 chunks.push({
27 text: `Project "${proj.name}": ${proj.description.join(' ')} Stack: ${proj.skills.join(', ')}.`,
28 section: 'project',
29 })
30 }
31
32 return chunks
33}
34
35const CHUNKS = buildChunks() // built once per cold startPrefixing each chunk with its type ("Experience:", "Project …:") does two jobs. It gives the model context about what it's reading, and it gives BM25 a keyword to match when someone asks about "experience" or "projects".
Step 2: BM25 in about 40 lines
BM25 scores a chunk higher when it contains the query's words, especially rare words, and it stops rewarding a word after a few repetitions. It also adjusts for chunk length so long chunks don't win just by being long.
1const K1 = 1.5 // how quickly repeated terms stop adding score
2const B = 0.75 // how strongly to normalise by chunk length
3
4const tokenisedChunks = CHUNKS.map((c) => tokenise(c.text))
5const avgDocLen = tokenisedChunks.reduce((s, t) => s + t.length, 0) / tokenisedChunks.length
6
7// Document frequency: in how many chunks does each term appear?
8const df = new Map<string, number>()
9for (const tokens of tokenisedChunks) {
10 for (const term of new Set(tokens)) df.set(term, (df.get(term) ?? 0) + 1)
11}
12
13function idf(term: string): number {
14 const n = CHUNKS.length
15 const d = df.get(term) ?? 0
16 return Math.log((n - d + 0.5) / (d + 0.5) + 1)
17}
18
19function bm25Score(queryTokens: string[], docTokens: string[]): number {
20 const tf = new Map<string, number>()
21 for (const t of docTokens) tf.set(t, (tf.get(t) ?? 0) + 1)
22
23 let score = 0
24 for (const term of queryTokens) {
25 const f = tf.get(term) ?? 0
26 if (f === 0) continue
27 const lengthNorm = 1 - B + B * (docTokens.length / avgDocLen)
28 score += idf(term) * ((f * (K1 + 1)) / (f + K1 * lengthNorm))
29 }
30 return score
31}
32
33function retrieveTopK(query: string, k = 5): string {
34 const q = tokenise(query)
35 return tokenisedChunks
36 .map((tokens, i) => ({ chunk: CHUNKS[i], score: bm25Score(q, tokens) }))
37 .sort((a, b) => b.score - a.score)
38 .slice(0, k)
39 .filter((r) => r.score > 0)
40 .map((r) => r.chunk.text)
41 .join('\n\n')
42}The IDF term is what makes this work. "Startbit" appears in one chunk, so matching it is worth a lot. "Built" appears in many chunks, so it's worth little. Everything is computed at module load, so a request only does the scoring loop.
Step 3: Stream the answer with the Vercel AI SDK
The route handler retrieves context and hands it to streamText. The model provider is chosen by an environment variable, so I can switch between Claude, Groq and Gemini without code changes.
1import { streamText } from 'ai'
2
3export async function POST(req: Request) {
4 if (!(await rateLimit(req, 'portfolio-chat', 20, 60 * 10))) {
5 return new Response('Too many messages. Please try again in a few minutes.', { status: 429 })
6 }
7
8 const { messages } = (await req.json()) as { messages: ChatMessage[] }
9 const userMessage = String(messages.at(-1)?.content ?? '').slice(0, 2000)
10 if (!userMessage) return new Response('No message provided', { status: 400 })
11
12 const { model, provider } = getProviderConfig()
13 const context = retrieveTopK(userMessage)
14
15 const result = streamText({
16 model,
17 system: `You are a helpful assistant on Amit Verma's portfolio website.
18Answer questions about Amit's background, skills, projects and experience.
19Stick strictly to the provided context. If something is not covered,
20say you don't have that detail and invite the visitor to get in touch.
21
22Retrieved context:
23${context}`,
24 messages: buildModelMessages(messages, provider), // last 12 turns, each capped at 2,000 chars
25 maxOutputTokens: 512,
26 })
27
28 return result.toTextStreamResponse()
29}A few decisions in there are worth explaining:
- Retrieval uses only the latest message. It's simple and it works for most questions. The trade-off is follow-ups: "what stack did it use?" doesn't name the project, so retrieval misses. Rewriting the follow-up into a standalone question first would fix it, at the cost of one extra model call.
- "Stick strictly to the provided context" plus a polite fallback is most of the hallucination protection. A small model with good context and a clear "say you don't know" instruction does better than a big model guessing.
- History and output are capped. Twelve turns, 2,000 characters each, 512 output tokens. Each request stays small and its cost is predictable.
- Provider quirks are real. Groq's and Gemini's OpenAI-compatible endpoints rejected some assistant-role history in my setup, so for those providers I send only the user's turns. Claude gets the full conversation.
Step 4: Rate limiting with Upstash Redis
A public endpoint that calls a paid model needs a limit. I use a fixed window keyed by IP: 20 messages per 10 minutes.
1export async function rateLimit(req: Request, name: string, limit: number, windowSeconds: number) {
2 const client = getRedis()
3 if (!client) return true // no Redis configured: allow
4
5 const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
6 const key = `ratelimit:${name}:${ip}`
7
8 try {
9 const count = await client.incr(key)
10 if (count === 1) await client.expire(key, windowSeconds)
11 return count <= limit
12 } catch {
13 return true // fail open
14 }
15}It fails open: if Redis is down, chats still work. For a portfolio, a short Redis outage shouldn't take the assistant offline. For an expensive or abusable endpoint I'd fail closed instead. A fixed window also allows a burst at the window boundary; a sliding window (@upstash/ratelimit has one) smooths that out if you need it.
What broke when I tested it properly
Unit tests passed. Then I ran real questions against the real data and printed the top results. Three problems showed up immediately.
1. Short words were silently dropped. The tokeniser discarded words of two characters or fewer. That removed "AI", "JS" and "TS" — some of the most important words a visitor types. For "Does Amit know AI and JS?" the only tokens left were does, amit, know, and, so the ranking was driven by filler words.
2. No stopwords. Words like "what", "has", "with" and "does" were scored like real terms. In a 25-chunk corpus they add noise to every query.
3. No normalisation of word forms. "Which companies has he worked at?" matched only one chunk. "Worked" didn't match "work", and "companies" appears in only one place. The model received one job out of four — so it would confidently answer with an incomplete list.
None of these were visible from reading the code. They only appeared by running representative questions and looking at what came back.
The fix: a better tokeniser
All three problems live in one function. The improved version keeps two-letter words, removes stopwords, maps common synonyms and abbreviations, and applies a very light stemmer:
1const STOPWORDS = new Set([
2 'the', 'and', 'for', 'with', 'what', 'which', 'does', 'did', 'has', 'have', 'can',
3 'his', 'him', 'about', 'tell', 'know', 'how', 'who', 'was', 'were', 'are', 'you',
4 'is', 'he', 'do', 'at', 'in', 'of', 'to', 'an', 'on',
5])
6
7const SYNONYMS: Record<string, string> = {
8 js: 'javascript', ts: 'typescript', ai: 'genai', llm: 'genai',
9 frontend: 'front', backend: 'back',
10 companies: 'experience', company: 'experience', worked: 'experience', job: 'experience',
11}
12
13const stem = (w: string) => w.replace(/(ing|ed|es|s)$/, '')
14
15function tokenise(text: string): string[] {
16 return text
17 .toLowerCase()
18 .replace(/front[\s-]end/g, 'front')
19 .replace(/back[\s-]end/g, 'back')
20 .replace(/[^a-z0-9\s]/g, ' ')
21 .split(/\s+/)
22 .filter((w) => w.length > 1 && !STOPWORDS.has(w))
23 .map((w) => SYNONYMS[w] ?? w)
24 .map(stem)
25}Because the same function tokenises both the chunks and the query, "front-end", "front end" and "frontend" all become front, and "JS" in a question matches "JavaScript" in the skills list.
Same questions, same data, before and after:
- "Does Amit know AI and JS?" — before: tokens
does, amit, know, and; top result was the generic profile chunk. After: tokensamit, genai, javascript; the top three are the profile ("GenAI Specialist"), the Generative AI skills chunk and the RAG project, followed by the JavaScript skills. - "Which companies has he worked at?" — before: 1 of 4 jobs retrieved. After: all 4 jobs in the top 5.
- "Can he do frontend work?" — before: the top result was an awards chunk and only one project matched, because "frontend" never matched "front end". After: three front-end projects and an experience chunk make the top five, with the Everledger front-end project first.
- Context size for "What has he built with Next.js?" dropped from about 2,900 to 2,200 characters, because filler-word matches no longer pull in weak chunks.
The synonym map is the part you'll customise most. Build it from real questions: log anonymised queries that return weak results, and add the words people actually use.
Is this "real" RAG?
Yes. RAG means retrieving relevant context and generating an answer grounded in it; nothing in the definition requires embeddings. BM25 is a retriever. What matters is whether the right chunks reach the model — and with a small, structured corpus, a tuned keyword retriever gets them there reliably, with nothing extra to host.
When would I switch? If the content grows into thousands of passages, if users ask conceptual questions in words that never appear in the text ("is he good with distributed systems?"), or if I add multiple languages. At that point I'd run BM25 and embeddings side by side and merge the results with reciprocal rank fusion — the hybrid approach I described in the vectorless RAG post.
Takeaways
- For small, structured content, BM25 is a strong, cheap, debuggable retriever.
- Chunk by meaning, and label each chunk with its type.
- The tokeniser is where retrieval quality lives. Stopwords, short tokens and synonyms matter more than the scoring formula.
- Test retrieval separately from generation. Print the top chunks for 20 real questions before you judge the model's answers.
- Protect the endpoint with a rate limit and capped history before you share the link.
Try the assistant on my homepage — and if it gets something wrong, now you know exactly where I'll look first.
Related posts
Share this article
Found it helpful? Share it with your network.
Related Posts
.png&w=3840&q=100)
Vectorless RAG: Why It Exists and What's Wrong with Vector Embeddings
Vector RAG has real flaws — bad chunking, lost structure, weak exact matches. Learn why vectorless RAG exists and when to use it, with TypeScript.

LangChain for JS Developers: Models, Prompts, Memory & Chains
A practical guide to LangChain for JavaScript and TypeScript developers. Learn Models, Prompt Templates, Output Parsers, Memory, and Chains — with real code examples you can use today.

Build an MCP Server in TypeScript and Use It in Cursor: A Hands-On Guide
Step-by-step guide to building a Model Context Protocol (MCP) server in TypeScript: tools, resources, prompts, testing with MCP Inspector, and connecting it to Cursor — plus the GitHub and Playwright MCP servers.