# 从 0 手搓一个 RAG

> 用一篇 Markdown 和七个 TypeScript 文件，把检索、增强、生成完整跑一遍。

![从 0 手搓一个 RAG：Markdown 经过切块、Top 3 和 Prompt 后生成回答](https://herblab.online/qwen-imgs/rag-from-zero-01-cover-v1.png)<!-- display-width:600 -->

这篇直接做一个能运行的最小 RAG：输入“npm 包是怎么发布出去的？”，程序先从我的文章里找到三个相关片段，再把它们交给生成模型组织成答案。

不用 LangChain，不用 LlamaIndex，也不接向量数据库。我们只用 Node.js、TypeScript、一篇 Markdown，以及生成阶段的 CodeBuddy Agent SDK。这样每一步到底做了什么，都能从代码里看见。

RAG 的全称是 Retrieval-Augmented Generation。为了跟代码对上，本文把它按过程拆成：

```text
Retrieval → Augmentation → Generation
先找资料   → 把资料放进问题 → 根据资料回答
```

![RAG 的三段主线：Retrieval 检索、Augmentation 增强、Generation 生成](https://herblab.online/qwen-imgs/rag-from-zero-02-rag-overview-v1.png)<!-- display-width:600 -->

下面从空目录开始。第一次下载 Embedding 模型所需时间取决于网络，其他步骤都很短。

## 1. 先把一篇 Markdown 读进程序

先创建项目：

```powershell
mkdir my-first-rag
cd .\my-first-rag\
npm init -y
```

![运行 npm init -y 后生成 my-first-rag 的 package.json](https://herblab.online/qwen-imgs/rag-from-zero-05-project-init-v1.png)<!-- display-width:520 -->

看到 package.json，就说明普通 Node.js 项目已经建好了。

接着创建 knowledge 目录，把一篇 Markdown 放进去。我的实验目录里有 npm.md、ollama.md 和 skill.md；这次只读取 npm.md，避免一上来处理多文档。

![实验目录放入 npm.md、ollama.md 和 skill.md，第一版只读取 npm.md](https://herblab.online/qwen-imgs/rag-from-zero-06-knowledge-files-v1.png)<!-- display-width:580 -->

你可以换成自己的文章。为了复现实验中的 8645 字、18 个 Chunk 和检索结果，仓库示例保留了同一份 npm.md。

安装 TypeScript 运行环境：

```powershell
npm install -D typescript@7.0.2 tsx@4.23.12 @types/node@26.2.0
npm pkg set type=module
```

![安装 TypeScript、tsx 和 Node.js 类型后，npm 报告 0 个漏洞](https://herblab.online/qwen-imgs/rag-from-zero-07-typescript-install-v1.png)<!-- display-width:560 -->

这里的三个包分工很直白：typescript 负责类型检查，tsx 直接运行 .ts 文件，@types/node 提供 Node.js API 的类型。

再创建 tsconfig.json：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],
    "skipLibCheck": true,
    "strict": true
  },
  "include": ["src/**/*.ts"]
}
```

package.json 中的 type=module 和这里的 NodeNext 让 Node.js 按 ES Module 处理源码，顶层 await 才能直接使用。

![package.json 已加入 type=module，并创建 tsconfig.json](https://herblab.online/qwen-imgs/rag-from-zero-08-esm-config-v1.png)<!-- display-width:560 -->

新建 src/01-load.ts：

```ts
import { readFile } from "node:fs/promises";

const filePath = "./knowledge/npm.md";
const text = await readFile(filePath, "utf8");

console.log("=== Loaded Document ===");
console.log("文件：", filePath);
console.log("类型：", typeof text);
console.log("字符数：", text.length);
console.log("\n=== Preview ===\n");
console.log(text.slice(0, 300));
```

运行：

```powershell
npx tsx src/01-load.ts
```

![readFile 成功读取 npm.md，得到 string 和 8645 个字符](https://herblab.online/qwen-imgs/rag-from-zero-09-loaded-document-v1.png)<!-- display-width:600 -->

输出里的类型是 string，字符数是 8645。到这里还没有模型参与，readFile 只是把硬盘上的 Markdown 读成一个字符串。

如果提示找不到 npm.md，先确认终端位于 my-first-rag，并检查 knowledge/npm.md 是否真的存在。

完整项目可以直接下载：[my-first-rag.zip](/articles/rag-from-zero/files/my-first-rag.zip)。建议先跟着正文写一遍，卡住时再对照。

## 2. 把长文章切成 18 个 Chunk

检索不能只返回整篇长文。我们先用最容易看懂的办法：每 500 个字符切一块。

新建 src/02-chunk.ts：

```ts
import { readFile } from "node:fs/promises";

const filePath = "./knowledge/npm.md";
const text = await readFile(filePath, "utf8");
const CHUNK_SIZE = 500;
const chunks: string[] = [];

for (let start = 0; start < text.length; start += CHUNK_SIZE) {
  chunks.push(text.slice(start, start + CHUNK_SIZE));
}

console.log("=== Chunk Result ===");
console.log("原文字符数：", text.length);
console.log("Chunk Size：", CHUNK_SIZE);
console.log("Chunk 数量：", chunks.length);

for (const [index, chunk] of chunks.slice(0, 3).entries()) {
  console.log(`\n--- Chunk ${index} (${chunk.length} chars) ---\n`);
  console.log(chunk);
}
```

运行：

```powershell
npx tsx src/02-chunk.ts
```

![8645 个字符按 500 字切分后得到 18 个 Chunk](https://herblab.online/qwen-imgs/rag-from-zero-10-chunk-count-v1.png)<!-- display-width:520 -->

8645 个字符被切成 18 个字符串。Chunk 到这里先理解成“可单独拿去比较的一小段文本”就够了。

固定长度切块很容易写，也立刻暴露了缺点。看 Chunk 0 的末尾和 Chunk 1 的开头：

![真实输出中 Chunk 0 与 Chunk 1 把包名从中间切开](https://herblab.online/qwen-imgs/rag-from-zero-11-chunk-break-v1.png)<!-- display-width:600 -->

包名 @ppshux/tiny-text-utils 被切成了 @p 和 pshux/tiny-text-utils。程序没有语义概念，只会在第 500 个字符处下刀。

![固定 500 字切块会把完整包名从中间切开](https://herblab.online/qwen-imgs/rag-from-zero-03-chunk-split-v1.png)<!-- display-width:560 -->

这里暂时不修。我们先把整条链路跑通，第 9 节再改进切块。

## 3. 文本怎样变成向量？

现在第一次让模型参与。安装 Transformers.js：

```powershell
npm install @huggingface/transformers@4.2.0
```

![安装 Transformers.js 时保留了当次 npm 依赖告警，正文不把告警当作安装失败](https://herblab.online/qwen-imgs/rag-from-zero-12-transformers-install-v1.png)<!-- display-width:600 -->

截图中的 npm 告警来自当次实验的依赖树。安装完成不等于告警可以忽略；准备长期使用时，仍要执行 npm audit，判断问题是否影响自己的运行路径。

新建 src/03-embedding.ts：

```ts
import { pipeline } from "@huggingface/transformers";

const text =
  "passage: 你天天 npm install，但知道一个 npm 包是怎么发出来的吗？";

console.log("=== Input ===");
console.log(text);
console.log("\n正在加载 Embedding 模型...");

const extractor = await pipeline(
  "feature-extraction",
  "Xenova/multilingual-e5-small",
);

console.log("模型加载完成");

const output = await extractor(text, {
  pooling: "mean",
  normalize: true,
});
const vector = Array.from(output.data);

console.log("\n=== Embedding ===");
console.log("维度：", vector.length);
console.log("前 10 个数字：");
console.log(vector.slice(0, 10));
```

运行：

```powershell
npx tsx src/03-embedding.ts
```

第一次运行会下载 multilingual-e5-small。成功后，一句话会变成 384 个数字：

![multilingual-e5-small 把中文资料转换为 384 维向量](https://herblab.online/qwen-imgs/rag-from-zero-13-embedding-output-v1.png)<!-- display-width:520 -->

这串数字就是 Embedding。它不是原文的加密版本，也不能直接读回一句话；它把文本放进一个 384 维的语义空间，方便后面比较方向。

E5 模型建议给问题加 query: 前缀，给资料加 passage: 前缀。这里先对一条资料做演示，下一节会把两种前缀一起用上。

如果出现 fetch failed 或 connect timeout，程序还没有进入向量计算。先检查当前网络能否访问 huggingface.co，再重试模型下载。

## 4. 手写一次 Cosine Similarity

准备三个句子：一个问题、一条相关资料、一条天气信息。它们都会先变成向量，再用 Cosine Similarity 比较方向。

新建 src/04-similarity.ts：

```ts
import { pipeline } from "@huggingface/transformers";

const extractor = await pipeline(
  "feature-extraction",
  "Xenova/multilingual-e5-small",
);

async function embed(text: string): Promise<number[]> {
  const output = await extractor(text, {
    pooling: "mean",
    normalize: true,
  });

  return Array.from(output.data);
}

function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length || a.length === 0) {
    throw new Error("向量维度必须相同且不能为空");
  }

  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let index = 0; index < a.length; index += 1) {
    const valueA = a[index];
    const valueB = b[index];
    dotProduct += valueA * valueB;
    normA += valueA * valueA;
    normB += valueB * valueB;
  }

  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

