不用框架和向量数据库,用一篇 Markdown 与七个 TypeScript 文件手写 Chunk、Embedding、相似度、Top 3、Prompt 和生成回答。
用一篇 Markdown 和七个 TypeScript 文件,把检索、增强、生成完整跑一遍。
这篇直接做一个能运行的最小 RAG:输入“npm 包是怎么发布出去的?”,程序先从我的文章里找到三个相关片段,再把它们交给生成模型组织成答案。
不用 LangChain,不用 LlamaIndex,也不接向量数据库。我们只用 Node.js、TypeScript、一篇 Markdown,以及生成阶段的 CodeBuddy Agent SDK。这样每一步到底做了什么,都能从代码里看见。
RAG 的全称是 Retrieval-Augmented Generation。为了跟代码对上,本文把它按过程拆成:
Retrieval → Augmentation → Generation
先找资料 → 把资料放进问题 → 根据资料回答下面从空目录开始。第一次下载 Embedding 模型所需时间取决于网络,其他步骤都很短。
1. 先把一篇 Markdown 读进程序
先创建项目:
mkdir my-first-rag
cd .\my-first-rag\
npm init -y看到 package.json,就说明普通 Node.js 项目已经建好了。
接着创建 knowledge 目录,把一篇 Markdown 放进去。我的实验目录里有 npm.md、ollama.md 和 skill.md;这次只读取 npm.md,避免一上来处理多文档。
你可以换成自己的文章。为了复现实验中的 8645 字、18 个 Chunk 和检索结果,仓库示例保留了同一份 npm.md。
安装 TypeScript 运行环境:
npm install -D typescript@7.0.2 tsx@4.23.12 @types/node@26.2.0
npm pkg set type=module这里的三个包分工很直白:typescript 负责类型检查,tsx 直接运行 .ts 文件,@types/node 提供 Node.js API 的类型。
再创建 tsconfig.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 才能直接使用。
新建 src/01-load.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));运行:
npx tsx src/01-load.ts输出里的类型是 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:
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);
}运行:
npx tsx src/02-chunk.ts8645 个字符被切成 18 个字符串。Chunk 到这里先理解成“可单独拿去比较的一小段文本”就够了。
固定长度切块很容易写,也立刻暴露了缺点。看 Chunk 0 的末尾和 Chunk 1 的开头:
包名 @ppshux/tiny-text-utils 被切成了 @p 和 pshux/tiny-text-utils。程序没有语义概念,只会在第 500 个字符处下刀。
这里暂时不修。我们先把整条链路跑通,第 9 节再改进切块。
3. 文本怎样变成向量?
现在第一次让模型参与。安装 Transformers.js:
npm install @huggingface/transformers@4.2.0截图中的 npm 告警来自当次实验的依赖树。安装完成不等于告警可以忽略;准备长期使用时,仍要执行 npm audit,判断问题是否影响自己的运行路径。
新建 src/03-embedding.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));运行:
npx tsx src/03-embedding.ts第一次运行会下载 multilingual-e5-small。成功后,一句话会变成 384 个数字:
这串数字就是 Embedding。它不是原文的加密版本,也不能直接读回一句话;它把文本放进一个 384 维的语义空间,方便后面比较方向。
E5 模型建议给问题加 query: 前缀,给资料加 passage: 前缀。这里先对一条资料做演示,下一节会把两种前缀一起用上。
如果出现 fetch failed 或 connect timeout,程序还没有进入向量计算。先检查当前网络能否访问 huggingface.co,再重试模型下载。
4. 手写一次 Cosine Similarity
准备三个句子:一个问题、一条相关资料、一条天气信息。它们都会先变成向量,再用 Cosine Similarity 比较方向。
新建 src/04-similarity.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 的结构:
新建 src/05-retrieve.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");
}运行:
npx tsx src/05-retrieve.ts实际结果是 Chunk 0、Chunk 9 和 Chunk 10:
这一步就是 Retrieval。问题先做一次 Embedding,18 个 Chunk 各做一次 Embedding,然后逐个算分、从高到低排序、取前三名。
你还会看到一个真实瑕疵:图片路径和 Markdown 标记也混进了结果。程序现在把它们当普通文字,没有清洗。
6. 把 Top 3 放进 Prompt
检索结果只是一个数组,模型还没有看到它。新建 src/06-augment.ts,把 Top 3 拼成 Context,再和原问题放进同一段 Prompt:
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);运行 npm run augment 时,06 会先导入并执行 05,拿到 topK,再打印完整的 Augmented Prompt。
这一步就是 Augmentation:没有训练模型,只是字符串拼接。资料仍在本地文件里,每次提问时才被临时放进 Prompt。
如果 Prompt 里没有三个 Chunk,先回到 05 看 topK 是否真的有三个元素。上游没检索到,继续改生成代码也补不回来。
7. 通过 CodeBuddy 生成回答
生成回答前,安装 CodeBuddy Agent SDK:
npm install @tencent-ai/agent-sdk@0.3.244CodeBuddy Agent SDK 当前仍是 Preview,接口以后可能调整。本文按 2026 年 8 月 18 日的 0.3.244 编写。
SDK 可以复用 CodeBuddy CLI 的登录状态。还没登录时,先安装并启动 CLI:
npm install -g @tencent-ai/codebuddy-code
codebuddy按终端提示完成登录,看到 Successfully signed in. 再继续:
新建 src/07-generate.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 也把这次演示限制为一轮。
运行:
npx tsx src/07-generate.ts程序会执行 05 的检索和 06 的 Prompt 拼接,再把 Prompt 交给生成能力。真实输出如下:
答案提到了准备包、执行 npm publish --access public、确认发布成功和换项目安装。这些内容没有写死在 07 里,来自检索到的 npm.md 片段。
这里要把角色说准:CodeBuddy Agent SDK 是调用入口,不等于“CodeBuddy 本身就是 LLM”。它负责把 Prompt 送入生成链路,并把消息流交回 TypeScript 程序。
如果仍提示 Authentication required,先在同一个 Windows 账户下运行 codebuddy 完成 CLI 登录,再重新执行 07。
8. 哪些地方用了模型?
把七步并排看,边界很清楚:
在这个最小实现里,模型只出现在两处:
- 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,也能准确判断它们替换的是哪一段。