字数 2785,阅读大约需 14 分钟
用一篇 Markdown 和七个 TypeScript 文件,把检索、增强、生成完整跑一遍。

从 0 手搓一个 RAG:Markdown 经过切块、Top 3 和 Prompt 后生成回答
-
- 先把一篇 Markdown 读进程序
-
- 把长文章切成 18 个 Chunk
-
- 文本怎样变成向量?
-
- 手写一次 Cosine Similarity
-
- 从 18 个 Chunk 找到 Top 3
-
- 把 Top 3 放进 Prompt
-
- 通过 CodeBuddy 生成回答
-
- 哪些地方用了模型?
-
- 这只是第一版
这篇直接做一个能运行的最小 RAG。
输入"npm 包是怎么发布出去的?",程序先从我的文章里找到三个相关片段,再把它们交给生成模型组织成答案。
不用 LangChain,不用 LlamaIndex,也不接向量数据库。我们只用 Node.js、TypeScript、一篇 Markdown,以及生成阶段的 CodeBuddy Agent SDK。
RAG 的全称是 Retrieval-Augmented Generation。按今天的代码,它会依次经过:
markdown
Retrieval → Augmentation → Generation先找资料 → 把资料放进问题 → 根据资料回答

RAG 的三段主线:Retrieval 检索、Augmentation 增强、Generation 生成
下面从空目录开始。第一次下载 Embedding 模型可能需要多等一会儿。
1. 先把一篇 Markdown 读进程序
创建项目:
perl
mkdir my-first-ragcd .\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
安装 TypeScript 运行环境,并开启 ES Module:
sql
npm install -D typescript@7.0.2 tsx@4.23.12 @types/node@26.2.0npm pkg set type=module

安装 TypeScript、tsx 和 Node.js 类型后,npm 报告 0 个漏洞
三个包各做一件事:typescript 负责类型检查,tsx 直接运行 TypeScript,@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 处理源码。

package.json 已加入 type=module,并创建 tsconfig.json
新建 src/01-load.ts:
arduino
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));
运行:
css
npx tsx src/01-load.ts

readFile 成功读取 npm.md,得到 string 和 8645 个字符
类型是 string,字符数是 8645。这里还没有模型参与,readFile 只是把硬盘上的 Markdown 读成字符串。
如果提示找不到 npm.md,先确认终端位于 my-first-rag,再检查 knowledge 目录。
2. 把长文章切成 18 个 Chunk
先用最直白的方法:每 500 个字符切一块。
typescript
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);}
运行 src/02-chunk.ts 后,结果如下:

8645 个字符按 500 字切分后得到 18 个 Chunk
8645 个字符被切成 18 个字符串。Chunk 先理解成"可单独拿去比较的一小段文本"即可。
固定长度切块也立刻暴露了缺点:

真实输出中 Chunk 0 与 Chunk 1 把包名从中间切开
包名 @ppshux/tiny-text-utils 被切成了 @p 和 pshux/tiny-text-utils。程序只知道第 500 个字符到了,不知道这里是一个完整包名。

固定 500 字切块会把完整包名从中间切开
这个问题先留着。现在的目标是把整条链路看清楚。
3. 文本怎样变成向量?
安装 Transformers.js:
css
npm install @huggingface/transformers@4.2.0

安装 Transformers.js 时保留了当次 npm 依赖告警,正文不把告警当作安装失败
截图中的 npm 告警来自当次实验的依赖树。安装完成不代表告警可以一直不管;准备长期使用时,仍要执行 npm audit,判断是否影响自己的运行路径。
新建 src/03-embedding.ts:
arduino
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));
第一次运行会下载模型。成功后,一句话会变成 384 个数字:

multilingual-e5-small 把中文资料转换为 384 维向量
这串数字就是 Embedding。它把文本放进一个 384 维的语义空间,方便后面比较方向。
E5 模型建议给问题加 query: 前缀,给资料加 passage: 前缀。
如果出现 fetch failed 或 connect timeout,程序还没有进入向量计算。先检查当前网络能否访问 Hugging Face,再重试模型下载。
4. 手写一次 Cosine Similarity
准备一个问题、一个相关答案、一条天气信息。它们先变成向量,再用 Cosine Similarity 比较方向。
新建 src/04-similarity.ts:
typescript
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 当成所有模型都通用的合格线。
5. 从 18 个 Chunk 找到 Top 3
现在把读取、切块、Embedding 和相似度串起来。先看 src/05-retrieve.ts 的结构:

