+1 (726) 227-3745

Add RAG Search to a MEAN App with MongoDB Atlas Vector Search

Every client conversation we have had this year contains some version of the same request: "can you add AI search to our app?" What they usually mean is retrieval-augmented generation (RAG) — a search box that answers questions in prose, grounded in the company's own documents, instead of returning ten blue links.

The good news for MEAN teams is that you do not need a separate vector database. MongoDB Atlas has native vector search, so embeddings live in the same collections and the same Mongoose models as the rest of your data, and retrieval is just another aggregation stage. This tutorial adds a working RAG endpoint to an Express 5 API and a streaming answer panel to an Angular client.

What we are building

A /api/ask endpoint that:

  1. embeds the user's question,
  2. runs $vectorSearch against a documents collection in MongoDB Atlas,
  3. sends the top matching chunks to an LLM as context,
  4. streams the answer back to Angular token by token, with citations.

You need an Atlas cluster (M0 free tier is enough to follow along; vector search is available on shared tiers) and an API key for whichever embedding and chat provider you use. The code below uses OpenAI-shaped calls because they are the most familiar; the shape is the same for Voyage, Cohere, or a self-hosted model.

Step 1: model your chunks

The single biggest determinant of RAG quality is not the model — it is chunking. Store one document per chunk, not per file, and keep the parent reference so you can cite the source.

import { Schema, model } from 'mongoose';

const chunkSchema = new Schema({
  sourceId:   { type: Schema.Types.ObjectId, ref: 'Source', required: true, index: true },
  sourceTitle:{ type: String, required: true },
  url:        { type: String },
  text:       { type: String, required: true },
  tokens:     { type: Number },
  embedding:  { type: [Number], required: true, select: false },
  tenantId:   { type: String, required: true, index: true },
}, { timestamps: true });

export const Chunk = model('Chunk', chunkSchema);

Two details that matter in production:

  • select: false on embedding. A 1536-float array is roughly 12 KB of JSON. If you forget this, every ordinary find() drags megabytes over the wire and your API latency doubles for no reason.
  • tenantId as a real field, not an implicit filter. Vector search happily returns a competitor's documents if you forget to filter, and unlike a normal query you will not notice, because the results still look plausible.

Aim for chunks of roughly 300–800 tokens with 10–15% overlap. Split on structural boundaries (headings, paragraphs) before you fall back to fixed-size windows.

Step 2: create the Atlas Vector Search index

Vector search needs its own index definition, separate from ordinary MongoDB indexes. Create it in the Atlas UI (Atlas Search → Create Index → JSON editor) or with the driver. The JSON:

{
  "fields": [
    { "type": "vector", "path": "embedding", "numDimensions": 1536, "similarity": "cosine" },
    { "type": "filter", "path": "tenantId" },
    { "type": "filter", "path": "sourceId" }
  ]
}

Name it chunk_vector_index. Every field you intend to filter on inside $vectorSearch must be declared as a filter field here — this is the mistake that produces "path is not indexed as filter" errors later.

numDimensions must match your embedding model exactly (1536 for text-embedding-3-small, 3072 for text-embedding-3-large, 1024 for voyage-3). Changing models later means a full re-embed, so decide deliberately. Smaller vectors are cheaper to store and faster to search; for most business-document use cases the small model is indistinguishable in quality.

Step 3: the ingestion job

Embedding is the slow, expensive part. Batch it, make it resumable, and never run it inside a request handler.

import { Chunk } from './models.js';

const EMBED_URL = 'https://api.openai.com/v1/embeddings';

export async function embedBatch(texts) {
  const res = await fetch(EMBED_URL, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({ model: 'text-embedding-3-small', input: texts }),
  });
  if (!res.ok) throw new Error(`embedding failed: ${res.status} ${await res.text()}`);
  const json = await res.json();
  return json.data.map((d) => d.embedding);
}