const query = "query: npm 包是怎么发布出去的？";
const passageA = "passage: 使用 npm publish 可以把 npm 包发布到 registry。";
const passageB = "passage: 今天天气很好，我准备下午出去散步。";

const queryVector = await embed(query);
const vectorA = await embed(passageA);
const vectorB = await embed(passageB);

console.log("=== Query ===");
console.log(query);
console.log("\n=== Candidate A ===");
console.log(passageA);
console.log("Similarity:", cosineSimilarity(queryVector, vectorA));
console.log("\n=== Candidate B ===");
console.log(passageB);
console.log("Similarity:", cosineSimilarity(queryVector, vectorB));
```

运行后，相关的 Candidate A 得到 0.9091，天气 Candidate B 得到 0.7798：

![相关发布资料的相似度高于无关天气文本](https://herblab.online/qwen-imgs/rag-from-zero-14-similarity-output-v1.png)<!-- display-width:520 -->

这里看的是同一次比较中的相对顺序：A 比 B 更接近问题。不要把 0.8 当成所有模型都通用的合格线，不同模型、语言和资料集合的分数不能这样硬比。

如果两个结果完全相同，先检查三个输入有没有误写成同一段文本，再检查 query: 和 passage: 前缀。

## 5. 从 18 个 Chunk 找到 Top 3

现在把读取、切块、Embedding 和相似度串起来。先看 src/05-retrieve.ts 的结构：

![05-retrieve.ts 把读取、切块、向量化、相似度、排序和 Top 3 串在一起](https://herblab.online/qwen-imgs/rag-from-zero-15-retrieve-structure-v1.png)<!-- display-width:430 -->

新建 src/05-retrieve.ts：

```ts
import { readFile } from "node:fs/promises";
import { pipeline } from "@huggingface/transformers";

