LLM应用开发二:让AI学会"翻书"——RAG检索增强从踩坑到跑通

摘要:大模型什么都懂?别逗了,它连你公司上个季度的财报都没见过。RAG(检索增强生成)就是给AI配一个"私人图书馆",让它回答问题之前先翻资料,而不是一本正经地胡说八道。但理想很丰满,现实很骨感------Embedding API返回404、向量库需要C++编译、HuggingFace被墙下载不了......这篇文章记录了我从零实现RAG的全过程,包括四次连续翻车和最终跑通的方案。


一、AI为什么会"一本正经胡说八道"

你有没有遇到过这种情况------问AI一个专业问题,它回答得头头是道,但全是编的?

arduino 复制代码
你:"我们公司2025年Q3的营收是多少?"
AI:"根据公开数据,2025年Q3营收为3.2亿元,同比增长15%。"
(实际上你公司根本没公布过这个数据,3.2亿是它编的)

这叫幻觉(Hallucination),是LLM最臭名昭著的问题。模型被训练成"总要给出回答",当它不知道答案时,不会说"我不知道",而是编一个看起来合理的答案。

就像考试遇到不会的题------老实人交白卷,LLM选择编答案,还编得有模有样。

RAG就是解决方案。 它让AI在回答之前,先去"图书馆"查资料,然后根据查到的真实内容来回答。

复制代码
没有RAG:AI凭记忆回答 → 可能编造
有了RAG:AI先查资料再回答 → 基于事实

二、RAG到底在做什么?

RAG的全称是 Retrieval Augmented Generation(检索增强生成)。拆开看:

  • Retrieval(检索):根据用户问题,从知识库中找到相关内容
  • Augmented(增强):把找到的内容拼接到提示词中,增强上下文
  • Generation(生成):LLM基于增强后的上下文生成回答

整个流程分两大阶段:

复制代码
阶段一:建库(离线,只做一次)
━━━━━━━━━━━━━━━━━━━━━━━━━━
文档 → 加载 → 分块 → 向量化 → 存入向量库

就像:买书 → 拆章节 → 贴标签 → 上架


阶段二:检索+生成(在线,用户每次提问时)
━━━━━━━━━━━━━━━━━━━━━━━━━━
用户问题 → 检索相关片段 → 拼接到Prompt → LLM生成回答

就像:提问 → 管理员找书 → 把书翻到相关页 → AI照着书回答

这里有几个关键概念需要理解:

2.1 为什么要分块?

你不会把一整本《三国演义》递给AI说"自己找吧"。文档太长,模型处理不了,检索也不精确。切成小块,每次只给最相关的几段,AI才能精准回答。

但分块是个技术活:

  • 太大:一个块里塞了太多内容,检索不精确,可能混入无关信息
  • 太小:一个块只有半句话,丢失了上下文,模型看不懂

推荐的参数:chunkSize=1000, chunkOverlap=200。overlap是相邻块的重叠部分,防止关键信息恰好在切分边界被截断。

diff 复制代码
块1: [====重叠区====]
块2:          [====重叠区====]
块3:                    [====重叠区====]

2.2 什么是Embedding?

Embedding是把文本变成数字向量的技术。语义相似的文本,向量在空间中距离近;语义无关的文本,向量距离远。

arduino 复制代码
"猫是一种宠物" → [0.12, 0.85, 0.33, ...]
"狗是常见的家养动物" → [0.14, 0.82, 0.35, ...]  ← 距离近,都是宠物
"量子力学的测不准原理" → [0.91, 0.03, 0.67, ...]  ← 距离远,语义无关

就像给每本书贴了一个"内容指纹"------两本书的指纹越相似,内容越相关。

2.3 向量库怎么检索?

用户提问时,先把问题也变成向量,然后在向量库中找距离最近的K个文本块。这叫相似度搜索(Similarity Search)

就好比你在图书馆问"有没有讲RAG的书",管理员不是一本一本地翻,而是直接去"RAG"那个书架拿。


