一、问题引入:为什么需要 RAG?
用 ChatGPT 问"我们公司上季度的销售额是多少?"——它无法回答,因为它没有你的内部数据。
RAG(Retrieval-Augmented Generation,检索增强生成) 解决了这个问题:先把你的文档"喂"给系统,用户提问时从文档中检索最相关的内容,然后让 LLM 基于检索结果生成答案。
| 直接问 LLM | 用 RAG | |
|---|---|---|
| 公司内部数据 | ❌ 不知道 | ✅ 从文档检索 |
| 答案准确性 | ❌ 可能幻觉 | ✅ 有据可查 |
| 引用来源 | ❌ 无 | ✅ 标注出处 |
二、RAG 完整链路
用户上传 PDF
│
▼
文档解析(提取文本)
│
▼
文本分块(每块 500-1000 字)
│
▼
向量化(文本 → 向量)
│
▼
存入向量数据库
│
▼
═══════════════════════
│
用户提问 "Q"
│
▼
问题向量化
│
▼
向量相似度检索(Top-K 最相关内容)
│
▼
拼接 Prompt:"基于以下文档内容回答:{检索结果}\n\n问题:{Q}"
│
▼
LLM 生成回答 + 引用来源
三、完整可运行代码
3.1 前端核心实现(原生 JS)
// ============ 文档分块器 ============
class DocumentChunker {
/**
* 将长文本切分为重叠块
* @param {string} text 原文
* @param {number} chunkSize 每块最大字符数
* @param {number} overlap 块间重叠字符数
*/
static split(text, chunkSize = 800, overlap = 100) {
const chunks = []
let start = 0
while (start < text.length) {
// 尽量在句号、换行等自然边界处切分
let end = start + chunkSize
if (end < text.length) {
// 在 chunkSize 范围内找最近的句号或换行
const sub = text.substring(end - 50, end + 50)
const breakPoint = Math.max(sub.lastIndexOf('。'), sub.lastIndexOf('
'))
if (breakPoint > 0) {
end = end - 50 + breakPoint + 1
}
}
const chunk = text.substring(start, Math.min(end, text.length)).trim()
if (chunk) {
chunks.push({
id: `chunk_${start}`,
content: chunk,
startIndex: start,
endIndex: Math.min(end, text.length),
})
}
start = end - overlap
}
return chunks
}
}
// ============ 简易向量相似度(余弦相似度) ============
class VecSim {
/**
* 余弦相似度 = A·B / (|A| * |B|)
* 范围 [-1, 1],越接近 1 越相似
*/
static cosine(a, b) {
if (a.length !== b.length) {
throw new Error('向量维度不一致')
}
let dot = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
/** 在 chunks 中检索与 query 最相似的 topK 个 */
static search(queryEmbedding, chunks, topK = 3) {
return chunks
.map(chunk => ({
...chunk,
score: VecSim.cosine(queryEmbedding, chunk.embedding),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
}
}
// ============ RAG 问答主流程 ============
class RAGEngine {
constructor() {
this.chunks = [] // 文档分块列表
this.isReady = false
}
/**
* 上传并处理文档
* @param {File} file PDF 或 Markdown 文件
*/
async uploadDocument(file) {
// 1. 读取文本
const text = await this.extractText(file)
// 2. 分块
this.chunks = DocumentChunker.split(text, 800, 100)
console.log(`文档已分块: ${this.chunks.length} 个块`)
// 3. 向量化(调用后端 Embedding API)
for (const chunk of this.chunks) {
chunk.embedding = await this.getEmbedding(chunk.content)
}
this.isReady = true
return { chunkCount: this.chunks.length, file, fileName: file.name }
}
/**
* 提问
* @param {string} question 用户提问
* @returns {{ answer: string, sources: Array }}
*/
async ask(question) {
if (!this.isReady) throw new Error('请先上传文档')
// 1. 将问题向量化
const qEmbedding = await this.getEmbedding(question)
// 2. 检索 Top-3 相关内容
const topChunks = VecSim.search(qEmbedding, this.chunks, 3)
// 3. 拼接 Prompt
const context = topChunks.map(c => c.content).join('
---
')
const prompt = `基于以下文档内容回答用户问题。如果文档中没有相关信息,请如实说明。
文档内容:
${context}
用户问题:${question}
回答:`
// 4. 调用 LLM
const answer = await this.callLLM(prompt)
// 5. 返回答案 + 引用
return {
answer,
sources: topChunks.map((c, i) => ({
index: i + 1,
content: c.content.substring(0, 200) + '...',
score: c.score.toFixed(3),
startIndex: c.startIndex,
})),
}
}
// ----- 辅助方法 -----
async extractText(file) {
// PDF 解析:可接入 pdf-parse 或 pdfjs-dist
// Markdown 解析:直接读文本
if (file.name.endsWith('.md')) {
return await file.text()
}
return `[${file.name}] 的文本内容(需接入 PDF 解析库)`
}
async getEmbedding(text) {
const res = await fetch('/api/embedding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const data = await res.json()
return data.embedding
}
async callLLM(prompt) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] }),
})
const data = await res.json()
return data.choices[0].message.content
}
}
// ============ 使用示例 ============
// const engine = new RAGEngine()
// await engine.uploadDocument(selectedFile)
// const result = await engine.ask('公司 Q3 营收多少?')
// console.log(result.answer, result.sources)
四、逐行精讲
4.1 分块策略:overlap 为什么重要
static split(text, chunkSize = 800, overlap = 100) {
没有 overlap 的问题:一个完整的句子可能被切在两块之间,"Q3 营收为 1000 万" 变成了 "Q3 营收为 1" 和 "000 万",向量检索时关键词匹配不到。
overlap = 100 确保相邻块共享 100 个字符的交集,边界处的关键信息不会丢失。
4.2 余弦相似度的数学本质
static cosine(a, b) {
let dot = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i] // 点积
normA += a[i] * a[i] // ||A||^2
normB += b[i] * b[i] // ||B||^2
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}
这是机器学习中最常用的相似度算法。两个向量方向越一致,余弦值越接近 1。相比于欧氏距离,余弦相似度不受向量长度影响——长文本和短文本的向量也能公平比较。
五、常见问题
Q1:检索效果差怎么办?
A:1. 调整 chunkSize(通常 500-1000 字符最优);2. 增大 overlap;3. 用更好的 Embedding 模型(如 text-embedding-3-large);4. 尝试混合检索(关键词 BM25 + 向量检索)。
Q2:向量维度多少合适?
A:OpenAI text-embedding-3-small 输出 1536 维,3-large 可输出 256-3072 维。维度越高精度越高但存储和计算成本更大。
六、决策框架
你的文档规模?
├── < 10 篇,每篇 < 5000 字
│ └── 前端直接分块 + 后端提供 Embedding API(本文方案)
│
├── 10-100 篇
│ └── 接入向量数据库(Milvus / Qdrant / Pinecone)
│
└── > 100 篇
└── 专业 RAG 框架(LangChain + 混合检索 + 重排)
七、面试速记
Q:RAG 解决什么问题?
A:解决 LLM 的三大缺陷:1. 私有数据无法接入(企业文档);2. 知识过时(训练截止日期之后的内容);3. 幻觉(无中生有)。RAG 通过"先检索后生成"来保证回答有据可查。
Q:分块 overlap 的作用?
A:防止关键信息在分块边界被截断。相邻两块共享 100-200 字符,确保"跨块"的完整语句在至少一个块中被完整包含。
八、总结
- RAG = 文档预处理(分块 + 向量化)+ 实时检索(相似度匹配)+ 增强生成(拼接 Prompt)。
- 分块策略是核心:chunkSize 500-1000,overlap 100-200 最优。
- 余弦相似度是最简单有效的向量检索算法。
- 引用溯源是 RAG 被用户信任的关键——答案必须标注来自哪段原文。
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~