LOADING
3758 words
19 minutes
RAG 检索增强(一)

作者手记:这不是一篇抄来的博客。本文所有代码均来自 learn-langchain.js 项目实战,采用 纯 TypeScript + LangChain.js 7.0 + 本地 TF-IDF Embedding 实现——零 API 调用,开箱即跑。

为什么你的 LLM 总在「装懂」?

2023 年以来,大语言模型(LLM)展现了惊人的能力——写代码、写文章、做翻译、回答各种问题……仿佛一个无所不知的万能助手。

但只要有实际落地经验,你一定遇到过这些场景:

  • 问:「2025 年新出的数据合规政策是什么?」它答不上来,因为训练数据截止到 2023 年。
  • 问:「我的银行转账失败了,该怎么办?」它给了一堆通用建议,但完全不知道你的银行具体规则。
  • 问:「上季度我们部门的销售额是多少?」它编了一个数字,你一看就知道是错的。

这不是模型「笨」,而是它的设计局限

局限说明后果
知识时效性只学习到训练数据截止前的信息不知道今天的新闻、刚发布的政策
幻觉问题面对不确定的问题时会「编造」答案编造不存在的法规、错误的公司内部数据

那怎么办?难道每次有新知识就要重新训练一次模型吗?

显然不现实。训练一次 GPT-4 级别的模型,成本是千万美元级别。

于是,一个更聪明、更经济的方案诞生了——RAG(检索增强生成)

一、RAG 是什么?

1.1 一句话定义

RAG = 检索(Retrieval)+ 增强(Augmented)+ 生成(Generation)

它的核心思想是:

不给 LLM 喂「记忆」,而是给它「参考资料」。让它在回答问题时,先查资料,再作答。

1.2 一个类比

想象你是一个刚入职的新员工:

老板突然问你:「上季度华东区的销售额是多少?」

你不知道——因为你是新人,还没背下这些数据。

但你可以立刻打开公司报表,找到这个数据,然后告诉老板。

老板不会觉得你「无能」,反而觉得你「靠谱」——因为你的答案有据可查。

RAG 做的正是同样的事情。

1.3 核心流程一览

┌─────────────────────────────────────────────────────────────────┐
│ RAG 端到端流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 【离线阶段】文档 → 切分 → 向量化 → 存入向量库 │
│ │
│ 【在线阶段】用户提问 → 向量化 → 检索相似片段 → 构建 Prompt │
│ → LLM 生成答案 + 引用来源 │
│ │
└─────────────────────────────────────────────────────────────────┘

1.4 RAG vs 传统搜索 vs 微调

维度传统搜索RAG微调(Fine-tuning)
返回结果文档列表精准答案模型直接生成
知识更新即时即时需重新训练
可解释性高(可看到原文)高(可溯源)低(黑盒)
成本中(API + 存储)高(GPU 训练)
适用场景文档检索问答系统风格迁移、行为定制

选型建议

  • 知识频繁更新 → RAG
  • 知识固定但需要改模型行为 → 微调
  • 只需要找文档 → 传统搜索

二、RAG 核心流程详解

RAG 分为两个阶段:离线索引阶段在线检索生成阶段

2.1 阶段一:索引阶段(离线准备)

这个阶段提前准备好知识库,把文档处理成可检索的格式。

┌─────────────────────────────────────────────────────────────────┐
│ 索引阶段(Indexing) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 原始文档:PDF、Word、Markdown、数据库、网页... │
│ │ │
│ ▼ │
│ ① 文档加载与解析(Loading & Parsing) │
│ 把各种格式的文档统一提取为纯文本 │
│ │ │
│ ▼ │
│ ② 文本切分(Chunking) │
│ 按语义边界切分成 200-500 字的小片段 │
│ │ │
│ ▼ │
│ ③ 向量化(Embedding) │
│ 用 Embedding 模型把文本转成高维向量 │
│ │ │
│ ▼ │
│ ④ 存入向量数据库 │
│ MemoryVectorStore / Chroma / Milvus / pgvector │
│ │
└─────────────────────────────────────────────────────────────────┘

关键细节

  • 切分策略:不要按固定字数切,要按语义边界(段落、标题)切
  • 重叠设计:相邻片段重叠 10-20%,避免信息断裂
  • 元数据标注:每个片段记录来源文档、章节、更新时间

2.2 阶段二:检索 + 生成阶段(在线实时)

用户提问时,系统实时检索并生成答案。