export async function ingest(chunks, { batchSize = 96 } = {}) {
  for (let i = 0; i < chunks.length; i += batchSize) {
    const slice = chunks.slice(i, i + batchSize);
    const vectors = await embedBatch(slice.map((c) => c.text));
    await Chunk.bulkWrite(
      slice.map((c, n) => ({
        updateOne: {
          filter: { sourceId: c.sourceId, text: c.text },
          update: { $set: { ...c, embedding: vectors[n] } },
          upsert: true,
        },
      })),
    );
    console.log(`embedded ${Math.min(i + batchSize, chunks.length)}/${chunks.length}`);
  }
}

Run it from a script or a worker (BullMQ, Agenda, or a plain cron container). The upsert on { sourceId, text } makes re-runs idempotent, so a crashed job can simply be restarted.

Step 4: retrieval with $vectorSearch

export async function retrieve({ question, tenantId, k = 6 }) {
  const [queryVector] = await embedBatch([question]);

  return Chunk.aggregate([
    {
      $vectorSearch: {
        index: 'chunk_vector_index',
        path: 'embedding',
        queryVector,
        numCandidates: k * 20,
        limit: k,
        filter: { tenantId },
      },
    },
    {
      $project: {
        text: 1,
        url: 1,
        sourceTitle: 1,
        score: { $meta: 'vectorSearchScore' },
      },
    },
  ]);
}

numCandidates is the knob people get wrong. It controls how many nodes the approximate-nearest-neighbour search visits before returning the top limit. Too low and recall collapses; too high and latency climbs. Start at 10–20× your limit and tune with real queries. Also note that $vectorSearch must be the first stage in the pipeline — no $match before it. Filtering happens inside the stage.

Drop results below a score threshold (cosine scores under about 0.7 are usually noise) so that "we don't know" is a possible answer:

const hits = (await retrieve({ question, tenantId })).filter((h) => h.score >= 0.7);

Hybrid search, when pure vectors disappoint

Vector search is weak on exact tokens: part numbers, error codes, surnames. If your corpus is full of them, run an Atlas Search (BM25) query alongside the vector query and fuse the rankings:

function reciprocalRankFusion(lists, k = 60) {
  const scores = new Map();
  for (const list of lists) {
    list.forEach((doc, rank) => {
      const id = String(doc._id);
      const entry = scores.get(id) ?? { doc, score: 0 };
      entry.score += 1 / (k + rank + 1);
      scores.set(id, entry);
    });
  }
  return [...scores.values()].sort((a, b) => b.score - a.score).map((e) => e.doc);
}

Atlas also supports $rankFusion natively in MongoDB 8.1+, which does this server-side; the JavaScript version above works on any version and is easy to reason about.

Step 5: the streaming Express 5 endpoint

Do not make users watch a spinner for eight seconds. Stream.

import express from 'express';
import { z } from 'zod';
import { retrieve } from './retrieve.js';

export const router = express.Router();
const AskBody = z.object({ question: z.string().min(3).max(500) });

router.post('/ask', async (req, res) => {
  const { question } = AskBody.parse(req.body);
  const hits = (await retrieve({ question, tenantId: req.user.tenantId })).filter((h) => h.score >= 0.7);

  res.setHeader('content-type', 'text/event-stream');
  res.setHeader('cache-control', 'no-cache, no-transform');
  res.setHeader('connection', 'keep-alive');
  res.flushHeaders();

  const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

  if (hits.length === 0) {
    send('token', { text: "I couldn't find anything about that in your documents." });
    send('done', { citations: [] });
    return res.end();
  }

  send('citations', hits.map((h) => ({ title: h.sourceTitle, url: h.url })));

  const context = hits
    .map((h, i) => `[${i + 1}] ${h.sourceTitle}\n${h.text}`)
    .join('\n\n---\n\n');

  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4.1-mini',
      stream: true,
      messages: [
        {
          role: 'system',
          content:
            'Answer only from the numbered context. Cite sources as [1], [2]. If the context does not contain the answer, say so plainly.',
        },
        { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
      ],
    }),
  });

  const reader = upstream.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  req.on('close', () => reader.cancel().catch(() => {}));

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';
    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const payload = line.slice(6).trim();
      if (payload === '[DONE]') continue;
      const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
      if (delta) send('token', { text: delta });
    }
  }

  send('done', {});
  res.end();
});