export type SearchResult = {
  index: number;
  score: number;
  content: string;
};

const text = await readFile("./knowledge/npm.md", "utf8");
const CHUNK_SIZE = 500;
const chunks: string[] = [];

for (let start = 0; start < text.length; start += CHUNK_SIZE) {
  chunks.push(text.slice(start, start + CHUNK_SIZE));
}

const extractor = await pipeline(
  "feature-extraction",
  "Xenova/multilingual-e5-small",
);

async function embed(textToEmbed: string): Promise<number[]> {
  const output = await extractor(textToEmbed, {
    pooling: "mean",
    normalize: true,
  });

  return Array.from(output.data);
}

function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length || a.length === 0) {
    throw new Error("向量维度必须相同且不能为空");
  }

  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let index = 0; index < a.length; index += 1) {
    const valueA = a[index];
    const valueB = b[index];
    dotProduct += valueA * valueB;
    normA += valueA * valueA;
    normB += valueB * valueB;
  }

  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

export const query = "npm 包是怎么发布出去的？";
const queryVector = await embed(`query: ${query}`);
const results: SearchResult[] = [];

console.log("=== Query ===");
console.log(query);
console.log(`\n正在搜索 ${chunks.length} 个 Chunks...`);

for (const [index, chunk] of chunks.entries()) {
  const chunkVector = await embed(`passage: ${chunk}`);
  results.push({
    index,
    score: cosineSimilarity(queryVector, chunkVector),
    content: chunk,
  });
}