┌─────────────────────────────────────────────────────────────────┐
│ 检索 + 生成阶段(Retrieval & Generation) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 用户提问:「转账 5 万需要审批吗?」 │
│ │ │
│ ▼ │
│ ① 把用户问题向量化 │
│ │ │
│ ▼ │
│ ② 在向量数据库中检索最相似的 3-5 个片段 │
│ │ │
│ ▼ │
│ ③ 构建 Prompt:System + 检索结果 + 用户问题 │
│ │ │
│ ▼ │
│ ④ LLM 生成答案 + 引用来源 │
│ │
│ 输出示例: │
│ 「是的,转账 5 万元需要审批。 │
│ 根据【转账规则】,单笔转账超过 5 万元需经过审批流程。」 │
│ │
└─────────────────────────────────────────────────────────────────┘

三、实战实现:LangChain.js 7.0 + 纯本地 TF-IDF

亮点:我没有用 OpenAI Embedding API,而是实现了一个纯 TypeScript 的 TF-IDF 向量模型——零 API 调用,零网络依赖,开箱即跑!

3.1 技术栈(真实落地版)

组件选型理由
框架LangChain.js 7.0TypeScript 原生支持,工程化好
向量模型本地 TF-IDF(自研)零 API 依赖,原型验证极快
向量存储MemoryVectorStore内存存储,无需额外数据库
LLM火山引擎 GLM-4.7国内合规,响应快
Web 服务Express成熟稳定,支持 SSE 流式输出
文档格式PDF + Markdown + TXT + 代码覆盖绝大多数知识库场景

3.2 核心 1:纯 TypeScript 实现 TF-IDF Embedding

这是整个项目最有特色的部分——不依赖任何外部 API,用 60 行代码实现一个可用的向量模型

import { Embeddings } from "@langchain/core/embeddings";
class TfIdfEmbeddings extends Embeddings {
private vocab = new Map<string, number>(); // 词汇表
private docCount = 0; // 文档计数
private df = new Map<number, number>(); // 文档频率
constructor() { super({}); }
// 中文分词:汉字 + 英文/数字
private tokenize(text: string): string[] {
const tokens: string[] = [];
const re = /[一-鿿]|[a-zA-Z0-9]+/g;
for (const match of text.toLowerCase().matchAll(re)) {
tokens.push(match[0]);
}
return tokens;
}
// 文本 → TF-IDF 向量
private textToVec(text: string): number[] {
const tokens = this.tokenize(text);
// 1. 构建词汇表
for (const t of tokens) {
if (!this.vocab.has(t)) {
this.vocab.set(t, this.vocab.size);
this.df.set(this.vocab.size - 1, 0);
}
}
// 2. 计算 TF(词频)
const tf = new Array(this.vocab.size).fill(0);
for (const t of tokens) tf[this.vocab.get(t)!]++;
// 3. 计算 TF-IDF + L2 归一化
const n = this.docCount || 1;
for (let i = 0; i < tf.length; i++) {
if (tf[i] > 0) {
tf[i] = (1 + Math.log(tf[i])) * (Math.log(n / (this.df.get(i) || 1)) + 1);
}
}
const norm = Math.sqrt(tf.reduce((s, v) => s + v * v, 0)) || 1;
return tf.map(v => v / norm);
}
async embedQuery(text: string): Promise<number[]> {
return this.textToVec(text);
}
async embedDocuments(documents: string[]): Promise<number[][]> {
this.docCount += documents.length;
// 更新文档频率
for (const doc of documents) {
const seen = new Set<number>();
const tokens = this.tokenize(doc);
for (const t of tokens) {
const idx = this.vocab.get(t);
if (idx !== undefined && !seen.has(idx)) {
seen.add(idx);
this.df.set(idx, (this.df.get(idx) || 0) + 1);
}
}
}
return documents.map(doc => this.textToVec(doc));
}
}

为什么这很重要?

  1. 零成本原型:不需要 OpenAI API Key,不需要网络,装完依赖就能跑
  2. 学习价值:理解 Embedding 本质就是「文本→数字向量」的映射
  3. 渐进式升级:验证完流程后,一行代码换成 OpenAI/BGE 即可

💡 工程心得:做 AI 应用,先跑通流程,再追求精度。TF-IDF 虽然不如深度学习模型精准,但足够验证整个 RAG 链路是否正确。

3.3 核心 2:MemoryVectorStore — 零数据库的向量存储

LangChain 7.0 提供了 MemoryVectorStore——纯内存的向量存储,不需要安装 Chroma、Milvus 等数据库:

import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
// 从文档创建向量存储
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, embeddings);
// 创建检索器:返回最相似的 3 个片段
const retriever = vectorStore.asRetriever({ k: 3 });
// 检索相关文档
const relevantDocs = await retriever.invoke("用户的问题");