三、从零实现:代码实战

3.1 项目结构

bash 复制代码
d:\project\agent
├── .env                  # API Key 配置
├── data/
│   └── langchain-guide.md # 知识库文档
├── src/
│   ├── index.ts           # 第一步:Hello LLM
│   ├── memory.ts       # 第二步:对话记忆
│   └── rag.ts          # 第三步:RAG(本文重点)
└── package.json

3.2 建库阶段:加载→分块→索引

typescript 复制代码
// 1. 加载文档
const content = fs.readFileSync("data/langchain-guide.md", "utf-8");
const docs = [
  new Document({
    pageContent: content,
    metadata: { source: "langchain-guide.md" },
  }),
];

// 2. 分块
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const splits = await splitter.splitDocuments(docs);

// 3. 建索引(这里是 TF-IDF,后文解释为什么)
const retriever = new SimpleRetriever();
retriever.addDocuments(splits);

RecursiveCharacterTextSplitter 是 LangChain 提供的分块器,它会按段落→句子→字符的优先级递归切分,尽量保持语义完整性。

3.3 检索+生成阶段

typescript 复制代码
async function ragAnswer(model, retriever, question) {
  // 1. 检索相关文档片段
  const relevantDocs = retriever.search(question, 4);

  // 2. 把检索结果格式化
  const context = relevantDocs
    .map(
      (doc, i) =>
        `[片段${i + 1}] (来源: ${doc.metadata.source})\n${doc.pageContent}`,
    )
    .join("\n\n---\n\n");

  // 3. 构造提示词(关键:把检索结果塞进去)
  const messages = [
    new SystemMessage(`你是一个知识助手。请基于以下参考内容回答用户的问题。
规则:
- 只基于参考内容回答,不要编造信息
- 如果参考内容中没有相关信息,直接说"根据现有资料无法回答"

参考内容:
${context}`),
    new HumanMessage(question),
  ];

  // 4. LLM 生成回答
  const response = await model.invoke(messages);
  return response.content;
}

注意提示词里的规则:"只基于参考内容回答,不要编造"------这是RAG的纪律。就像开卷考试,资料都给你了,就别瞎编。

3.4 运行效果

关键验证:问知识库有的问题能准确回答,问知识库没有的问题能坦诚说"无法回答"------这就是RAG的价值,从"编造"到"诚实"


四、踩坑实录:四次翻车的血泪史

这才是本文的重点。RAG的原理谁都懂,但真正动手实现时,踩的坑远比代码多。

翻车①:FAISS需要C++编译环境

期望npm install faiss-node,然后直接用FAISS向量库。

现实

arduino 复制代码
npm error OMG Could not find any Visual Studio installation to use

FAISS是Facebook开源的高效向量检索库,但它的Node.js绑定faiss-node需要本地编译C++代码,Windows上必须安装Visual Studio的"Desktop development with C++"工作负载。

我的开发机上没有Visual Studio,也不想为了一个向量库装几个G的编译环境。

解决 :改用MemoryVectorStore------LangChain内置的纯JS内存向量库,零编译依赖。

方案 依赖 适合场景
FAISS C++编译环境 大规模生产数据
Chroma 需启动服务端 需持久化的项目
MemoryVectorStore 零依赖 开发测试(我的选择)

翻车②:Embedding API返回404

期望 :用OpenAIEmbeddings调OpenAI的Embedding接口,向量化文档。

现实

css 复制代码
BadRequestError: 400
error: { message: "no any schema route found", code: "ase_embedding_req_failed" }

原因:我用的API服务(兼容OpenAI协议的Coding Plan)只支持聊天补全(/v1/chat/completions),不支持Embedding(/v1/embeddings)。就像你的手机卡能打电话但不能发短信------套餐没包含这个功能。

教训:不是所有"兼容OpenAI协议"的服务都实现了全部接口。聊天和Embedding是两个独立的端点,前者是标配,后者很多服务商不提供。

