作者手记:这不是一篇抄来的博客。本文所有代码均来自
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.0 | TypeScript 原生支持,工程化好 |
| 向量模型 | 本地 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)); }}为什么这很重要?
- 零成本原型:不需要 OpenAI API Key,不需要网络,装完依赖就能跑
- 学习价值:理解 Embedding 本质就是「文本→数字向量」的映射
- 渐进式升级:验证完流程后,一行代码换成 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-IDF | BAAI/bge-large-zh | 中文效果好,可本地部署 |
| 向量数据库 | MemoryVectorStore | pgvector | 团队已有 PostgreSQL 技术栈 |
| 检索策略 | 纯向量 Top-3 | 混合检索(向量 + BM25) | 纯向量召回率不足 80% |
| 切分策略 | 300 字 + 50% 重叠 | 按语义边界 + 30% 重叠 | 避免信息断裂 |
| 重排序 | 不需要 | 需要(生产环境) | 精度提升 15%+ |
| LLM | 火山引擎 GLM-4.7 | 通义千问 / 文心一言 | 国内合规,响应稳定 |
| Web 服务 | Express + SSE | NestJS + gRPC | 企业级架构 |
我的工程心得
- 先跑通,再优化:TF-IDF 虽然简单,但足够验证整个链路
- 零依赖是宝:能本地跑就别调 API,学习和调试效率差 10 倍
- 流式输出是底线:用户等不起 10 秒,流式输出是体验的基本要求
- 检索比生成重要:80% 的 RAG 问题出在检索,不是生成
- 防幻觉是红线:宁可答「不知道」,也不能胡说八道
一句话总结
RAG 让 LLM 不再是「凭记忆答题的学生」,而是「带参考资料答题的专家」。
项目源码:learn-langchain.js
上一篇:四种记忆策略:让 LLM 真正「记住」对话 下一篇预告:[Prompt Engineering:把 LLM 当 API 用,而不是陪聊](Prompt Engineering(二).md)
Some information may be outdated