results.sort((a, b) => b.score - a.score);
export const topK: SearchResult[] = results.slice(0, 3);

console.log("\n=== Top 3 ===\n");
for (const result of topK) {
  console.log(`[Chunk ${result.index}] Score: ${result.score.toFixed(4)}`);
  console.log(result.content);
  console.log("\n---\n");
}
```

运行：

```powershell
npx tsx src/05-retrieve.ts
```

实际结果是 Chunk 0、Chunk 9 和 Chunk 10：

![从 18 个 Chunk 中真实检索出 Chunk 0、9 和 10](https://herblab.online/qwen-imgs/rag-from-zero-16-top-three-v1.png)<!-- display-width:600 -->

这一步就是 Retrieval。问题先做一次 Embedding，18 个 Chunk 各做一次 Embedding，然后逐个算分、从高到低排序、取前三名。

你还会看到一个真实瑕疵：图片路径和 Markdown 标记也混进了结果。程序现在把它们当普通文字，没有清洗。

## 6. 把 Top 3 放进 Prompt

检索结果只是一个数组，模型还没有看到它。新建 src/06-augment.ts，把 Top 3 拼成 Context，再和原问题放进同一段 Prompt：

```ts
import { query, topK } from "./05-retrieve.js";

const context = topK
  .map((result) => `[Chunk ${result.index}]\n${result.content}`)
  .join("\n\n---\n\n");

export { query };
export const prompt = `你是一个知识库问答助手。

请只根据下面提供的 Context 回答问题。
如果 Context 中没有答案，请直接说不知道。

=== Context ===

${context}

=== Question ===

${query}`;

console.log("\n=== A: Augmented Prompt ===\n");
console.log(prompt);
```

![06-augment.ts 把 Top 3 组合为 Context，再与问题拼成 Augmented Prompt](https://herblab.online/qwen-imgs/rag-from-zero-17-augment-structure-v1.png)<!-- display-width:500 -->

运行 npm run augment 时，06 会先导入并执行 05，拿到 topK，再打印完整的 Augmented Prompt。

这一步就是 Augmentation：没有训练模型，只是字符串拼接。资料仍在本地文件里，每次提问时才被临时放进 Prompt。

如果 Prompt 里没有三个 Chunk，先回到 05 看 topK 是否真的有三个元素。上游没检索到，继续改生成代码也补不回来。

## 7. 通过 CodeBuddy 生成回答

生成回答前，安装 CodeBuddy Agent SDK：

```powershell
npm install @tencent-ai/agent-sdk@0.3.244
```

![安装 CodeBuddy Agent SDK，并保留当次 npm 依赖审计结果](https://herblab.online/qwen-imgs/rag-from-zero-18-agent-sdk-install-v1.png)<!-- display-width:540 -->

CodeBuddy Agent SDK 当前仍是 Preview，接口以后可能调整。本文按 2026 年 8 月 18 日的 0.3.244 编写。

SDK 可以复用 CodeBuddy CLI 的登录状态。还没登录时，先安装并启动 CLI：

```powershell
npm install -g @tencent-ai/codebuddy-code
codebuddy
```

按终端提示完成登录，看到 Successfully signed in. 再继续：

![安装并启动 CodeBuddy CLI 后，终端显示 Successfully signed in](https://herblab.online/qwen-imgs/rag-from-zero-19-codebuddy-login-v1.png)<!-- display-width:600 -->

新建 src/07-generate.ts：

```ts
import { query as codebuddyQuery } from "@tencent-ai/agent-sdk";
import { prompt } from "./06-augment.js";