翻车③:HuggingFace被墙

期望 :用@huggingface/transformers在本地运行Embedding模型,绕过API限制。

现实

yaml 复制代码
ConnectTimeoutError: Connect Timeout Error
(attempted address: huggingface.co:443, timeout: 10000ms)

HuggingFace是全球最大的AI模型托管平台,但在国内经常无法访问。模型文件下载不下来,本地Embedding方案也泡汤了。

尝试 :设置了国内镜像HF_ENDPOINT=https://hf-mirror.com,仍然失败(镜像服务也不稳定)。

翻车④:依赖冲突

期望 :安装@langchain/communityfaiss-node

现实

vbnet 复制代码
npm error ERESOLVE unable to resolve dependency tree
npm error Could not resolve dependency: peer dotenv@"^16.4.5" from @browserbasehq/stagehand@1.14.0

LangChain的@langchain/community引入了@browserbasehq/stagehand作为peer dependency,而它要求的dotenv版本和项目中的不一致。

解决npm install --legacy-peer-deps,忽略peer dependency版本检查。这不是最优解,但在学习阶段够用。


五、最终方案:TF-IDF纯本地检索

四次翻车后,我决定换一个思路:不用神经网络Embedding,用TF-IDF实现纯本地检索。

TF-IDF是什么?

TF-IDF(Term Frequency - Inverse Document Frequency)是一种经典的信息检索算法:

  • TF(词频):一个词在文档中出现的频率越高,越重要
  • IDF(逆文档频率):一个词在所有文档中越罕见,区分度越高
  • TF-IDF = TF × IDF:综合衡量一个词对某篇文档的重要性

就像判断一篇文章的主题------"的"字出现很多但没区分度(IDF低),"量子计算"出现不多但很有区分度(IDF高)。

为什么TF-IDF能替代Embedding?

对比 TF-IDF Embedding
原理 关键词匹配 语义理解
依赖 零,纯数学计算 需下载模型/调API
网络 完全离线 需联网或本地模型
效果 能匹配同关键词的内容 能理解同义词和语义
启动速度 毫秒级 首次需加载模型
类比 书后索引查关键词 找一个读懂全书的人帮你查

关键区别在"语义理解"

arduino 复制代码
问题:"检索增强是什么?"
知识库中的原文:"RAG(Retrieval Augmented Generation)是一种..."

TF-IDF:搜"检索增强" → 找不到(因为文档里写的是"RAG",不是"检索增强")
Embedding:搜"检索增强" → 能找到(因为它理解"检索增强"和"RAG"是同义的)

TF-IDF是"死板但靠谱的索引员",Embedding是"灵活但需要先培训的助手"。学习阶段用索引员,生产环境换助手------检索接口不变,只换实现

实现核心代码

typescript 复制代码
class SimpleRetriever {
  private documents: Document[] = [];
  private docTokens: string[][] = [];
  private idf: Map<string, number> = new Map();

  // 中文分词:提取连续中文字符和英文单词
  function tokenize(text: string): string[] {
    const matches = text.match(/[\u4e00-\u9fa5]{2,}|[a-zA-Z]{2,}/g);
    return matches ? matches.map(t => t.toLowerCase()) : [];
  }

  // 计算IDF:log(总文档数 / 包含该词的文档数)
  private computeIDF() {
    for (const [token, freq] of docFreq) {
      this.idf.set(token, Math.log((totalDocs + 1) / (freq + 1)) + 1);
    }
  }

  // 计算余弦相似度
  private cosineSimilarity(a: Map<string, number>, b: Map<string, number>): number {
    // 两个向量的点积 / (向量A的模 × 向量B的模)
    // 值域 [0, 1],1 表示完全相同,0 表示完全无关
  }

