今天的唯一目标

不用框架和向量数据库,用一篇 Markdown 与七个 TypeScript 文件手写 Chunk、Embedding、相似度、Top 3、Prompt 和生成回答。

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

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

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

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

text可复制后修改
Retrieval → Augmentation → Generation
先找资料   → 把资料放进问题 → 根据资料回答
RAG 的三段主线:Retrieval 检索、Augmentation 增强、Generation 生成

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

1. 先把一篇 Markdown 读进程序

先创建项目:

powershell可复制后修改
mkdir my-first-rag
cd .\my-first-rag\
npm init -y
运行 npm init -y 后生成 my-first-rag 的 package.json

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

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

实验目录放入 npm.md、ollama.md 和 skill.md,第一版只读取 npm.md

你可以换成自己的文章。为了复现实验中的 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 个漏洞

这里的三个包分工很直白: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

新建 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 个字符

输出里的类型是 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

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

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

真实输出中 Chunk 0 与 Chunk 1 把包名从中间切开

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

固定 500 字切块会把完整包名从中间切开

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

3. 文本怎样变成向量?

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

powershell可复制后修改
npm install @huggingface/transformers@4.2.0
安装 Transformers.js 时保留了当次 npm 依赖告警,正文不把告警当作安装失败

截图中的 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 维向量

这串数字就是 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:

相关发布资料的相似度高于无关天气文本

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

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

5. 从 18 个 Chunk 找到 Top 3

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

05-retrieve.ts 把读取、切块、向量化、相似度、排序和 Top 3 串在一起

新建 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

这一步就是 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

运行 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 依赖审计结果

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

新建 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 包发布与验证步骤

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

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

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

8. 哪些地方用了模型?

把七步并排看,边界很清楚:

本文最小 RAG 中,Embedding 与 Generation 使用模型,其余为普通程序逻辑

在这个最小实现里,模型只出现在两处:

  • 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,也能准确判断它们替换的是哪一段。

参考资料