create-rag-pipeline 0.0.0-stage → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -2
  3. package/bin/create-rag-pipeline.js +97 -0
  4. package/lib/scaffold.js +106 -0
  5. package/package.json +43 -4
  6. package/templates/mastra/.env.example +4 -0
  7. package/templates/mastra/README.md +47 -0
  8. package/templates/mastra/_gitignore +5 -0
  9. package/templates/mastra/docs/expenses.md +3 -0
  10. package/templates/mastra/docs/remote-work.md +3 -0
  11. package/templates/mastra/docs/vacation.md +3 -0
  12. package/templates/mastra/package.json +27 -0
  13. package/templates/mastra/src/ask.ts +49 -0
  14. package/templates/mastra/src/mastra/agents/docs-agent.ts +29 -0
  15. package/templates/mastra/src/mastra/index.ts +9 -0
  16. package/templates/mastra/src/mastra/retrieval.ts +80 -0
  17. package/templates/mastra/src/mastra/tools/search-documents.ts +23 -0
  18. package/templates/mastra/tsconfig.json +13 -0
  19. package/templates/next/.env.example +4 -0
  20. package/templates/next/README.md +54 -0
  21. package/templates/next/_gitignore +7 -0
  22. package/templates/next/app/api/ask/route.ts +80 -0
  23. package/templates/next/app/layout.tsx +24 -0
  24. package/templates/next/app/page.tsx +86 -0
  25. package/templates/next/docs/expenses.md +3 -0
  26. package/templates/next/docs/remote-work.md +3 -0
  27. package/templates/next/docs/vacation.md +3 -0
  28. package/templates/next/lib/rag.ts +46 -0
  29. package/templates/next/next.config.ts +11 -0
  30. package/templates/next/package.json +29 -0
  31. package/templates/next/tsconfig.json +33 -0
  32. package/templates/workers/.dev.vars.example +3 -0
  33. package/templates/workers/README.md +58 -0
  34. package/templates/workers/_gitignore +4 -0
  35. package/templates/workers/package.json +18 -0
  36. package/templates/workers/src/docs.js +25 -0
  37. package/templates/workers/src/worker.js +136 -0
  38. package/templates/workers/wrangler.toml +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ali Kahwaji
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # create-rag-pipeline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Starter projects for [`@devilsdev/rag-pipeline-utils`](https://www.npmjs.com/package/@devilsdev/rag-pipeline-utils): an app that answers questions from a folder of documents, cites the passages it used and checks every sentence of the answer against them.
4
+
5
+ ```bash
6
+ npm create rag-pipeline@latest my-app -- --template next
7
+ cd my-app
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ | Template | What you get |
13
+ | --------- | ------------------------------------------------------------------------------------------------------------ |
14
+ | `next` | Next.js and the AI SDK: a question page and `POST /api/ask`, with the model wrapped in `groundingMiddleware` |
15
+ | `mastra` | A Mastra agent with a `search_documents` tool, a groundedness scorer and guardrails |
16
+ | `workers` | A Cloudflare Worker: `GET /ask` as JSON or a token stream, with citations and a groundedness score |
17
+
18
+ Without `--template`, it asks. Each starter runs before you add an API key, returning the matching passages; copy its `.env.example` (`.dev.vars.example` for Workers) and set `ANTHROPIC_API_KEY` to get answers from Claude.
19
+
20
+ The starters ask for the library version this package was released with. Node.js 22 or later.
21
+
22
+ [Documentation](https://devilsdev.github.io/rag-pipeline-utils/docs/Starter-Templates) · [Source](https://github.com/DevilsDev/rag-pipeline-utils/tree/main/packages/create-rag-pipeline) · MIT
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * npm create rag-pipeline@latest [dir] -- [--template next|mastra|workers]
6
+ *
7
+ * Without --template, asks which starter to use (or fails when not run in a
8
+ * terminal, so scripts and CI must pass it).
9
+ */
10
+
11
+ const readline = require("readline/promises");
12
+ const path = require("path");
13
+ const { scaffold, TEMPLATES, version } = require("../lib/scaffold");
14
+
15
+ const USAGE = `Usage: npm create rag-pipeline@latest [dir] -- [--template ${Object.keys(TEMPLATES).join("|")}]
16
+
17
+ Templates:
18
+ ${Object.entries(TEMPLATES)
19
+ .map(([k, v]) => ` ${k.padEnd(8)} ${v}`)
20
+ .join("\n")}`;
21
+
22
+ // Where each starter reads its API key from in development
23
+ const KEY_FILE = { next: ".env.local", mastra: ".env", workers: ".dev.vars" };
24
+ const EXAMPLE = {
25
+ next: ".env.example",
26
+ mastra: ".env.example",
27
+ workers: ".dev.vars.example",
28
+ };
29
+ const RUN = { next: "dev", mastra: "ask", workers: "dev" };
30
+
31
+ function parse(argv) {
32
+ const out = { dir: undefined, template: undefined, help: false };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === "-h" || a === "--help") out.help = true;
36
+ else if (a === "-v" || a === "--version") out.version = true;
37
+ else if (a === "-t" || a === "--template") out.template = argv[++i];
38
+ else if (a.startsWith("--template=")) out.template = a.slice(11);
39
+ else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
40
+ else if (!out.dir) out.dir = a;
41
+ else throw new Error(`Unexpected argument ${a}`);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ async function ask(question) {
47
+ const rl = readline.createInterface({
48
+ input: process.stdin,
49
+ output: process.stdout,
50
+ });
51
+ try {
52
+ return (await rl.question(question)).trim();
53
+ } finally {
54
+ rl.close();
55
+ }
56
+ }
57
+
58
+ async function main() {
59
+ const args = parse(process.argv.slice(2));
60
+ if (args.help) return console.log(USAGE);
61
+ if (args.version) return console.log(version);
62
+
63
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
64
+ let { dir, template } = args;
65
+ if (!template) {
66
+ if (!interactive) throw new Error(`Pass --template.\n\n${USAGE}`);
67
+ const keys = Object.keys(TEMPLATES);
68
+ console.log("Which starter?");
69
+ keys.forEach((k, i) => console.log(` ${i + 1}. ${k}: ${TEMPLATES[k]}`));
70
+ const answer = await ask(`Number or name [1]: `);
71
+ template = keys[Number(answer || 1) - 1] || answer;
72
+ }
73
+ if (!dir) {
74
+ dir = interactive
75
+ ? (await ask("Directory [rag-app]: ")) || "rag-app"
76
+ : "rag-app";
77
+ }
78
+
79
+ const result = scaffold({ template, dir });
80
+ const rel = path.relative(process.cwd(), result.dir) || ".";
81
+ const pm = (process.env.npm_config_user_agent || "npm").split("/")[0];
82
+ const run = pm === "npm" ? "npm run" : pm;
83
+ console.log(`
84
+ Created ${result.name} from the ${template} starter in ${rel}
85
+
86
+ cd ${rel}
87
+ ${pm} install
88
+ ${run} ${RUN[template]}
89
+
90
+ It answers with the matching passages until you add a key: copy
91
+ ${EXAMPLE[template]} to ${KEY_FILE[template]} and set ANTHROPIC_API_KEY. See README.md.`);
92
+ }
93
+
94
+ main().catch((error) => {
95
+ console.error(`create-rag-pipeline: ${error.message}`);
96
+ process.exit(1);
97
+ });
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Copies a starter template into a new directory: `npm create rag-pipeline`.
5
+ *
6
+ * The templates pin @devilsdev/rag-pipeline-utils to this package's own
7
+ * version (the two are released together), so a starter never asks for an
8
+ * older library than the one it was written for. Node built-ins only.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ const { version } = require("../package.json");
15
+
16
+ const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
17
+
18
+ /** The starters, in the order the prompt lists them. */
19
+ const TEMPLATES = {
20
+ next: "Next.js and the AI SDK: a chat page and an API route that answer from your documents, with grounding checks",
21
+ mastra:
22
+ "Mastra: an agent with a document-search tool, a groundedness scorer and guardrails",
23
+ workers:
24
+ "Cloudflare Workers: a RAG API with in-memory retrieval and streamed answers",
25
+ };
26
+
27
+ // npm drops .gitignore from published tarballs, so templates ship it under
28
+ // another name and it is renamed on copy
29
+ const RENAME = { _gitignore: ".gitignore" };
30
+
31
+ const LIBRARY = "@devilsdev/rag-pipeline-utils";
32
+
33
+ const isSeparator = (c) => c === "-" || c === "." || c === "_";
34
+
35
+ /** A valid npm package name from a directory name. */
36
+ function packageName(dir) {
37
+ const name = path
38
+ .basename(path.resolve(dir))
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9-._~]+/g, "-");
41
+ // Trim separators from both ends (a loop, not a regex: no backtracking)
42
+ let start = 0;
43
+ let end = name.length;
44
+ while (start < end && isSeparator(name[start])) start++;
45
+ while (end > start && isSeparator(name[end - 1])) end--;
46
+ return name.slice(start, end) || "rag-app";
47
+ }
48
+
49
+ function isEmptyDir(dir) {
50
+ return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0;
51
+ }
52
+
53
+ function copyDir(from, to) {
54
+ fs.mkdirSync(to, { recursive: true });
55
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
56
+ const src = path.join(from, entry.name);
57
+ const dest = path.join(to, RENAME[entry.name] || entry.name);
58
+ if (entry.isDirectory()) copyDir(src, dest);
59
+ else fs.copyFileSync(src, dest);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Create a project from a template.
65
+ *
66
+ * @param {Object} options
67
+ * @param {string} options.template - One of the keys of TEMPLATES
68
+ * @param {string} options.dir - Directory to create; must be empty or missing
69
+ * @param {string} [options.libraryVersion] - Version range for the library (default `^<this version>`)
70
+ * @returns {{ dir: string, name: string, template: string }}
71
+ */
72
+ function scaffold({ template, dir, libraryVersion = `^${version}` }) {
73
+ if (!TEMPLATES[template]) {
74
+ throw new Error(
75
+ `Unknown template "${template}". Use one of: ${Object.keys(TEMPLATES).join(", ")}`,
76
+ );
77
+ }
78
+ if (!dir) throw new Error("Give a directory for the new project");
79
+ const target = path.resolve(dir);
80
+ if (!isEmptyDir(target)) {
81
+ throw new Error(`${target} is not empty; choose a new directory`);
82
+ }
83
+
84
+ copyDir(path.join(TEMPLATES_DIR, template), target);
85
+
86
+ const pkgFile = path.join(target, "package.json");
87
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
88
+ const name = packageName(target);
89
+ pkg.name = name;
90
+ pkg.dependencies[LIBRARY] = libraryVersion;
91
+ fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
92
+
93
+ // A Worker's name is its URL on workers.dev
94
+ const wrangler = path.join(target, "wrangler.toml");
95
+ if (fs.existsSync(wrangler)) {
96
+ const toml = fs.readFileSync(wrangler, "utf8");
97
+ fs.writeFileSync(
98
+ wrangler,
99
+ toml.replace(/^name = ".*"$/m, `name = "${name}"`),
100
+ );
101
+ }
102
+
103
+ return { dir: target, name, template };
104
+ }
105
+
106
+ module.exports = { scaffold, TEMPLATES, packageName, version };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "create-rag-pipeline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.7.0",
4
+ "description": "npm create rag-pipeline: starter projects for @devilsdev/rag-pipeline-utils with Next.js and the AI SDK, Mastra, or Cloudflare Workers.",
5
+ "keywords": [
6
+ "rag",
7
+ "create",
8
+ "starter",
9
+ "template",
10
+ "nextjs",
11
+ "ai-sdk",
12
+ "mastra",
13
+ "cloudflare-workers",
14
+ "claude"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Ali Kahwaji",
18
+ "homepage": "https://devilsdev.github.io/rag-pipeline-utils/docs/Starter-Templates",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/DevilsDev/rag-pipeline-utils.git",
22
+ "directory": "packages/create-rag-pipeline"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/DevilsDev/rag-pipeline-utils/issues"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "type": "commonjs",
31
+ "main": "./lib/scaffold.js",
32
+ "bin": {
33
+ "create-rag-pipeline": "bin/create-rag-pipeline.js"
34
+ },
35
+ "files": [
36
+ "bin/",
37
+ "lib/",
38
+ "templates/",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
@@ -0,0 +1,4 @@
1
+ # Copy to .env. Without a key, `npm run ask` prints the matching passages.
2
+ ANTHROPIC_API_KEY=
3
+ # Any model Mastra's router knows, as provider/model
4
+ MODEL=anthropic/claude-sonnet-5-5
@@ -0,0 +1,47 @@
1
+ # Docs agent (Mastra)
2
+
3
+ A [Mastra](https://mastra.ai) agent that answers questions from the Markdown files in `docs/`, made with [`create-rag-pipeline`](https://devilsdev.github.io/rag-pipeline-utils/docs/Starter-Templates). [`@devilsdev/rag-pipeline-utils`](https://www.npmjs.com/package/@devilsdev/rag-pipeline-utils) provides the retrieval behind its `search_documents` tool, a groundedness scorer (no model calls) and guardrails: prompt-injection detection on the question, PII redaction on the answer.
4
+
5
+ ## Run it
6
+
7
+ ```bash
8
+ npm install
9
+ npm run ask -- "How many vacation days do I get?"
10
+ ```
11
+
12
+ Without an API key it prints the matching passages. To get answers from Claude:
13
+
14
+ ```bash
15
+ cp .env.example .env # then set ANTHROPIC_API_KEY
16
+ npm run ask -- "How many vacation days do I get?"
17
+ ```
18
+
19
+ ```
20
+ Full-time employees receive 20 days of paid vacation per year. Unused days carry over until the end of March.
21
+
22
+ Sources: vacation.md, expenses.md, remote-work.md
23
+ Grounded: every sentence is supported by a passage.
24
+ ```
25
+
26
+ `npm run dev` opens Mastra Studio, where you can chat with the agent and see the groundedness score of each answer.
27
+
28
+ ## Make it yours
29
+
30
+ - **Your documents:** replace the files in `docs/` (Markdown or text), or set `DOCS_DIR`. They're indexed in memory on first use with the library's built-in TF-IDF embedder.
31
+ - **More documents:** swap `MemoryRetriever` in `src/mastra/retrieval.ts` for `QdrantRetriever` or `PgVectorRetriever`, and `LocalEmbedder` for an embedding model.
32
+ - **Another model:** set `MODEL` to any model Mastra's router knows (`anthropic/claude-haiku-4-5`, `openai/gpt-5`, ...).
33
+ - **Other scores:** `createMastraScorer` also takes `metric: "faithfulness"`, `"contextPrecision"` or `"contextRecall"`.
34
+ - **Storage:** `src/mastra/index.ts` keeps threads and scores in memory; use `@mastra/libsql` or `@mastra/pg` to keep them.
35
+
36
+ ## Deploy
37
+
38
+ `npm run build` writes a server to `.mastra/output`; run it with `node .mastra/output/index.mjs` from the project folder (it reads `docs/`), or deploy it with one of Mastra's deployers.
39
+
40
+ ## Files
41
+
42
+ | File | What it does |
43
+ | -------------------------------------- | --------------------------------------------------------------- |
44
+ | `src/mastra/agents/docs-agent.ts` | The agent: instructions, model, tool, scorer, guardrails |
45
+ | `src/mastra/tools/search-documents.ts` | The `search_documents` tool |
46
+ | `src/mastra/retrieval.ts` | Indexes `docs/` in memory |
47
+ | `src/ask.ts` | `npm run ask`: one question, the answer and its grounding check |
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .mastra/
3
+ dist/
4
+ .env
5
+ *.db
@@ -0,0 +1,3 @@
1
+ # Expenses
2
+
3
+ Submit receipts within thirty days. Meals while travelling are reimbursed up to fifty dollars per day.
@@ -0,0 +1,3 @@
1
+ # Remote work
2
+
3
+ Employees may work remotely up to three days a week with manager approval. Core hours are 10:00 to 15:00.
@@ -0,0 +1,3 @@
1
+ # Vacation
2
+
3
+ Full-time employees receive twenty days of paid vacation per year. Unused days carry over until the end of March.
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "rag-mastra",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "A Mastra agent that answers from your documents, with @devilsdev/rag-pipeline-utils retrieval, a groundedness scorer and guardrails.",
7
+ "scripts": {
8
+ "ask": "node --env-file-if-exists=.env --import tsx src/ask.ts",
9
+ "dev": "mastra dev",
10
+ "build": "mastra build",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@devilsdev/rag-pipeline-utils": "LIBRARY_VERSION",
15
+ "@mastra/core": "^1.82.0",
16
+ "zod": "^4.6.7"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.20.5",
20
+ "mastra": "^1.38.0",
21
+ "tsx": "^4.23.15",
22
+ "typescript": "^6.0.3"
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ }
27
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * npm run ask -- "How many vacation days do I get?"
3
+ *
4
+ * Asks the agent once and prints the answer, the passages it used and
5
+ * whether every sentence is supported by them. Without ANTHROPIC_API_KEY it
6
+ * prints the passages that match, so you can try retrieval first.
7
+ */
8
+
9
+ import { checkAnswer } from "@devilsdev/rag-pipeline-utils";
10
+ import { mastra } from "./mastra";
11
+ import { searchDocuments, type Passage } from "./mastra/retrieval";
12
+
13
+ const question =
14
+ process.argv.slice(2).join(" ").trim() || "How many vacation days do I get?";
15
+
16
+ if (!process.env.ANTHROPIC_API_KEY) {
17
+ console.log(
18
+ `No ANTHROPIC_API_KEY (copy .env.example to .env). Passages for "${question}":\n`,
19
+ );
20
+ for (const p of await searchDocuments(question)) {
21
+ console.log(`[${p.source}] ${p.content.trim()}\n`);
22
+ }
23
+ process.exit(0);
24
+ }
25
+
26
+ const result = await mastra.getAgent("docsAgent").generate(question);
27
+
28
+ // The passages the agent's search_documents calls returned
29
+ const passages: Passage[] = (result.toolResults ?? []).flatMap((r: any) =>
30
+ r.payload?.toolName === "search_documents"
31
+ ? (r.payload.result?.passages ?? [])
32
+ : [],
33
+ );
34
+
35
+ const check = await checkAnswer({
36
+ answer: result.text,
37
+ sources: passages.map((p) => ({ id: p.id, content: p.content })),
38
+ evaluate: false,
39
+ });
40
+
41
+ console.log(result.text.trim());
42
+ console.log(
43
+ `\nSources: ${[...new Set(passages.map((p) => p.source))].join(", ") || "none"}`,
44
+ );
45
+ console.log(
46
+ check.grounded
47
+ ? "Grounded: every sentence is supported by a passage."
48
+ : `Not grounded. Unsupported: ${check.unsupported.map((s: string) => `"${s}"`).join("; ")}`,
49
+ );
@@ -0,0 +1,29 @@
1
+ import { Agent } from "@mastra/core/agent";
2
+ import { createScorer } from "@mastra/core/evals";
3
+ import {
4
+ createMastraGuardrails,
5
+ createMastraScorer,
6
+ } from "@devilsdev/rag-pipeline-utils";
7
+ import { searchDocumentsTool } from "../tools/search-documents";
8
+
9
+ // Prompt-injection detection on the question, PII redaction on the answer
10
+ const guardrails = createMastraGuardrails();
11
+
12
+ export const docsAgent = new Agent({
13
+ id: "docs-agent",
14
+ name: "Docs agent",
15
+ instructions: `You answer questions from the user's documents.
16
+ Always call search_documents first, then answer only from the passages it returns.
17
+ If they don't contain the answer, say you don't know. Keep answers short.`,
18
+ model: process.env.MODEL || "anthropic/claude-sonnet-5-5",
19
+ tools: { search_documents: searchDocumentsTool },
20
+ inputProcessors: [guardrails],
21
+ outputProcessors: [guardrails],
22
+ // Scores each answer against the passages the tool returned: a lexical
23
+ // check, no model calls. See it in Mastra Studio (npm run dev)
24
+ scorers: {
25
+ groundedness: {
26
+ scorer: createMastraScorer({ createScorer, metric: "groundedness" }),
27
+ },
28
+ },
29
+ });
@@ -0,0 +1,9 @@
1
+ import { Mastra } from "@mastra/core/mastra";
2
+ import { InMemoryStore } from "@mastra/core/storage";
3
+ import { docsAgent } from "./agents/docs-agent";
4
+
5
+ export const mastra = new Mastra({
6
+ agents: { docsAgent },
7
+ // Lost on restart: use @mastra/libsql or @mastra/pg to keep threads and scores
8
+ storage: new InMemoryStore(),
9
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Retrieval over the Markdown files in docs/: indexed in memory on first use
3
+ * with the library's built-in TF-IDF embedder, so there is no database and
4
+ * no embedding API. Swap in a vector store (QdrantRetriever,
5
+ * PgVectorRetriever) and an embedding model when the documents outgrow
6
+ * memory.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { LocalEmbedder, MemoryRetriever } from "@devilsdev/rag-pipeline-utils";
13
+
14
+ export interface Passage {
15
+ id: string;
16
+ content: string;
17
+ source?: string;
18
+ score?: number;
19
+ }
20
+
21
+ /**
22
+ * The project's docs/ folder: the nearest one at or above the working
23
+ * directory, since `mastra dev` and `mastra build` run from folders inside
24
+ * the project. Set DOCS_DIR to read another folder.
25
+ */
26
+ function findDocsDir(): string {
27
+ if (process.env.DOCS_DIR) return path.resolve(process.env.DOCS_DIR);
28
+ for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
29
+ const candidate = path.join(dir, "docs");
30
+ if (existsSync(candidate)) return candidate;
31
+ if (path.dirname(dir) === dir) return path.resolve("docs");
32
+ }
33
+ }
34
+
35
+ const DOCS_DIR = findDocsDir();
36
+
37
+ let index: Promise<{ embedder: LocalEmbedder; retriever: MemoryRetriever }>;
38
+
39
+ async function buildIndex() {
40
+ const files = (await readdir(DOCS_DIR)).filter((f) => /\.(md|txt)$/i.test(f));
41
+ const documents = await Promise.all(
42
+ files.map(async (file) => ({
43
+ id: file.replace(/\.[^.]+$/, ""),
44
+ content: await readFile(path.join(DOCS_DIR, file), "utf8"),
45
+ metadata: { source: file },
46
+ })),
47
+ );
48
+ const embedder = new LocalEmbedder();
49
+ embedder.train(documents);
50
+ const retriever = new MemoryRetriever();
51
+ await retriever.store(
52
+ await Promise.all(
53
+ documents.map(async (doc) => ({
54
+ ...doc,
55
+ vector: await embedder.embed(doc.content),
56
+ })),
57
+ ),
58
+ );
59
+ return { embedder, retriever };
60
+ }
61
+
62
+ /** The passages that best match a query. */
63
+ export async function searchDocuments(
64
+ query: string,
65
+ topK = 3,
66
+ ): Promise<Passage[]> {
67
+ index ??= buildIndex();
68
+ const { embedder, retriever } = await index;
69
+ const results = await retriever.retrieve({
70
+ query,
71
+ queryVector: await embedder.embed(query),
72
+ topK,
73
+ });
74
+ return results.map((r: any) => ({
75
+ id: r.id,
76
+ content: r.content,
77
+ source: r.metadata?.source,
78
+ score: r.score,
79
+ }));
80
+ }
@@ -0,0 +1,23 @@
1
+ import { createTool } from "@mastra/core/tools";
2
+ import { z } from "zod";
3
+ import { searchDocuments } from "../retrieval";
4
+
5
+ export const searchDocumentsTool = createTool({
6
+ id: "search_documents",
7
+ description:
8
+ "Search the documents. Returns the best-matching passages with their ids and source files.",
9
+ inputSchema: z.object({
10
+ query: z.string().describe("What to look for"),
11
+ }),
12
+ outputSchema: z.object({
13
+ passages: z.array(
14
+ z.object({
15
+ id: z.string(),
16
+ content: z.string(),
17
+ source: z.string().optional(),
18
+ score: z.number().optional(),
19
+ }),
20
+ ),
21
+ }),
22
+ execute: async ({ query }) => ({ passages: await searchDocuments(query) }),
23
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,4 @@
1
+ # Copy to .env.local. Without a key, the app answers with the matching passages.
2
+ ANTHROPIC_API_KEY=
3
+ # Optional: the Claude model
4
+ ANTHROPIC_MODEL=claude-sonnet-5-5
@@ -0,0 +1,54 @@
1
+ # Ask your documents (Next.js + AI SDK)
2
+
3
+ A Next.js app that answers questions from the Markdown files in `docs/`, made with [`create-rag-pipeline`](https://devilsdev.github.io/rag-pipeline-utils/docs/Starter-Templates). The AI SDK model is wrapped with `groundingMiddleware` from [`@devilsdev/rag-pipeline-utils`](https://www.npmjs.com/package/@devilsdev/rag-pipeline-utils): before each call it retrieves the matching passages and gives them to the model; after the call it checks every sentence of the answer against them and returns the report with the sources.
4
+
5
+ ## Run it
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ Open http://localhost:3000 and ask "How many vacation days do I get?". Without an API key the app shows the matching passages. To get answers from Claude:
13
+
14
+ ```bash
15
+ cp .env.example .env.local # then set ANTHROPIC_API_KEY
16
+ ```
17
+
18
+ Or call the API:
19
+
20
+ ```bash
21
+ curl -X POST localhost:3000/api/ask -H "content-type: application/json" \
22
+ -d '{"question":"How many vacation days do I get?"}'
23
+ ```
24
+
25
+ ```json
26
+ {
27
+ "answer": "Full-time employees receive twenty days of paid vacation per year…",
28
+ "sources": [{ "id": "vacation", "source": "vacation.md" }],
29
+ "grounding": { "grounded": true, "groundedness": 1, "unsupported": [] }
30
+ }
31
+ ```
32
+
33
+ `grounded: false` lists the sentences no passage supports, so you can flag or retry them.
34
+
35
+ ## Make it yours
36
+
37
+ - **Your documents:** replace the files in `docs/` (Markdown or text). They're indexed in memory on the first request with the library's built-in TF-IDF embedder: no database, no embedding API.
38
+ - **More documents:** swap `MemoryRetriever` in `lib/rag.ts` for `QdrantRetriever` or `PgVectorRetriever`, and `LocalEmbedder` for an embedding model (`OpenAIConnector`, or any AI SDK embedding model through `createAiSdkAdapter`).
39
+ - **Another model:** set `ANTHROPIC_MODEL`, or replace `anthropic(...)` in `app/api/ask/route.ts` with any AI SDK provider.
40
+ - **Paraphrases:** the check is lexical, so a correct paraphrase can be reported unsupported. Pass `judge` to `groundingMiddleware` to have a model rule on those sentences ([LLM judge](https://devilsdev.github.io/rag-pipeline-utils/docs/Evaluation#paraphrases-the-optional-llm-judge)).
41
+ - **Guardrails:** `groundingMiddleware({ preRetrieval: true, postGeneration: true })` adds prompt-injection detection on the question and PII redaction on the answer.
42
+
43
+ ## Deploy
44
+
45
+ On Vercel, import the repository and set `ANTHROPIC_API_KEY`. `next.config.ts` includes `docs/` in the deployment of the route that reads it.
46
+
47
+ ## Files
48
+
49
+ | File | What it does |
50
+ | ---------------------- | -------------------------------------------------------- |
51
+ | `app/api/ask/route.ts` | The API: retrieval, the model call, the grounding report |
52
+ | `lib/rag.ts` | Indexes `docs/` in memory, once per server process |
53
+ | `app/page.tsx` | The page: a question box, the answer, its sources |
54
+ | `next.config.ts` | Loads the library with Node at runtime; deploys `docs/` |
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ next-env.d.ts
5
+ *.tsbuildinfo
6
+ .env*.local
7
+ .vercel
@@ -0,0 +1,80 @@
1
+ /**
2
+ * POST /api/ask { question } -> { answer, sources, grounding }
3
+ *
4
+ * The AI SDK model is wrapped with groundingMiddleware: before the call it
5
+ * retrieves passages for the question and gives them to the model as
6
+ * reference data; after the call it checks every sentence of the answer
7
+ * against them. Without ANTHROPIC_API_KEY the route returns the passages
8
+ * only (answer: null).
9
+ */
10
+
11
+ import { anthropic } from "@ai-sdk/anthropic";
12
+ import { groundingMiddleware } from "@devilsdev/rag-pipeline-utils";
13
+ import { generateText, wrapLanguageModel } from "ai";
14
+ import { getIndex } from "@/lib/rag";
15
+
16
+ export const runtime = "nodejs";
17
+
18
+ export async function POST(request: Request) {
19
+ const { question } = (await request.json().catch(() => ({}))) as {
20
+ question?: unknown;
21
+ };
22
+ if (typeof question !== "string" || !question.trim()) {
23
+ return Response.json({ error: "Send { question }" }, { status: 400 });
24
+ }
25
+
26
+ const { embedder, retriever } = await getIndex();
27
+
28
+ if (!process.env.ANTHROPIC_API_KEY) {
29
+ const results = await retriever.retrieve({
30
+ query: question,
31
+ queryVector: await embedder.embed(question),
32
+ topK: 3,
33
+ });
34
+ return Response.json({
35
+ answer: null,
36
+ sources: results.map((r: any) => ({
37
+ id: r.id,
38
+ source: r.metadata?.source,
39
+ content: r.content,
40
+ })),
41
+ grounding: null,
42
+ });
43
+ }
44
+
45
+ const model = wrapLanguageModel({
46
+ model: anthropic(process.env.ANTHROPIC_MODEL || "claude-sonnet-5-5"),
47
+ middleware: groundingMiddleware({
48
+ retriever,
49
+ embedder,
50
+ topK: 3,
51
+ specificationVersion: "v4",
52
+ }),
53
+ });
54
+
55
+ try {
56
+ const result = await generateText({ model, prompt: question });
57
+ const report = result.providerMetadata?.ragPipeline as
58
+ | { grounded?: boolean; groundedness?: number; unsupported?: string[] }
59
+ | undefined;
60
+ return Response.json({
61
+ answer: result.text,
62
+ sources: result.sources.map((s: any) => ({
63
+ id: s.id,
64
+ source: s.title ?? s.id,
65
+ })),
66
+ grounding: report
67
+ ? {
68
+ grounded: report.grounded,
69
+ groundedness: report.groundedness,
70
+ unsupported: report.unsupported ?? [],
71
+ }
72
+ : null,
73
+ });
74
+ } catch (error) {
75
+ return Response.json(
76
+ { error: error instanceof Error ? error.message : String(error) },
77
+ { status: 502 },
78
+ );
79
+ }
80
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export const metadata = {
4
+ title: "Ask your documents",
5
+ description: "Answers from your documents, checked against them.",
6
+ };
7
+
8
+ export default function RootLayout({ children }: { children: ReactNode }) {
9
+ return (
10
+ <html lang="en">
11
+ <body
12
+ style={{
13
+ fontFamily: "system-ui, sans-serif",
14
+ maxWidth: 680,
15
+ margin: "48px auto",
16
+ padding: "0 16px",
17
+ lineHeight: 1.5,
18
+ }}
19
+ >
20
+ {children}
21
+ </body>
22
+ </html>
23
+ );
24
+ }
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useState, type FormEvent } from "react";
4
+
5
+ type Source = { id: string; source?: string; content?: string };
6
+ type Answer = {
7
+ answer: string | null;
8
+ sources: Source[];
9
+ grounding: {
10
+ grounded?: boolean;
11
+ groundedness?: number;
12
+ unsupported: string[];
13
+ } | null;
14
+ error?: string;
15
+ };
16
+
17
+ export default function Home() {
18
+ const [question, setQuestion] = useState("How many vacation days do I get?");
19
+ const [result, setResult] = useState<Answer | null>(null);
20
+ const [loading, setLoading] = useState(false);
21
+
22
+ async function ask(event: FormEvent) {
23
+ event.preventDefault();
24
+ setLoading(true);
25
+ setResult(null);
26
+ const response = await fetch("/api/ask", {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify({ question }),
30
+ });
31
+ setResult(await response.json());
32
+ setLoading(false);
33
+ }
34
+
35
+ return (
36
+ <main>
37
+ <h1>Ask your documents</h1>
38
+ <form onSubmit={ask} style={{ display: "flex", gap: 8 }}>
39
+ <input
40
+ value={question}
41
+ onChange={(e) => setQuestion(e.target.value)}
42
+ style={{ flex: 1, padding: 8 }}
43
+ aria-label="Question"
44
+ />
45
+ <button disabled={loading || !question.trim()}>
46
+ {loading ? "Asking…" : "Ask"}
47
+ </button>
48
+ </form>
49
+
50
+ {result?.error && <p role="alert">Error: {result.error}</p>}
51
+
52
+ {result && !result.error && (
53
+ <section>
54
+ {result.answer === null ? (
55
+ <p>
56
+ <em>
57
+ No ANTHROPIC_API_KEY yet (see .env.example), so here are the
58
+ matching passages:
59
+ </em>
60
+ </p>
61
+ ) : (
62
+ <p>{result.answer}</p>
63
+ )}
64
+
65
+ {result.grounding && (
66
+ <p>
67
+ {result.grounding.grounded
68
+ ? "✓ Every sentence is supported by the sources."
69
+ : `⚠ Not supported by the sources: ${result.grounding.unsupported.join(" ")}`}
70
+ </p>
71
+ )}
72
+
73
+ <h2 style={{ fontSize: "1rem" }}>Sources</h2>
74
+ <ul>
75
+ {result.sources.map((s) => (
76
+ <li key={s.id}>
77
+ <strong>{s.source ?? s.id}</strong>
78
+ {s.content && <div>{s.content.replace(/^#.*\n+/, "")}</div>}
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </section>
83
+ )}
84
+ </main>
85
+ );
86
+ }
@@ -0,0 +1,3 @@
1
+ # Expenses
2
+
3
+ Submit receipts within thirty days. Meals while travelling are reimbursed up to fifty dollars per day.
@@ -0,0 +1,3 @@
1
+ # Remote work
2
+
3
+ Employees may work remotely up to three days a week with manager approval. Core hours are 10:00 to 15:00.
@@ -0,0 +1,3 @@
1
+ # Vacation
2
+
3
+ Full-time employees receive twenty days of paid vacation per year. Unused days carry over until the end of March.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Retrieval over the Markdown files in docs/: indexed in memory on first use
3
+ * with the library's built-in TF-IDF embedder, so there is no database and
4
+ * no embedding API. Swap in a vector store (QdrantRetriever,
5
+ * PgVectorRetriever) and an embedding model when the documents outgrow
6
+ * memory.
7
+ */
8
+
9
+ import { readdir, readFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { LocalEmbedder, MemoryRetriever } from "@devilsdev/rag-pipeline-utils";
12
+
13
+ const DOCS_DIR = path.join(process.cwd(), "docs");
14
+
15
+ let index:
16
+ | Promise<{ embedder: LocalEmbedder; retriever: MemoryRetriever }>
17
+ | undefined;
18
+
19
+ async function buildIndex() {
20
+ const files = (await readdir(DOCS_DIR)).filter((f) => /\.(md|txt)$/i.test(f));
21
+ const documents = await Promise.all(
22
+ files.map(async (file) => ({
23
+ id: file.replace(/\.[^.]+$/, ""),
24
+ content: await readFile(path.join(DOCS_DIR, file), "utf8"),
25
+ metadata: { source: file },
26
+ })),
27
+ );
28
+ const embedder = new LocalEmbedder();
29
+ embedder.train(documents);
30
+ const retriever = new MemoryRetriever();
31
+ await retriever.store(
32
+ await Promise.all(
33
+ documents.map(async (doc) => ({
34
+ ...doc,
35
+ vector: await embedder.embed(doc.content),
36
+ })),
37
+ ),
38
+ );
39
+ return { embedder, retriever };
40
+ }
41
+
42
+ /** The embedder and retriever, built once per server process. */
43
+ export function getIndex() {
44
+ index ??= buildIndex();
45
+ return index;
46
+ }
@@ -0,0 +1,11 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // Loaded by Node at runtime rather than bundled: the library is CommonJS
5
+ // and requires its optional providers (openai, ...) only when used
6
+ serverExternalPackages: ["@devilsdev/rag-pipeline-utils"],
7
+ // Deploy the documents with the route that reads them
8
+ outputFileTracingIncludes: { "/api/ask": ["./docs/**"] },
9
+ };
10
+
11
+ export default nextConfig;
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "rag-next",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "A Next.js app that answers from your documents with the AI SDK and @devilsdev/rag-pipeline-utils: retrieval, grounding checks and sources.",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@ai-sdk/anthropic": "^4.0.97",
14
+ "@devilsdev/rag-pipeline-utils": "LIBRARY_VERSION",
15
+ "ai": "^7.0.163",
16
+ "next": "^16.4.5",
17
+ "react": "^19.3.1",
18
+ "react-dom": "^19.3.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.20.5",
22
+ "@types/react": "^19.3.0",
23
+ "@types/react-dom": "^19.3.0",
24
+ "typescript": "^6.0.3"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ }
29
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts"
31
+ ],
32
+ "exclude": ["node_modules"]
33
+ }
@@ -0,0 +1,3 @@
1
+ # Copy to .dev.vars (read by `wrangler dev`). Without a key the Worker
2
+ # returns the matching passages. In production: npx wrangler secret put ANTHROPIC_API_KEY
3
+ ANTHROPIC_API_KEY=
@@ -0,0 +1,58 @@
1
+ # RAG on Cloudflare Workers
2
+
3
+ A Cloudflare Worker that answers questions from your documents, made with [`create-rag-pipeline`](https://devilsdev.github.io/rag-pipeline-utils/docs/Starter-Templates): retrieval in memory with the built-in TF-IDF embedder, answers from Claude with citations and a groundedness score, and streaming. It uses `@devilsdev/rag-pipeline-utils/edge`, the library's build for runtimes with Web APIs only.
4
+
5
+ ## Run it
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ In another terminal:
13
+
14
+ ```bash
15
+ curl "http://localhost:8787/ask?q=How+many+vacation+days+do+I+get%3F"
16
+ ```
17
+
18
+ Without an API key the Worker returns the matching passages (`answer: null`). To get answers from Claude:
19
+
20
+ ```bash
21
+ cp .dev.vars.example .dev.vars # then set ANTHROPIC_API_KEY
22
+ ```
23
+
24
+ Stream the answer as server-sent events:
25
+
26
+ ```bash
27
+ curl -N "http://localhost:8787/ask?q=How+many+vacation+days+do+I+get%3F&stream=1"
28
+ ```
29
+
30
+ ```
31
+ event: token
32
+ data: "Full-time employees receive "
33
+ ...
34
+ event: done
35
+ data: {"groundedness":1,"timing":{"firstTokenMs":412,"totalMs":980}}
36
+ ```
37
+
38
+ ## Deploy
39
+
40
+ ```bash
41
+ npx wrangler secret put ANTHROPIC_API_KEY
42
+ npm run deploy
43
+ ```
44
+
45
+ ## API
46
+
47
+ | Request | Response |
48
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
49
+ | `GET /ask?q=...` | `{ answer, sources: [{ id, source, score }], groundedness, timing }` |
50
+ | `GET /ask?q=...&stream=1` | Server-sent events: `token` events, then `done` with `groundedness` and `timing: { firstTokenMs, totalMs }` |
51
+
52
+ A missing `q` is a `400`; a failed run (for example, a provider error or the 30-second timeout) is a `502` with `{ error }`.
53
+
54
+ ## Make it yours
55
+
56
+ - **Your documents:** `src/docs.js` holds them. Workers have no disk to read at runtime, so they're bundled with the code: replace them, or generate the file at build time.
57
+ - **More documents:** `QdrantRetriever` works in Workers; index your documents there and give the pipeline an embedder.
58
+ - **Settings:** `ANTHROPIC_MODEL` chooses the model; `ANTHROPIC_BASE_URL` sends requests through a gateway such as Cloudflare AI Gateway. Set them under `[vars]` in `wrangler.toml`.
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .wrangler/
3
+ dist/
4
+ .dev.vars
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "rag-worker",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "A RAG API on Cloudflare Workers with @devilsdev/rag-pipeline-utils: in-memory retrieval and streamed answers from Claude.",
7
+ "scripts": {
8
+ "dev": "wrangler dev",
9
+ "deploy": "wrangler deploy",
10
+ "check": "wrangler deploy --dry-run --outdir dist"
11
+ },
12
+ "dependencies": {
13
+ "@devilsdev/rag-pipeline-utils": "LIBRARY_VERSION"
14
+ },
15
+ "devDependencies": {
16
+ "wrangler": "^4.157.0"
17
+ }
18
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The documents the Worker answers from. Workers have no local disk to read
3
+ * them from at runtime, so they are bundled with the code: replace these, or
4
+ * load your own at build time.
5
+ */
6
+ export const documents = [
7
+ {
8
+ id: "vacation",
9
+ content:
10
+ "Full-time employees receive twenty days of paid vacation per year. Unused days carry over until the end of March.",
11
+ metadata: { source: "vacation.md" },
12
+ },
13
+ {
14
+ id: "remote-work",
15
+ content:
16
+ "Employees may work remotely up to three days a week with manager approval. Core hours are 10:00 to 15:00.",
17
+ metadata: { source: "remote-work.md" },
18
+ },
19
+ {
20
+ id: "expenses",
21
+ content:
22
+ "Submit receipts within thirty days. Meals while travelling are reimbursed up to fifty dollars per day.",
23
+ metadata: { source: "expenses.md" },
24
+ },
25
+ ];
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A RAG API on Cloudflare Workers.
3
+ *
4
+ * GET /ask?q=... answer as JSON (passages only without an API key)
5
+ * GET /ask?q=...&stream=1 answer as server-sent events, token by token
6
+ *
7
+ * The documents are bundled with the Worker (./docs.js) and indexed in
8
+ * memory on the first request with the built-in TF-IDF embedder, so there is
9
+ * no database and no embedding API. Set the ANTHROPIC_API_KEY secret to
10
+ * generate answers with Claude; without it the Worker returns the passages.
11
+ * Needs the nodejs_compat compatibility flag (see wrangler.toml).
12
+ */
13
+
14
+ import {
15
+ AnthropicConnector,
16
+ LocalEmbedder,
17
+ MemoryRetriever,
18
+ createRagPipeline,
19
+ } from "@devilsdev/rag-pipeline-utils/edge";
20
+ import { documents } from "./docs.js";
21
+
22
+ /** One index per isolate, built on first use. */
23
+ let index;
24
+ async function getIndex() {
25
+ if (!index) {
26
+ index = (async () => {
27
+ const embedder = new LocalEmbedder();
28
+ embedder.train(documents);
29
+ const retriever = new MemoryRetriever();
30
+ await retriever.store(
31
+ await Promise.all(
32
+ documents.map(async (doc) => ({
33
+ ...doc,
34
+ vector: await embedder.embed(doc.content),
35
+ })),
36
+ ),
37
+ );
38
+ return { embedder, retriever };
39
+ })();
40
+ }
41
+ return index;
42
+ }
43
+
44
+ function pipelineFor(env, { embedder, retriever }) {
45
+ const llm = env.ANTHROPIC_API_KEY
46
+ ? new AnthropicConnector({
47
+ apiKey: env.ANTHROPIC_API_KEY,
48
+ ...(env.ANTHROPIC_MODEL && { model: env.ANTHROPIC_MODEL }),
49
+ // e.g. a Cloudflare AI Gateway URL
50
+ ...(env.ANTHROPIC_BASE_URL && { baseURL: env.ANTHROPIC_BASE_URL }),
51
+ maxTokens: 512,
52
+ })
53
+ : undefined;
54
+ return createRagPipeline({ embedder, retriever, llm });
55
+ }
56
+
57
+ const sources = (results) =>
58
+ results.map((d) => ({
59
+ id: d.id,
60
+ source: d.metadata?.source,
61
+ score: d.score,
62
+ }));
63
+
64
+ /** The stream as server-sent events: `token` events, then one `done`. */
65
+ function eventStream(stream) {
66
+ const encoder = new TextEncoder();
67
+ return new ReadableStream({
68
+ async start(controller) {
69
+ const send = (event, data) =>
70
+ controller.enqueue(
71
+ encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
72
+ );
73
+ try {
74
+ for await (const chunk of stream) {
75
+ if (chunk.token) send("token", chunk.token);
76
+ if (chunk.done) {
77
+ send("done", {
78
+ ...(chunk.results && { sources: sources(chunk.results) }),
79
+ groundedness: chunk.citations?.groundednessScore,
80
+ timing: chunk.timing,
81
+ });
82
+ }
83
+ }
84
+ } catch (error) {
85
+ send("error", { message: String(error?.message || error) });
86
+ }
87
+ controller.close();
88
+ },
89
+ });
90
+ }
91
+
92
+ export default {
93
+ async fetch(request, env) {
94
+ const url = new URL(request.url);
95
+ if (url.pathname !== "/ask") {
96
+ return Response.json(
97
+ { error: "Ask with GET /ask?q=your+question" },
98
+ { status: 404 },
99
+ );
100
+ }
101
+ const query = (url.searchParams.get("q") || "").trim();
102
+ if (!query) {
103
+ return Response.json({ error: "Missing ?q=" }, { status: 400 });
104
+ }
105
+
106
+ const pipeline = pipelineFor(env, await getIndex());
107
+ const options = { topK: 3, citations: true, timeout: 30000 };
108
+
109
+ if (url.searchParams.get("stream") === "1") {
110
+ const stream = await pipeline.run({
111
+ query,
112
+ options: { ...options, stream: true },
113
+ });
114
+ if (stream.success === false) {
115
+ return Response.json({ error: stream.error }, { status: 502 });
116
+ }
117
+ return new Response(eventStream(stream), {
118
+ headers: {
119
+ "content-type": "text/event-stream; charset=utf-8",
120
+ "cache-control": "no-store",
121
+ },
122
+ });
123
+ }
124
+
125
+ const result = await pipeline.run({ query, options });
126
+ if (!result.success) {
127
+ return Response.json({ error: result.error }, { status: 502 });
128
+ }
129
+ return Response.json({
130
+ answer: result.answer,
131
+ sources: sources(result.results),
132
+ groundedness: result.citations?.groundednessScore,
133
+ timing: result.timing,
134
+ });
135
+ },
136
+ };
@@ -0,0 +1,10 @@
1
+ name = "rag-worker"
2
+ main = "src/worker.js"
3
+ compatibility_date = "2026-10-01"
4
+ # The library uses Node.js APIs (events, crypto, buffer, ...) that Workers
5
+ # provide under this flag.
6
+ compatibility_flags = ["nodejs_compat"]
7
+
8
+ # Optional: the Claude model (the library's default otherwise).
9
+ # [vars]
10
+ # ANTHROPIC_MODEL = "claude-sonnet-5-5"