  // 检索:把问题也变成TF-IDF向量,找最相似的文档
  search(query: string, topK: number = 4): Document[] {
    const queryVector = this.tfidfVector(tokenize(query));
    // 计算问题和每个文档的余弦相似度,返回topK
  }
}

余弦相似度是核心------它衡量两个向量方向的相似程度,不管长度。就像比较两个人的兴趣方向是否一致,不管谁投入的时间更多。


六、踩坑总结与经验

经验一:先跑通,再优化

RAG的真正难点不在检索算法,而在整个流程的串联。TF-IDF虽然效果不如Embedding,但能让你在最短时间内跑通完整流程。流程跑通了,再换检索器只是改一个类的事情。

不要在第一步就追求最优解。 先让飞机飞起来,再优化引擎。

经验二:API兼容≠功能完整

"兼容OpenAI协议"不等于"支持OpenAI所有接口"。很多服务只兼容聊天补全(/chat/completions),不支持Embedding(/embeddings)、Function Calling等高级功能。选API之前,先确认你需要哪些端点。

经验三:国内开发AI应用的三大拦路虎

问题 原因 解决方案
HuggingFace被墙 网络限制 用国内镜像或本地方案
C++编译环境缺失 很多AI库需要native编译 用纯JS替代方案
API功能不完整 兼容协议≠完整功能 先确认端点支持情况

经验四:RAG的提示词很关键

同样的检索结果,不同的提示词,回答质量天差地别:

arduino 复制代码
❌ 差的提示词:"回答用户问题"
→ AI可能无视检索结果,自己编答案

✅ 好的提示词:"只基于参考内容回答,不要编造。如果参考内容中没有相关信息,直接说无法回答"
→ AI严格基于检索结果回答,该说不知道就说不知道

RAG不只是"检索+拼接",提示词的纪律性约束同样重要。就像开卷考试也需要规定"不许带小抄"。


七、总结

核心知识点

知识点 一句话总结
RAG 让LLM先查资料再回答,从"编造"到"诚实"
分块 文档太大要拆,chunkSize=1000, chunkOverlap=200
Embedding 文本→向量,语义相似则距离近
TF-IDF 基于关键词的检索,零依赖但不懂语义
余弦相似度 衡量两个向量方向相似程度的数学方法
提示词纪律 "只基于参考内容回答"是RAG的底线

技术选型路线

markdown 复制代码
学习阶段:TF-IDF + MemoryVectorStore(零依赖,秒启动)
     ↓ 理解流程后
生产阶段:Embedding + Chroma/Pinecone(语义检索,持久化存储)

换检索器只需要改一行代码------因为检索的输入输出接口是固定的(问题→文档片段),内部实现是TF-IDF还是神经网络,上层代码不需要关心。这就是抽象的价值


本文是「LLM应用开发」系列第二篇。上一篇:给失忆的大模型装上"脑子"------对话记忆从零实战。下一篇:让AI"动手干活"------Tool Calling工具调用实战。

相关推荐
weiggle6 小时前
第二篇:搭建你的第一个 Compose 项目——开发环境与项目结构
android·前端
Simon523146 小时前
Spring AOP 五大通知类型
java·前端·spring
Asmewill6 小时前
LangGraph学习笔记八(SubGraph)
前端
叶落阁主6 小时前
AntV npm 投毒复盘:一次公司私服缓存恶意包引发的账号封禁事件
前端·安全·npm
vaexu6 小时前
Android 定时提醒的终极防线:我是如何用“双保险机制”攻克后台保活的?
前端
小村儿6 小时前
连载11- Claude code 的 Agent Teams——当子 Agent 开始互相说话
前端·后端·ai编程
潍坊老登6 小时前
关于 number类型从vue端传到golang后端是float而不是int的事
前端
茶底世界之下6 小时前
你的 Mac 里,藏着一支 AI 开发团队
前端·javascript
不爱说话郭德纲7 小时前
出门在外收到任务,我用 TRAE SOLO 把电脑“叫醒”干活
前端·ai编程