大模型记不住长文档?带你从零手写一个企业级 RAG 系统
今天我们来聊聊目前 AI 应用开发中最火热的技术之一——RAG(Retrieval-Augmented Generation,检索增强生成)。
在实际业务开发中,大家肯定是不是也有遇到过这样的痛点:你有一份 800 页的财务报告,你想问大模型(比如 Claude):“这家公司面临哪些风险因素?”
最简单粗暴的做法是:把这 800 页的内容全部塞进 Prompt 里,然后再附上你的问题。
但这种“无脑拼接”的做法面临着四个致命的挑战:
-
长度硬限制:大模型有上下文窗口限制(Context Window),太长的文档根本塞不进去。
-
能力衰减:Prompt 越长,大模型的注意力机制越容易“迷失”,回答质量直线下降。
-
烧钱:API 是按 Token 计费的,每次提问都带上 800 页文档,成本会原地起飞。
-
巨慢无比:处理海量 Token 需要极长的响应时间,用户体验极差。
那么,有没有一种方法,既能让大模型知道文档里的内容,又不用每次都把整个文档喂给它呢?
答案就是 RAG(检索增强生成)。它的核心思想很优雅:与其让大模型死记硬背整本书,不如给它一个带搜索引擎的图书馆。
当用户提问时,我们先去文档里“搜”出最相关的几段话(比如包含“风险因素”的段落),然后把这几段话作为背景知识,和问题一起发给大模型。这就是 RAG。
接下来,我将带大家一步步拆解 RAG 的核心流程,并通过 NestJS 代码示例。哪怕你是刚入职的初级工程师,只要跟着这套逻辑走,也能轻松搞懂!
核心步骤一:文本分块(Chunking)—— 把大书拆成单页
要把文档做成“可检索”的,第一步就是要把大文档切解成小块。这一步叫做 Chunking(文本分块)。如果分块没做好,你的 AI 就会像个“智障”。
比如,文档里同时讲了“医学研究”和“软件工程”。 如果分块不合理,用户问“工程师修复了多少个 Bug?”,AI 可能会把医学研究里关于“超级细菌(Bug)”的内容检索出来。
常见的切块策略有三种:
1. 基于大小的分块(Size-Based Chunking)
最简单的方法,按固定字符长度切分。为了防止一句话被从中间斩断导致语义丢失,我们通常会加上 Overlap(重叠区)。
在 NestJS 中,我们可以封装一个分块服务:
import { Injectable } from '@nestjs/common';@Injectable()export class ChunkingService {/*** 基于字符长度的滑动窗口分块* @param text 原始长文本* @param chunkSize 每个块的最大长度* @param chunkOverlap 相邻块的重叠字符数*/chunkByChar(text: string, chunkSize = 150, chunkOverlap = 20): string[] {const chunks: string[] = [];let startIdx = 0;while (startIdx < text.length) {// 确定当前块的结束位置const endIdx = Math.min(startIdx + chunkSize, text.length);chunks.push(text.substring(startIdx, endIdx));// 计算下一个块的起始位置,减去重叠部分以保留上下文连贯性startIdx = endIdx < text.length ? endIdx - chunkOverlap : text.length;}return chunks;}}
2. 基于结构的分块(Structure-Based Chunking)
如果你的文档是非常规整的 Markdown 文件,可以直接根据标题(如 ## )来切分。这种切出来的块语义最完整,但前提是你的文档格式得非常标准。
3. 基于句子的分块(Sentence-Based Chunking)
这是一个非常实用的折中方案。先用正则把文章按标点符号切成句子,然后再把几个句子打包成一个块。这种方法能保证每句话都是完整的。
/*** 基于句子的分块*/chunkBySentence(text: string, maxSentencesPerChunk = 5, overlapSentences = 1): string[] {// 按句号、感叹号、问号切分句子const sentences = text.split(/(?<=[.!?])\s+/);const chunks: string[] = [];let startIdx = 0;while (startIdx < sentences.length) {const endIdx = Math.min(startIdx + maxSentencesPerChunk, sentences.length);const currentChunk = sentences.slice(startIdx, endIdx);chunks.push(currentChunk.join(' '));startIdx += (maxSentencesPerChunk - overlapSentences);if (startIdx < 0) startIdx = 0;}return chunks;}
建议:在生产环境中,“带重叠区的基于大小分块”通常是最稳妥的保底方案,它足够简单、可靠,且通吃任何文本格式。
核心步骤二:向量化(Embeddings)—— 让计算机理解“意义”
文本切好后,我们要怎么搜索呢?普通的关键词搜索(比如查 “apple”)搜不到 “iPhone”,因为字面不匹配。
在 RAG 中,我们使用的是语义搜索(Semantic Search)。核心技术就是 Text Embeddings(文本向量化)。
简单来说,Embedding 就是把一段文字变成一串长长的浮点数字(比如 [-0.1, 0.89, 0.3...])。这串数字包含了这段文字的“意义”。
-
比如块 A 讲医学研究,它的向量可能在“医学”这个维度上得分很高。
-
块 B 讲软件工程,它的向量在“代码”维度上得分很高。
由于 Anthropic (Claude) 目前不提供 Embedding API,我们通常推荐使用业界顶尖的 VoyageAI。
NestJS 接入 VoyageAI 示例代码:
import { Injectable } from '@nestjs/common';import { ConfigService } from '@nestjs/config';// 假设的官方 SDK,或直接使用 axios/fetchimport { VoyageAIClient } from 'voyageai';@Injectable()export class EmbeddingService {private client: VoyageAIClient;constructor(privateconfigService: ConfigService) {// 从 .env 文件中读取密钥 VOYAGE_API_KEYconst apiKey = this.configService.get<string>('VOYAGE_API_KEY');this.client = new VoyageAIClient({ apiKey });}/*** 将文本转换为向量*/async generateEmbedding(text: string, inputType: 'document' | 'query' = 'document'): Promise<number[]> {const response = await this.client.embed({input: [text],model: 'voyage-3-large',inputType: inputType,});// 返回经过归一化(Normalization)的浮点数数组return response.embeddings[0];}}
核心步骤三:向量数据库与相似度搜索 —— 在知识的海洋里捞针
我们把切好的每一个文本块,都通过 API 转换成了向量,并存入向量数据库(Vector Database)中。
当用户提问时(比如:“软件工程部门今年做了什么?”),系统会经历以下魔法时刻:
-
把用户的问题也变成一个向量(假设得分为
[0.112, 0.993],代表软件属性极强)。 -
让数据库对比这个问题向量与之前存入的所有文档向量。
-
找出最相似的向量。
它是怎么计算相似度的?核心数学原理是余弦相似度(Cosine Similarity)。它测量的是两个向量在空间中的夹角。
-
值越接近 1,代表两段文本含义越相似(角度越小)。
-
值越接近 -1,代表意思截然相反。
-
值接近 0,代表毫无关系。
有时候数据库里也会用余弦距离(Cosine Distance),它的公式仅仅是 1 - 余弦相似度。距离越小(接近 0),说明越相似。
核心步骤四:拼接最终 Prompt
经过向量数据库的搜索,我们找到了余弦相似度最高的那个“文本块”(比如找到了文档里描述分布式系统 Bug 的那一段)。
最后一步,我们把用户的原始问题和检索到的内容拼接起来,交给大模型去生成最终答案:
import { Injectable } from '@nestjs/common';@Injectable()export class RagPipelineService {async generateFinalPrompt(userQuestion: string, retrievedChunk: string): Promise<string> {const prompt = `Answer the user's question about the document based ONLY on the provided context.<user_question>${userQuestion}</user_question><context>${retrievedChunk}</context>`;// 这里将 prompt 发送给大模型(如 Claude),等待大模型返回精准的回答return prompt;}}
总结:RAG 的得与失
通过这套机制,大家应该能看明白:RAG 的本质是用“工程复杂度”去换取系统的“扩展性与高效率”。
收益:
-
大模型只需要看最相关的片段,回答极度精准。
-
完美突破大模型的字数限制,几万份文档也能轻松应对。
-
极大地节省了 Token 开销和响应时间。
挑战:
-
需要搭建额外的链路(分块逻辑、Embedding 模型、向量数据库)。
-
如果前面检索出来的文本块是错的(垃圾进),大模型依然会一本正经地胡说八道(垃圾出)。
这就是今天关于 RAG 的全链路解析。希望通过 NestJS 的实战代码,能帮助大家把这个看似高深的人工智能概念彻底落地到我们的日常工程开发中去!
夜雨聆风