console.log("\n=== G: Generation ===\n");

const conversation = codebuddyQuery({
  prompt,
  options: {
    maxTurns: 1,
  },
});

for await (const message of conversation) {
  if (message.type !== "assistant") {
    continue;
  }

  for (const block of message.message.content) {
    if (block.type === "text") {
      console.log(block.text);
    }
  }
}
```

CodeBuddy Agent SDK 的 query() 返回一个异步消息流。代码只打印 assistant 消息里的 text，不开放文件或终端权限；maxTurns: 1 也把这次演示限制为一轮。

运行：

```powershell
npx tsx src/07-generate.ts
```

程序会执行 05 的检索和 06 的 Prompt 拼接，再把 Prompt 交给生成能力。真实输出如下：

![最终生成结果根据 Context 整理出 npm 包发布与验证步骤](https://herblab.online/qwen-imgs/rag-from-zero-20-final-answer-v1.png)<!-- display-width:600 -->

答案提到了准备包、执行 npm publish --access public、确认发布成功和换项目安装。这些内容没有写死在 07 里，来自检索到的 npm.md 片段。

这里要把角色说准：CodeBuddy Agent SDK 是调用入口，不等于“CodeBuddy 本身就是 LLM”。它负责把 Prompt 送入生成链路，并把消息流交回 TypeScript 程序。

如果仍提示 Authentication required，先在同一个 Windows 账户下运行 codebuddy 完成 CLI 登录，再重新执行 07。

## 8. 哪些地方用了模型？

把七步并排看，边界很清楚：

![本文最小 RAG 中，Embedding 与 Generation 使用模型，其余为普通程序逻辑](https://herblab.online/qwen-imgs/rag-from-zero-04-model-boundary-v1.png)<!-- display-width:600 -->

在这个最小实现里，模型只出现在两处：

- Embedding 把问题和 Chunk 转成向量；
- Generation 根据 Augmented Prompt 生成回答文字。

readFile、固定长度切块、Cosine Similarity、排序、取 Top 3 和拼 Prompt 都是普通程序。换成别的检索策略或生成服务，这些边界还会变化，所以图里特意写的是“这个最小 RAG”。

## 9. 这只是第一版

现在的项目已经跑通 Retrieval → Augmentation → Generation，但还不适合直接扛真实业务。

最明显的几个问题：

- 500 字硬切会截断包名和句子，可以改成按 Markdown 标题、段落切分，并加入少量重叠；
- 每次查询都会重算 18 个 Chunk 的 Embedding，资料一多就该预先计算并保存向量；
- Markdown 图片路径也会参与检索，建索引前需要清洗无关标记；
- 05 和 06 在导入时就执行，正式项目更适合封装成 retrieve()、augment() 和 generate()；
- 现在只读一篇 npm.md，多文档还需要文件遍历、来源记录和权限边界。

到这里，每一层都有可检查的输出：readFile 是 8645，切块结果是 18，检索返回 Top 3，生成阶段给出了一份基于 Context 的回答。以后再接向量数据库或 Reranker，也能准确判断它们替换的是哪一段。

## 参考资料

- [Hugging Face：Xenova/multilingual-e5-small](https://huggingface.co/Xenova/multilingual-e5-small)
- [Transformers.js：Pipelines API](https://huggingface.co/docs/transformers.js/api/pipelines)
- [CodeBuddy Agent SDK](https://www.codebuddy.cn/docs/cli/sdk)
- [CodeBuddy Agent SDK TypeScript API](https://www.codebuddy.cn/docs/cli/sdk-typescript)
- [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401)

---

完整网页版本、清晰原图、Word 和离线 HTML：
[从 0 手搓一个 RAG｜文潇的技术博客](https://aiarchblog-6hz4s01hv.maozi.io/articles/rag-from-zero/)