适合场景

  • ✅ 原型验证 / 学习演示
  • ✅ 小知识库(1000 个文档以内)
  • ✅ 单实例部署

不适合场景

  • ❌ 大规模知识库(需要持久化)
  • ❌ 多实例部署(内存不共享)
  • ❌ 需要增量更新(重启就没了)

3.4 核心 3:文本切分策略(中文优化版)

中文切分不能照搬英文的策略。我的配置:

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 300, // 每个片段约 300 字
chunkOverlap: 150, // 重叠 50%!(防止语义断裂)
separators: [
"\n\n", "\n", // 优先按段落、换行切
"。", "!", "?", // 中文句号、感叹号、问号
",", "、", ";", // 中文逗号、顿号、分号
" " // 最后按空格
],
});
const splitDocs = await textSplitter.splitDocuments(docs);

关键点

  • 中文的语义边界是句号,不是空格
  • 重叠设为 50% 比 20% 效果好——宁可多检索也不漏掉
  • 生产环境可以用专门的中文分词库(如 nodejieba

3.5 核心 4:RAG Chain — LangChain 式的优雅

LangChain 的 Runnable 接口让代码变得非常清晰:

import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnablePassthrough } from "@langchain/core/runnables";
// 1. Prompt 模板(防幻觉是关键!)
const ragPrompt = PromptTemplate.fromTemplate(
`你是一个知识库问答助手。请根据提供的上下文回答用户问题。
上下文:
{context}
用户问题:{question}
要求:
- 仅根据上述上下文回答,不要编造信息
- 如果上下文中没有相关信息,请回答"抱歉,知识库中没有相关内容"
- 回答要简洁、准确、有条理`
);
// 2. 构建 RAG 链
const ragChain = RunnablePassthrough.assign({
context: (input: { question: string }) =>
retriever.invoke(input.question).then(docs =>
docs.map(d => d.pageContent).join("\n---\n")
),
}).pipe(ragPrompt).pipe(llm).pipe(parser);
// 3. 调用
const answer = await ragChain.invoke({ question: "李元静的专业是什么?" });

执行流程可视化

用户问题
RunnablePassthrough.assign
├─ 保持 question 不变
└─ 调用 retriever → 生成 context
ragPrompt 模板 → 拼装完整 Prompt
LLM 生成回答
StringOutputParser → 纯文本输出

3.6 核心 5:多格式文档加载

一个实用的 RAG 系统不能只支持 PDF:

const TEXT_EXTENSIONS = new Set([
".md", ".markdown", ".txt", ".csv", ".json",
".xml", ".html", ".log", ".yaml", ".yml",
".py", ".js", ".ts", ".java", ".c", ".cpp", ".h",
]);
async function loadDocuments(docsDir: string): Promise<Document[]> {
const allDocs: Document[] = [];
const files = collectFiles(docsDir);
for (const filePath of files) {
const ext = path.extname(file).toLowerCase();
if (ext === ".pdf") {
allDocs.push(...await new PDFLoader(filePath).load());
} else if (TEXT_EXTENSIONS.has(ext)) {
allDocs.push(...await new TextLoader(filePath).load());
}
}
return allDocs;
}

支持的格式

  • 📄 PDF(简历、论文、白皮书)
  • 📃 Markdown(文档、笔记)
  • 📝 TXT(日志、纯文本)
  • 💻 代码文件(Python、JS、TS、Java、C/C++)

3.7 核心 6:Express 后端 + 流式输出

生产环境需要一个 Web 服务,而且流式输出是必须的——没人愿意等 10 秒才看到答案:

import express from "express";
const app = express();
app.use(express.json());
// 1. 单轮 RAG 问答
app.post("/api/chat", async (req, res) => {
const { question } = req.body;
const answer = await ragChain.invoke({ question });
res.json({ answer });
});
// 2. 流式 RAG(SSE)
app.post("/api/chat/stream", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = await ragChain.stream({ question });
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
}
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
});
// 3. 检索调试接口(看检索到了什么片段)
app.post("/api/retrieve", async (req, res) => {
const docs = await retriever.invoke(question);
res.json({ question, results: docs.map((d, i) => ({
index: i + 1, content: d.pageContent, metadata: d.metadata,
})) });
});

启动输出

🚀 RAG 服务已启动: http://localhost:3000
📌 POST /api/chat — 单轮 RAG 问答
📌 POST /api/chat/stream — 流式 RAG
📌 POST /api/retrieve — 检索调试

