从 0 手搓一个 RAG

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

从 0 手搓一个 RAG:Markdown 经过切块、Top 3 和 Prompt 后生成回答

    1. 先把一篇 Markdown 读进程序
    1. 把长文章切成 18 个 Chunk
    1. 文本怎样变成向量?
    1. 手写一次 Cosine Similarity
    1. 从 18 个 Chunk 找到 Top 3
    1. 把 Top 3 放进 Prompt
    1. 通过 CodeBuddy 生成回答
    1. 哪些地方用了模型?
    1. 这只是第一版

这篇直接做一个能运行的最小 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.mdollama.mdskill.md;这次只读取 npm.md

实验目录放入 npm.mdollama.mdskill.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:

从 0 手搓一个 RAG|文潇的技术博客

相关推荐
番茄不是西红柿kk2 小时前
什么是Token?
人工智能·ai·chatgpt·agent·token·codex·deepseek
IvanCodes2 小时前
RAG 实战教程(二):向量相似度、向量数据库与 Chroma 实战
人工智能·agent
程序猿DD3 小时前
OctaFuse Gateway 2.6.0:完善 Vertex AI 鉴权、图片计费与日常调试
llm·agent
阿里云大数据AI技术4 小时前
阿里云PAI发布通用蒸馏框架EasyDistill2.0,提供主流大模型蒸馏场景最佳实践
人工智能·agent
付玉祥5 小时前
Agent 的部署流程:从本地启动到服务化
agent
樊小肆5 小时前
DeepSeeker-Code源码导读05-计划模式与子Agent
人工智能·agent
执行部之龙6 小时前
AI 前端流式输出手写与扩展知识
前端·javascript·面试·agent
百工蜂Agent7 小时前
对话才几轮,上下文窗口怎么就满了?
agent
小四的小六7 小时前
两个Agent同时写同一个Tool,数据被覆盖了——我是怎么用乐观锁修好的
openai·agent·ai编程