The req.on('close') handler matters more than it looks: without it, a user who navigates away leaves you paying for tokens nobody will read. Also set cache-control: no-transform and, if you sit behind Nginx, X-Accel-Buffering: no, or your carefully streamed tokens will arrive in one lump.

Step 6: consuming the stream in Angular

EventSource cannot issue a POST with an auth header, so use fetch and a signal.

import { Injectable, inject, signal } from '@angular/core';
import { Auth } from './auth';

@Injectable({ providedIn: 'root' })
export class AskService {
  private auth = inject(Auth);
  readonly answer = signal('');
  readonly citations = signal<{ title: string; url: string }[]>([]);
  readonly busy = signal(false);

  async ask(question: string) {
    this.answer.set('');
    this.citations.set([]);
    this.busy.set(true);

    const res = await fetch('/api/ask', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${this.auth.token()}`,
      },
      body: JSON.stringify({ question }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const frames = buffer.split('\n\n');
      buffer = frames.pop() ?? '';
      for (const frame of frames) {
        const event = /^event: (.+)$/m.exec(frame)?.[1];
        const data = JSON.parse(/^data: (.+)$/m.exec(frame)?.[1] ?? '{}');
        if (event === 'token') this.answer.update((a) => a + data.text);
        if (event === 'citations') this.citations.set(data);
      }
    }

    this.busy.set(false);
  }
}

And the component, using Angular's control-flow syntax:

@Component({
  selector: 'app-ask',
  template: `
    <form (submit)="submit($event)">
      <input name="q" placeholder="Ask about your documents…" [disabled]="ask.busy()" />
      <button type="submit" [disabled]="ask.busy()">Ask</button>
    </form>

    @if (ask.answer()) {
      <article>{{ ask.answer() }}</article>
    }
    @if (ask.citations().length) {
      <ol>
        @for (c of ask.citations(); track c.url) {
          <li><a [href]="c.url">{{ c.title }}</a></li>
        }
      </ol>
    }
  `,
})
export class AskPanel {
  ask = inject(AskService);

  submit(event: Event) {
    event.preventDefault();
    const form = event.target as HTMLFormElement;
    const q = new FormData(form).get('q');
    if (q) this.ask.ask(String(q));
  }
}

Because answer is a signal, the UI repaints on every token with no ChangeDetectorRef and no zone.js — this works unchanged in a zoneless Angular app.

What breaks in production

Things we have had to fix on real engagements:

  • Cost drift from re-embedding. Someone reruns ingestion nightly over the whole corpus. Hash each chunk's text and skip unchanged hashes; embedding bills are a per-token line item.
  • Stale answers. Embeddings do not update when the source does. Add a change stream or a post-save hook that re-embeds only the touched chunks.
  • Tenant leakage. Covered above, but worth repeating: put the tenant filter in the $vectorSearch stage and write an integration test that asserts tenant B never sees tenant A's chunks.
  • Prompt injection through your own documents. If users can upload files, an uploaded document can contain "ignore previous instructions". Keep retrieved text clearly delimited, never let the model call tools with the user's privileges, and treat model output as untrusted input when you render it.
  • No evaluation. Build a set of 30–50 real questions with known-good answers before you ship, and re-run it whenever you change the chunker, the model, or numCandidates. Without it, every tuning change is a guess.
  • Index memory. Vector indexes live in memory on Atlas Search nodes. Roughly, numDimensions × 4 bytes × chunk count plus overhead; a million 1536-dim chunks is about 6 GB. Quantization (scalar or binary in the index definition) cuts that dramatically with a small recall cost.

Where this fits

The interesting part of this build is not the LLM call — it is the data engineering around it: chunking, tenancy, freshness, and cost control. That work sits squarely inside a normal MEAN application, which is why teams with an existing Express and MongoDB codebase get to a useful RAG feature far faster than teams who think they need a new stack.

If you are adding retrieval or AI features to an existing MEAN application and want a second set of hands, that is what our MEAN Stack API development and MEAN Stack consulting teams do. Get in touch with a description of your corpus and where the search box needs to live, and we will tell you what the honest scope looks like.