3.8 运行效果演示

📄 从 library/ 加载文档...
📃 加载文本: business.md
📄 加载 PDF: reference.pdf
✓ 共加载 7 个文档
✓ 分割完成: 36 个文本块
🔍 正在构建向量索引...
✓ 向量索引创建成功!
────────────────────────────────────────────────────
❓ 用户: 李元静的专业是什么?
🔎 正在检索相关文档...
📚 检索到 3 段相关文本
💬 LLM 正在生成回答...
🤖 回答: 李元静的专业是软件工程。
────────────────────────────────────────────────────
❓ 用户: 李元静会哪些前端技术?
🔎 正在检索相关文档...
📚 检索到 3 段相关文本
💬 LLM 正在生成回答...
🤖 回答: 李元静掌握的前端技术包括:Vue.js、TypeScript、Vite、
Tailwind CSS,并且有企业级管理系统开发经验。
────────────────────────────────────────────────────
🎉 RAG 完整链路演示完成!

四、真实世界的坑与解决方案

坑 1:切分太碎,语义断裂

现象:检索到的片段都是半句话,LLM 无法理解。

错误做法

// ❌ 按固定 200 字切,重叠太小
chunkSize: 200, chunkOverlap: 20

正确做法

// ✅ 重叠 50%,按中文语义边界切
chunkSize: 300, chunkOverlap: 150,
separators: ["\n\n", "\n", "。", "!", "?", ",", "、", ";", " "]

坑 2:Prompt 没约束,LLM 继续幻觉

现象:检索到的片段都不相关,但 LLM 还是生成了一个看似合理的答案。

错误 Prompt

请回答用户的问题。

正确 Prompt

你是一个知识库问答助手。请严格遵循以下规则:
1. 仅根据「上下文」回答用户的问题
2. 如果上下文中没有明确的答案,请回答:「抱歉,知识库中没有相关内容」
3. 不要在答案中编造任何上下文中没有的内容
4. 回答要简洁、准确
上下文:
{context}
用户问题:{question}

坑 3:TF-IDF 效果不如预期

现象:检索结果不相关,比如搜「前端技术」返回了「后端架构」的内容。

解决方案

  • 短期:调大 k 值(从 3 调到 5),让 LLM 自己筛选
  • 中期:换成 BGE 系列开源模型(中文效果好)
  • 长期:用混合检索(向量 + BM25 关键词)

我的升级路径

TF-IDF(原型) → BGE-small-zh(本地部署) → text-embedding-3(生产)

坑 4:知识库中有矛盾的信息

现象:知识库中有两个互相矛盾的文档,LLM 不知道该信哪个。

解决方案

策略说明
让 LLM 指出矛盾在 Prompt 中要求 LLM 识别并指出矛盾点
设置优先级为新文档设置更高权重(按更新时间排序)
人工审核关键知识需要人工确认后入库

五、架构决策表(生产环境参考)

决策点我的当前选择生产环境推荐理由
Embedding 模型本地 TF-IDFBAAI/bge-large-zh中文效果好,可本地部署
向量数据库MemoryVectorStorepgvector团队已有 PostgreSQL 技术栈
检索策略纯向量 Top-3混合检索(向量 + BM25)纯向量召回率不足 80%
切分策略300 字 + 50% 重叠按语义边界 + 30% 重叠避免信息断裂
重排序不需要需要(生产环境)精度提升 15%+
LLM火山引擎 GLM-4.7通义千问 / 文心一言国内合规,响应稳定
Web 服务Express + SSENestJS + gRPC企业级架构

我的工程心得

  1. 先跑通,再优化:TF-IDF 虽然简单,但足够验证整个链路
  2. 零依赖是宝:能本地跑就别调 API,学习和调试效率差 10 倍
  3. 流式输出是底线:用户等不起 10 秒,流式输出是体验的基本要求
  4. 检索比生成重要:80% 的 RAG 问题出在检索,不是生成
  5. 防幻觉是红线:宁可答「不知道」,也不能胡说八道

一句话总结

RAG 让 LLM 不再是「凭记忆答题的学生」,而是「带参考资料答题的专家」。

项目源码:learn-langchain.js

上一篇:四种记忆策略:让 LLM 真正「记住」对话 下一篇预告:[Prompt Engineering:把 LLM 当 API 用,而不是陪聊](Prompt Engineering(二).md)

RAG 检索增强(一)
/posts/2026-7-1/1-rag-检索增强/
Author
Atopos
Published at
2026-07-01
License
CC BY-NC-SA 4.0

Some information may be outdated