For this lab, we will build a small Express.js web application using MVC. This includes:
Retrieval-Augmented Generation (RAG) is an AI technique that enhances Large Language Models (LLMs) by connecting them to external, authoritative data sources to provide more accurate, up-to-date, and specific answers, rather than relying solely on their static training data.
For this lab, you will use Ollama installed directly on the Desktop.
1
2
3
ollama serve
ollama pull llama3.1:8b
ollama ls
First, we will start with creating the main folder for the lab application.
1
2
3
cd /apps
mkdir mini-rag
cd mini-rag
Second, we initialize the Node project inside the directory structure.
1
npm init -y
Next, we create the remaining directory structures
1
mkdir config controllers models routes services views public
1
2
npm install express mongoose ejs dotenv
npm install --save-dev nodemon
Add the following content to package.json:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "mini-rag",
"version": "1.0.0",
"description": "Simple MVC RAG with MongoDB, Mongoose, and Ollama",
"main": "app.js",
"scripts": {
"dev": "nodemon app.js",
"start": "node app.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.19.2",
"mongoose": "^8.6.1"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
.env with the following contents:
1
2
3
4
5
PORT=3000
MONGODB_URI=mongodb://webdb:27017/mini_rag
OLLAMA_URL=http://host.docker.internal:11434
OLLAMA_MODEL=llama3.1:8b
TOP_K=4
.env is a convenient way to separate configuration from deployment. In this case, these are environment parameters that will be used inside the NodeJS app later on.OLLAMA_URL is a way to allow containers inside Docker to communicate with the Ollama service on host.config/db.js with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"use strict";
const mongoose = require("mongoose");
module.exports = async function connectDb() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error("Missing MONGODB_URI in environment.");
process.exit(1);
}
try {
await mongoose.connect(uri);
console.log("Connected to MongoDB");
} catch (err) {
console.error("MongoDB connection failed:", err.message);
process.exit(1);
}
};
This code will do the followings:
process.env.MONGODB_URI MONGODB_URI specified in .env We will create a document model with the following fields:
title (String, required)content (String, required)tags (array of Strings)timestamps enabledWe will create the following contents in models/Document.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
"use strict";
const mongoose = require("mongoose");
const DocumentSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true, maxlength: 120 },
content: { type: String, required: true, trim: true, maxlength: 20000 },
tags: [{ type: String, trim: true }]
},
{ timestamps: true }
);
// Simple retrieval: Mongo text search
DocumentSchema.index({ title: "text", content: "text" });
module.exports = mongoose.model("Document", DocumentSchema);
Create controllers/docController.js with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
"use strict";
const Document = require("../models/Document");
exports.index = async (req, res, next) => {
try {
const docs = await Document.find().sort({ createdAt: -1 }).limit(50);
res.render("docs", { title: "Docs", docs, error: null });
} catch (err) {
next(err);
}
};
exports.create = async (req, res, next) => {
try {
const title = (req.body.title || "").trim();
const content = (req.body.content || "").trim();
const tagsRaw = (req.body.tags || "").trim();
if (!title || !content) {
const docs = await Document.find().sort({ createdAt: -1 }).limit(50);
return res.status(400).render("docs", {
title: "Docs",
docs,
error: "Title and content are required."
});
}
const tags = tagsRaw
? tagsRaw.split(",").map(t => t.trim()).filter(Boolean).slice(0, 10)
: [];
await Document.create({ title, content, tags });
res.redirect("/docs");
} catch (err) {
next(err);
}
};
Create controllers/ragController.js with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
"use strict";
const Document = require("../models/Document");
const { generateWithOllama } = require("../services/ollamaService");
function buildPrompt({ question, docs }) {
// Keep it strict. Less “creative writing,” more “grounded answer.”
const system = [
"You are a helpful assistant.",
"Use ONLY the provided CONTEXT to answer.",
"If the answer is not in the context, say: “I don’t know from the provided notes.”",
"When you use a note, cite it like [Doc:TITLE]."
].join(" ");
const context = docs.length
? docs
.map((d, idx) => {
// Trim context to avoid huge prompts
const snippet = d.content.length > 1200 ? d.content.slice(0, 1200) + "..." : d.content;
return `Doc ${idx + 1}\nTITLE: ${d.title}\nCONTENT:\n${snippet}\n`;
})
.join("\n")
: "No notes found.";
return `${system}\n\nCONTEXT:\n${context}\n\nQUESTION:\n${question}\n\nANSWER:`;
}
exports.askPage = (req, res) => {
res.render("index", {
title: "MiniRAG",
question: "",
answer: null,
citations: [],
error: null
});
};
exports.answer = async (req, res, next) => {
try {
const question = (req.body.question || "").trim();
const topK = Math.max(1, Math.min(parseInt(process.env.TOP_K || "4", 10), 8));
if (!question) {
return res.status(400).render("index", {
title: "MiniRAG",
question: "",
answer: null,
citations: [],
error: "Please enter a question."
});
}
// Retrieval: MongoDB text search
// If empty DB or no text index, this will simply return empty results.
const docs = await Document.find(
{ $text: { $search: question } },
{ score: { $meta: "textScore" }, title: 1, content: 1 }
)
.sort({ score: { $meta: "textScore" } })
.limit(topK);
const prompt = buildPrompt({ question, docs });
let answerText;
try {
answerText = await generateWithOllama({ prompt });
} catch (ollamaErr) {
// Make this a teaching moment: graceful degradation.
return res.status(502).render("index", {
title: "MiniRAG",
question,
answer: null,
citations: docs.map(d => d.title),
error:
"Could not reach Ollama. Is it running? (Try: `ollama serve`) " +
`Details: ${ollamaErr.message}`
});
}
res.render("index", {
title: "MiniRAG",
question,
answer: answerText,
citations: docs.map(d => d.title),
error: null
});
} catch (err) {
next(err);
}
};
Create services/ollamaService.js with the following contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"use strict";
/**
* Calls Ollama's /api/generate endpoint.
* Uses fetch (Node 18+ has global fetch).
*/
async function generateWithOllama({ prompt }) {
const baseUrl = process.env.OLLAMA_URL || "http://localhost:11434";
const model = process.env.OLLAMA_MODEL || "llama3.1:8b";
const res = await fetch(`${baseUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
prompt,
stream: false
})
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Ollama error (${res.status}): ${text}`);
}
const data = await res.json();
return data.response || "";
}
module.exports = { generateWithOllama };
Create routes/docRoutes.js with the following contents:
1
2
3
4
5
6
7
8
9
10
"use strict";
const express = require("express");
const router = express.Router();
const docController = require("../controllers/docController");
router.get("/", docController.index);
router.post("/", docController.create);
module.exports = router;
Create routes/ragRoutes.js with the following contents:
1
2
3
4
5
6
7
8
9
10
"use strict";
const express = require("express");
const router = express.Router();
const ragController = require("../controllers/ragController");
router.get("/", ragController.askPage);
router.post("/ask", ragController.answer);
module.exports = router;
Create the following view EJS files inside views directory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title><%= title %></title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<a href="/">MiniRAG</a>
<span class="sep">|</span>
<a href="/docs">Docs</a>
</div>
</header>
<main class="container">
<%- body %>
</main>
<footer class="footer">
<small>Lab RAG</small>
</footer>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<%
const body = `
<h1>MiniRAG</h1>
<p class="muted">Ask a question. The server retrieves matching notes and asks the local model to answer using only that context.</p>
${error ? `<div class="alert">${error}</div>` : ""}
<form method="POST" action="/ask" class="card">
<label>Question</label>
<input name="question" value="${(question || "").replace(/"/g, """)}" placeholder="e.g., What is Mongoose used for?" />
<button type="submit">Ask</button>
</form>
${answer ? `
<section class="card">
<h2>Answer</h2>
<pre class="answer">${answer}</pre>
<h3>Citations (retrieved notes)</h3>
${citations && citations.length
? `<ul>${citations.map(t => `<li>${t}</li>`).join("")}</ul>`
: `<p class="muted">No notes matched your question.</p>`
}
</section>
` : ""}
<p class="muted">Tip: add some notes first in <a href="/docs">Docs</a>.</p>
`;
%>
<%- include("layout", { title, body }) %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<%
const body = `
<h1>Docs</h1>
<p class="muted">Add notes to your knowledge base. Retrieval uses MongoDB text search.</p>
${error ? `<div class="alert">${error}</div>` : ""}
<form method="POST" action="/docs" class="card">
<label>Title</label>
<input name="title" placeholder="e.g., Express middleware basics" />
<label>Content</label>
<textarea name="content" rows="8" placeholder="Write your note here..."></textarea>
<label>Tags (comma-separated)</label>
<input name="tags" placeholder="express, mvc, mongodb" />
<button type="submit">Save Note</button>
</form>
<section class="card">
<h2>Saved Notes</h2>
${docs.length ? `
<ul class="doclist">
${docs.map(d => `
<li>
<div class="doctitle">${d.title}</div>
<div class="muted">${new Date(d.createdAt).toLocaleString()}</div>
<div class="snippet">${(d.content || "").slice(0, 180)}${d.content.length > 180 ? "..." : ""}</div>
</li>
`).join("")}
</ul>
` : `<p class="muted">No notes yet. Add one above.</p>`}
</section>
`;
%>
<%- include("layout", { title, body }) %>
The app.js should:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
"use strict";
require("dotenv").config();
const path = require("path");
const express = require("express");
const connectDb = require("./config/db");
const ragRoutes = require("./routes/ragRoutes");
const docRoutes = require("./routes/docRoutes");
const app = express();
const port = process.env.PORT || 3000;
// DB
connectDb();
// View engine
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
// Middleware
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// Tiny request logger (helps students “see” the pipeline)
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// Routes
app.use("/", ragRoutes);
app.use("/docs", docRoutes);
// 404
app.use((req, res) => {
res.status(404).render("layout", {
title: "Not Found",
body: `<h2>404</h2><p>That route doesn't exist.</p><p><a href="/">Go home</a></p>`
});
});
// Error handler
app.use((err, req, res, next) => {
console.error("ERROR:", err);
res.status(500).render("layout", {
title: "Server Error",
body: `<h2>500</h2><p>Something broke on the server.</p>`
});
});
app.listen(port, () => console.log(`MiniRAG running at http://localhost:${port}`));
1
npm run dev
This is where you can ask the RAG bot the question. You need to update your document first.
Attempt to perform the following add-ons to your app
ragController.js and see how that impact the answer.