05-retrieve.ts 把读取、切块、向量化、相似度、排序和 Top 3 串在一起
新建 src/05-retrieve.ts:
typescript
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");}
运行后,真实检索结果是 Chunk 0、Chunk 9 和 Chunk 10:

从 18 个 Chunk 中真实检索出 Chunk 0、9 和 10
这一步就是 Retrieval。问题做一次 Embedding,18 个 Chunk 各做一次,再逐个算分、排序、取前三名。
结果里还混进了图片路径和 Markdown 标记。现在的程序没有清洗,它们也会被当成普通文字。
6. 把 Top 3 放进 Prompt
检索结果只是一个数组,生成模型还没有看到它。新建 src/06-augment.ts,把 Top 3 拼成 Context,再和原问题放进同一段 Prompt:
javascript
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
这一步就是 Augmentation。没有训练模型,只是把资料和问题拼成一段更完整的输入。
如果 Prompt 里没有三个 Chunk,先回到 05 检查 topK。上游没有找出资料,后面的生成也没有依据。
7. 通过 CodeBuddy 生成回答
安装 CodeBuddy Agent SDK:
css
npm install @tencent-ai/agent-sdk@0.3.244

安装 CodeBuddy Agent SDK,并保留当次 npm 依赖审计结果
SDK 当前仍是 Preview,接口以后可能调整。本文按 2026 年 8 月 18 日的 0.3.244 编写。
还没登录时,先安装并启动 CodeBuddy CLI:
bash
npm install -g @tencent-ai/codebuddy-codecodebuddy
按终端提示完成登录,看到 Successfully signed in. 再继续:

安装并启动 CodeBuddy CLI 后,终端显示 Successfully signed in
新建 src/07-generate.ts:
javascript
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); } }}
query() 返回异步消息流。代码只打印 assistant 消息里的 text,并把这次演示限制为一轮。
运行:
css
npx tsx src/07-generate.ts
程序会先检索、拼接 Prompt,再生成回答:

最终生成结果根据 Context 整理出 npm 包发布与验证步骤
答案提到了准备包、执行 npm publish --access public、确认发布成功和换项目安装。这些内容来自检索到的 npm.md 片段,没有写死在 07 里。
CodeBuddy Agent SDK 是调用入口。它负责把 Prompt 送入生成链路,再把消息流交回 TypeScript 程序。
如果提示 Authentication required,先在同一个 Windows 账户下运行 codebuddy 完成 CLI 登录。
8. 哪些地方用了模型?
把七步放在一起看:

本文最小 RAG 中,Embedding 与 Generation 使用模型,其余为普通程序逻辑
这个最小实现只有两处用到模型:Embedding 把问题和 Chunk 转成向量;Generation 根据 Augmented Prompt 生成文字。
readFile、固定长度切块、Cosine Similarity、排序、取 Top 3 和拼 Prompt 都是普通程序。其他 RAG 系统可能加入更多模型,所以这张图只描述本文的实现。
9. 这只是第一版
现在的项目已经跑通完整 RAG 链路,但离真实业务还有距离:
- 500 字硬切会截断包名和句子,可以改成按标题、段落切分;
- 每次查询都会重算 18 个 Chunk 的 Embedding,资料多了需要预先计算并保存向量;
- Markdown 图片路径也会参与检索,建索引前需要清洗;
- 05 和 06 在导入时就执行,正式项目更适合封装成函数;
- 现在只读一篇 npm.md,多文档还要记录来源和权限。
到这里,每一层都有可检查的输出:readFile 是 8645,切块结果是 18,检索返回 Top 3,生成阶段给出了一份基于 Context 的回答。
完整网页版本、清晰原图、Word 和离线 HTML: