RAG 检索增强生成
问题
什么是 RAG(Retrieval-Augmented Generation)?前端如何参与构建基于 RAG 的知识库问答系统?文档预处理、分块策略、检索方案、重排序和效果评估的完整技术链路是怎样的?
答案
RAG 是解决 LLM 知识时效性和幻觉问题的核心技术架构——先从外部知识库中检索与用户问题相关的文档片段,再将其作为上下文提供给 LLM 生成回答。与微调(Fine-tuning)不同,RAG 不修改模型权重,而是在推理时动态注入外部知识,因此更新知识的成本极低(更新文档即可,无需重新训练)。
前端在 RAG 系统中扮演重要角色:文档上传与管理、检索体验设计、引用来源展示、反馈收集等均需要精心的前端工程。
RAG 之所以成为企业 AI 应用的首选方案,是因为它同时解决了三个问题:
- 知识时效性:LLM 训练数据有截止日期,RAG 可以实时接入最新文档
- 领域知识缺失:企业内部文档、私有知识库不在公开训练数据中
- 幻觉问题:通过提供真实来源约束 LLM 输出,并可展示引用供用户验证
一、RAG 整体架构
一个完整的 RAG 系统分为两个阶段:离线索引阶段(Indexing)和在线查询阶段(Querying)。离线阶段负责将文档处理为可检索的向量索引;在线阶段负责接收用户问题、检索相关片段、生成回答。
离线和在线两个阶段使用同一个 Embedding 模型是关键——文档分块和用户问题必须被编码到同一个向量空间中,否则相似度搜索将毫无意义。更换 Embedding 模型意味着需要重新向量化所有文档。
二、文档预处理流水线
文档预处理是 RAG 系统的第一道防线——垃圾进垃圾出(Garbage In, Garbage Out)。不同格式的文档需要不同的解析策略。
文档解析器
import pdf from 'pdf-parse';
import mammoth from 'mammoth';
import { JSDOM } from 'jsdom';
import { marked } from 'marked';
// 统一的文档解析接口
interface ParsedDocument {
content: string; // 纯文本内容
metadata: DocumentMetadata;
pages?: PageInfo[]; // PDF 按页信息
}
interface DocumentMetadata {
filename: string;
fileType: string;
fileSize: number;
title?: string;
author?: string;
createdAt?: Date;
pageCount?: number;
}
interface PageInfo {
pageNumber: number;
content: string;
}
// 文档解析器注册表
const loaders: Record<string, (buffer: Buffer, filename: string) => Promise<ParsedDocument>> = {
// PDF 解析:提取文本并保留页码信息
'.pdf': async (buffer, filename) => {
const data = await pdf(buffer);
// pdf-parse 返回按页分割的文本
const pages: PageInfo[] = data.text
.split(/\f/) // PDF 页分隔符
.map((content, i) => ({ pageNumber: i + 1, content: content.trim() }))
.filter(p => p.content.length > 0);
return {
content: data.text,
metadata: {
filename,
fileType: 'pdf',
fileSize: buffer.length,
title: data.info?.Title,
author: data.info?.Author,
pageCount: data.numpages,
},
pages,
};
},
// Word 文档解析
'.docx': async (buffer, filename) => {
const result = await mammoth.extractRawText({ buffer });
return {
content: result.value,
metadata: { filename, fileType: 'docx', fileSize: buffer.length },
};
},
// HTML 解析:去除标签和脚本,保留结构化文本
'.html': async (buffer, filename) => {
const html = buffer.toString('utf-8');
const dom = new JSDOM(html);
const doc = dom.window.document;
// 移除 script、style、nav、footer 等无用元素
const removeSelectors = ['script', 'style', 'nav', 'footer', 'header', 'aside'];
removeSelectors.forEach(sel => {
doc.querySelectorAll(sel).forEach(el => el.remove());
});
const title = doc.querySelector('title')?.textContent || '';
// 获取 body 的纯文本,保留换行结构
const content = doc.body?.textContent?.replace(/\s+/g, ' ').trim() || '';
return {
content,
metadata: { filename, fileType: 'html', fileSize: buffer.length, title },
};
},
// Markdown 解析
'.md': async (buffer, filename) => {
const markdown = buffer.toString('utf-8');
// 将 Markdown 转为 HTML,再提取纯文本(保留结构)
const html = await marked(markdown);
const dom = new JSDOM(html);
const content = dom.window.document.body?.textContent || '';
// 提取 Markdown 标题作为文档标题
const titleMatch = markdown.match(/^#\s+(.+)$/m);
return {
content,
metadata: {
filename,
fileType: 'markdown',
fileSize: buffer.length,
title: titleMatch?.[1],
},
};
},
// 纯文本
'.txt': async (buffer, filename) => ({
content: buffer.toString('utf-8'),
metadata: { filename, fileType: 'text', fileSize: buffer.length },
}),
};
// 通用文档加载入口
export async function loadDocument(buffer: Buffer, filename: string): Promise<ParsedDocument> {
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
const loader = loaders[ext];
if (!loader) {
throw new Error(`不支持的文件格式: ${ext},支持: ${Object.keys(loaders).join(', ')}`);
}
return loader(buffer, filename);
}
文本清洗
// 文本预处理:清洗噪声,保留有意义的内容
export function cleanText(text: string): string {
return text
// 移除连续空白行(保留单个换行作为段落分隔)
.replace(/\n{3,}/g, '\n\n')
// 移除页眉页脚常见模式(如 "第 X 页"、"Page X of Y")
.replace(/第\s*\d+\s*页/g, '')
.replace(/Page\s+\d+\s+(of\s+\d+)?/gi, '')
// 移除特殊控制字符
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
// 规范化 Unicode 空格
.replace(/[\u00A0\u2000-\u200B\u3000]/g, ' ')
// 移除 URL(可选,根据业务决定)
// .replace(/https?:\/\/\S+/g, '[链接]')
.trim();
}
// 估算 Token 数(粗略估算,中文约 1 字 = 1-2 token,英文约 4 字符 = 1 token)
export function estimateTokens(text: string): number {
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const otherChars = text.length - chineseChars;
return Math.ceil(chineseChars * 1.5 + otherChars / 4);
}
三、文本分块策略
分块(Chunking)是 RAG 系统中影响检索质量最大的因素之一。块太大则包含过多无关信息,稀释相关内容的信号;块太小则丢失上下文,语义不完整。
三种主要分块策略
策略一:固定大小分块
interface FixedSizeOptions {
chunkSize: number; // 每块目标 token 数
chunkOverlap: number; // 相邻块重叠 token 数
}
/**
* 固定大小分块:最简单的策略
* 优点:实现简单、行为可预测
* 缺点:可能在句子中间截断,破坏语义完整性
*/
export function fixedSizeChunking(text: string, options: FixedSizeOptions): string[] {
const { chunkSize, chunkOverlap } = options;
const chunks: string[] = [];
// 将文本按字符切分(简化版,生产中应按 Token 切分)
const charSize = chunkSize * 2; // 粗略估算:1 token ≈ 2 中文字符
const charOverlap = chunkOverlap * 2;
let start = 0;
while (start < text.length) {
const end = Math.min(start + charSize, text.length);
const chunk = text.slice(start, end).trim();
if (chunk.length > 0) {
chunks.push(chunk);
}
start += charSize - charOverlap;
}
return chunks;
}
策略二:递归分割分块(推荐)
interface RecursiveOptions {
chunkSize: number;
chunkOverlap: number;
// 分隔符优先级:从大到小尝试
separators: string[];
}
/**
* 递归分割分块:LangChain 默认使用的策略
* 核心思想:优先按大的语义边界(章节、段落)分割,
* 如果某个片段仍然过大,再递归使用更细的分隔符切割。
* 这样能最大限度地保留语义完整性。
*/
export function recursiveChunking(text: string, options: RecursiveOptions): string[] {
const { chunkSize, chunkOverlap, separators } = options;
function splitRecursive(text: string, sepIndex: number): string[] {
// 如果文本已足够小,直接返回
if (estimateTokens(text) <= chunkSize) {
return text.trim() ? [text.trim()] : [];
}
// 如果没有更多分隔符可用,按固定大小切割
if (sepIndex >= separators.length) {
return fixedSizeChunking(text, { chunkSize, chunkOverlap });
}
const separator = separators[sepIndex];
const parts = text.split(separator).filter(Boolean);
// 合并小片段,拆分大片段
const chunks: string[] = [];
let currentChunk = '';
for (const part of parts) {
const combined = currentChunk ? currentChunk + separator + part : part;
if (estimateTokens(combined) <= chunkSize) {
currentChunk = combined;
} else {
// 当前块已满,保存它
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
// 如果单个 part 就超过 chunkSize,递归使用下一级分隔符
if (estimateTokens(part) > chunkSize) {
chunks.push(...splitRecursive(part, sepIndex + 1));
currentChunk = '';
} else {
// 保留 overlap:取上一个 chunk 的尾部
const overlapText = getOverlapText(currentChunk, chunkOverlap);
currentChunk = overlapText ? overlapText + separator + part : part;
}
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
}
return splitRecursive(text, 0);
}
function getOverlapText(text: string, overlapTokens: number): string {
if (!text || overlapTokens <= 0) return '';
// 从尾部截取约 overlapTokens 个 token 的文本
const chars = text.slice(-(overlapTokens * 2));
// 尝试从句子边界开始
const sentenceStart = chars.search(/[。!?.!?]\s*/);
return sentenceStart > 0 ? chars.slice(sentenceStart + 1).trim() : chars.trim();
}
// 推荐的默认分隔符优先级(中文文档)
const CHINESE_SEPARATORS = [
'\n## ', // Markdown 二级标题
'\n### ', // Markdown 三级标题
'\n\n', // 段落
'\n', // 换行
'。', // 中文句号
';', // 中文分号
',', // 中文逗号(最后手段)
];
// 推荐的默认参数
const DEFAULT_RECURSIVE_OPTIONS: RecursiveOptions = {
chunkSize: 512,
chunkOverlap: 50,
separators: CHINESE_SEPARATORS,
};
策略三:语义分块
/**
* 语义分块:基于 Embedding 相似度的智能分块
* 原理:将文本按句子切分,计算相邻句子的 Embedding 相似度,
* 在相似度骤降的位置切分(说明语义发生了跳转)。
* 优点:语义边界最准确
* 缺点:需要调用 Embedding API,成本较高
*/
export async function semanticChunking(
text: string,
options: {
bufferSize: number; // 计算相似度时的滑动窗口大小
breakpointThreshold: number; // 相似度下降阈值(低于此值则切分)
minChunkSize: number; // 最小块大小(避免过碎)
}
): Promise<string[]> {
const { bufferSize, breakpointThreshold, minChunkSize } = options;
// 1. 按句子切分
const sentences = text
.split(/(?<=[。!?.!?])\s*/)
.filter(s => s.trim().length > 0);
if (sentences.length <= 1) return [text];
// 2. 对每个句子(含上下文窗口)生成 Embedding
const windowedSentences = sentences.map((_, i) => {
const start = Math.max(0, i - bufferSize);
const end = Math.min(sentences.length, i + bufferSize + 1);
return sentences.slice(start, end).join(' ');
});
const embeddings = await batchEmbed(windowedSentences);
// 3. 计算相邻句子的余弦相似度
const similarities: number[] = [];
for (let i = 0; i < embeddings.length - 1; i++) {
similarities.push(cosineSimilarity(embeddings[i], embeddings[i + 1]));
}
// 4. 找到相似度低于阈值的断点
const breakpoints: number[] = [];
for (let i = 0; i < similarities.length; i++) {
if (similarities[i] < breakpointThreshold) {
breakpoints.push(i + 1); // 在第 i+1 个句子前切分
}
}
// 5. 按断点组装 chunks
const chunks: string[] = [];
let start = 0;
for (const bp of breakpoints) {
const chunk = sentences.slice(start, bp).join('');
if (estimateTokens(chunk) >= minChunkSize) {
chunks.push(chunk);
start = bp;
}
// 如果 chunk 太小,不切分,继续累积
}
// 剩余部分
const lastChunk = sentences.slice(start).join('');
if (lastChunk.trim()) {
// 如果最后一块太小,合并到上一块
if (estimateTokens(lastChunk) < minChunkSize && chunks.length > 0) {
chunks[chunks.length - 1] += lastChunk;
} else {
chunks.push(lastChunk);
}
}
return chunks;
}
function cosineSimilarity(a: number[], b: number[]): number {
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];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
三种策略对比
| 维度 | 固定大小分块 | 递归分割分块 | 语义分块 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 语义完整性 | 差(可能截断句子) | 好(按段落/句子边界) | 最好(语义驱动) |
| 成本 | 无额外成本 | 无额外成本 | 需调用 Embedding API |
| 速度 | 最快 | 快 | 慢(需要 API 调用) |
| 适用场景 | 原型开发、格式统一的文本 | 生产环境首选 | 高质量要求、预算充足 |
| 典型工具 | 手写 | LangChain RecursiveCharacterTextSplitter | LangChain SemanticChunker |
没有放之四海而皆准的分块参数。需要根据文档类型和查询模式实验调优:
- 技术文档:chunk_size=512
1024, overlap=50100(代码块需要完整性) - 法律/合同:chunk_size=256~512, overlap=100(条款需精确匹配)
- FAQ 问答对:直接按 QA 对分块,无需 overlap
- 长篇叙事:chunk_size=1024~2048, overlap=200(需要更多上下文)
四、Embedding 模型选型
Embedding 模型将文本转换为高维向量,是 RAG 检索质量的决定性因素。选择合适的 Embedding 模型直接影响检索准确率。
更多 Embedding 原理和代码实现详见 向量搜索与语义化。
import OpenAI from 'openai';
const openai = new OpenAI();
// 单条文本向量化
export async function embed(text: string, model = 'text-embedding-3-small'): Promise<number[]> {
const response = await openai.embeddings.create({
model,
input: text,
});
return response.data[0].embedding;
}
// 批量向量化(Embedding API 支持批量输入,减少网络开销)
export async function batchEmbed(
texts: string[],
model = 'text-embedding-3-small',
batchSize = 100
): Promise<number[][]> {
const embeddings: number[][] = [];
// OpenAI API 单次最多处理 2048 条,分批处理
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize);
const response = await openai.embeddings.create({
model,
input: batch,
});
embeddings.push(...response.data.map(d => d.embedding));
}
return embeddings;
}
Embedding 模型对比
| 模型 | 提供商 | 维度 | 最大 Token | 中文支持 | 价格 (每百万 Token) | 适用场景 |
|---|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536 | 8191 | 好 | $0.02 | 高性价比首选 |
| text-embedding-3-large | OpenAI | 3072 | 8191 | 好 | $0.13 | 高精度需求 |
| embed-v4 | Cohere | 1024 | 512 | 一般 | $0.10 | 英文搜索 |
| bge-large-zh-v1.5 | BAAI | 1024 | 512 | 优秀 | 免费(本地部署) | 中文场景、隐私敏感 |
| m3e-base | Moka AI | 768 | 512 | 优秀 | 免费(本地部署) | 中文轻量级 |
| all-MiniLM-L6-v2 | Sentence Transformers | 384 | 256 | 差 | 免费(本地部署) | 英文原型开发 |
| Voyage-3 | Voyage AI | 1024 | 16000 | 好 | $0.06 | 长文本场景 |
- 快速上线 + 中英文混合:OpenAI text-embedding-3-small(性价比最高)
- 高精度英文搜索:OpenAI text-embedding-3-large 或 Cohere embed-v4
- 中文专精 + 数据隐私:BAAI bge-large-zh(本地部署,无数据外泄风险)
- 超长文本:Voyage-3(支持 16K token 输入)
注意:更换 Embedding 模型意味着需要重新向量化所有已索引文档,成本不可忽视。
五、向量数据库选型
向量数据库是 RAG 系统的存储和检索引擎,负责高效地存储 Embedding 向量并支持近似最近邻搜索(ANN)。
| 数据库 | 类型 | 语言 | 索引算法 | 元数据过滤 | 最大向量数 | 部署方式 | 适用场景 |
|---|---|---|---|---|---|---|---|
| Pinecone | 云服务 | - | 专有 | 丰富 | 数十亿 | 全托管 | 快速上线、免运维 |
| pgvector | PG 扩展 | C | IVFFlat/HNSW | SQL 级 | 数百万 | 复用现有 PG | 全栈应用、已有 PG |
| Chroma | 开源 | Python | HNSW | 基础 | 数百万 | 本地/Docker | 原型开发、小规模 |
| Qdrant | 开源 | Rust | HNSW | 丰富 | 数十亿 | 自建/云 | 高性能、大规模 |
| Milvus | 开源 | Go/C++ | IVF/HNSW/DiskANN | 丰富 | 数百亿 | 分布式集群 | 企业级、超大规模 |
| Weaviate | 开源 | Go | HNSW | GraphQL | 数十亿 | 自建/云 | 多模态、GraphQL 生态 |
| Supabase Vector | 云服务 | - | pgvector | SQL 级 | 数百万 | 全托管 | Supabase 全家桶 |
如果你的项目已经在用 PostgreSQL(大部分 Web 应用都是),pgvector 是零运维成本的选择——不需要额外部署向量数据库,只需安装 PG 扩展。对于百万级以下的向量规模,pgvector 的 HNSW 索引性能完全够用。
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// 初始化 pgvector 表
export async function initVectorStore(): Promise<void> {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS document_chunks (
id SERIAL PRIMARY KEY,
document_id TEXT NOT NULL,
document_name TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
-- highlight-next-line
embedding vector(1536), -- OpenAI text-embedding-3-small 维度
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 创建 HNSW 索引(推荐,比 IVFFlat 更快更准)
CREATE INDEX IF NOT EXISTS idx_chunks_embedding
ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
`);
}
// 插入文档分块
export async function insertChunks(
documentId: string,
documentName: string,
chunks: string[],
embeddings: number[][]
): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (let i = 0; i < chunks.length; i++) {
await client.query(
`INSERT INTO document_chunks (document_id, document_name, chunk_index, content, embedding)
VALUES ($1, $2, $3, $4, $5)`,
[documentId, documentName, i, chunks[i], JSON.stringify(embeddings[i])]
);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
interface SearchResult {
id: number;
documentId: string;
documentName: string;
content: string;
similarity: number;
metadata: Record<string, unknown>;
}
// 相似度搜索
export async function similaritySearch(
queryEmbedding: number[],
topK: number = 5,
threshold: number = 0.7
): Promise<SearchResult[]> {
const result = await pool.query<SearchResult>(
`SELECT
id,
document_id AS "documentId",
document_name AS "documentName",
content,
-- 余弦相似度(1 - 余弦距离)
1 - (embedding <=> $1::vector) AS similarity,
metadata
FROM document_chunks
WHERE 1 - (embedding <=> $1::vector) >= $3
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[JSON.stringify(queryEmbedding), topK, threshold]
);
return result.rows;
}
六、检索策略
检索质量直接决定 RAG 系统的回答质量。单纯的向量相似度搜索往往不够,需要结合多种检索策略。
1. 纯向量搜索(Similarity Search)
最基础的检索方式,将查询向量化后在向量数据库中找最近邻。
- 优点:语义理解能力强,能匹配同义词和意近表述
- 缺点:对精确关键词(如专有名词、代码名称)不敏感
2. 关键词搜索(BM25)
传统的全文检索算法,基于词频和逆文档频率。
- 优点:精确关键词匹配能力强
- 缺点:无法理解语义
3. 混合检索(Hybrid Search)推荐
interface HybridSearchOptions {
query: string;
topK: number;
alpha: number; // 向量搜索权重(0-1),1-alpha 为关键词权重
}
interface ScoredResult {
chunkId: string;
content: string;
documentName: string;
score: number; // 融合后的分数
vectorScore?: number;
bm25Score?: number;
}
/**
* 混合检索:向量搜索 + BM25 关键词搜索 + RRF 排序融合
* 兼顾语义理解和精确匹配,是生产环境的推荐方案
*/
export async function hybridSearch(options: HybridSearchOptions): Promise<ScoredResult[]> {
const { query, topK, alpha } = options;
// 并行执行向量搜索和关键词搜索
const [vectorResults, bm25Results] = await Promise.all([
vectorSimilaritySearch(query, topK * 2), // 多检索一些,留给融合和排序
bm25FullTextSearch(query, topK * 2),
]);
// RRF(Reciprocal Rank Fusion)排序融合
const rrf = reciprocalRankFusion(vectorResults, bm25Results, alpha);
return rrf.slice(0, topK);
}
/**
* RRF 排序融合算法
* 将不同检索方式的排名转化为统一分数,再加权合并
* 公式:RRF(d) = Σ 1 / (k + rank(d)),其中 k 通常取 60
*/
function reciprocalRankFusion(
vectorResults: ScoredResult[],
bm25Results: ScoredResult[],
alpha: number,
k: number = 60
): ScoredResult[] {
const scoreMap = new Map<string, ScoredResult & { fusedScore: number }>();
// 向量搜索的 RRF 分数(乘以 alpha 权重)
vectorResults.forEach((result, rank) => {
const rrfScore = alpha * (1 / (k + rank + 1));
const existing = scoreMap.get(result.chunkId);
if (existing) {
existing.fusedScore += rrfScore;
existing.vectorScore = result.score;
} else {
scoreMap.set(result.chunkId, {
...result,
fusedScore: rrfScore,
vectorScore: result.score,
});
}
});
// BM25 搜索的 RRF 分数(乘以 1-alpha 权重)
bm25Results.forEach((result, rank) => {
const rrfScore = (1 - alpha) * (1 / (k + rank + 1));
const existing = scoreMap.get(result.chunkId);
if (existing) {
existing.fusedScore += rrfScore;
existing.bm25Score = result.score;
} else {
scoreMap.set(result.chunkId, {
...result,
fusedScore: rrfScore,
bm25Score: result.score,
});
}
});
// 按融合分数排序
return Array.from(scoreMap.values())
.sort((a, b) => b.fusedScore - a.fusedScore)
.map(({ fusedScore, ...rest }) => ({ ...rest, score: fusedScore }));
}
4. 最大边际相关性(MMR)
MMR(Maximal Marginal Relevance)在检索时平衡相关性和多样性——避免 Top-K 结果都来自同一段内容的不同分块。
/**
* MMR 多样性检索
* 每次选择与 query 最相关、且与已选结果最不相似的候选
* lambda 控制相关性 vs 多样性的平衡(0.5-0.7 通常较好)
*/
export function mmrRerank(
queryEmbedding: number[],
candidates: Array<{ embedding: number[]; content: string; score: number }>,
topK: number,
lambda: number = 0.7
): Array<{ content: string; score: number }> {
const selected: typeof candidates = [];
const remaining = [...candidates];
for (let i = 0; i < topK && remaining.length > 0; i++) {
let bestIndex = -1;
let bestScore = -Infinity;
for (let j = 0; j < remaining.length; j++) {
// 与查询的相似度
const relevance = cosineSimilarity(queryEmbedding, remaining[j].embedding);
// 与已选结果的最大相似度(衡量冗余程度)
const maxRedundancy = selected.length === 0
? 0
: Math.max(...selected.map(s => cosineSimilarity(s.embedding, remaining[j].embedding)));
// MMR 公式:lambda * 相关性 - (1 - lambda) * 冗余度
const mmrScore = lambda * relevance - (1 - lambda) * maxRedundancy;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestIndex = j;
}
}
if (bestIndex >= 0) {
selected.push(remaining[bestIndex]);
remaining.splice(bestIndex, 1);
}
}
return selected.map(s => ({ content: s.content, score: s.score }));
}
七、重排序(Reranking)
初检阶段为了速度会使用双编码器(Bi-Encoder),将 query 和 document 分别编码后计算距离。重排序阶段使用交叉编码器(Cross-Encoder),将 query 和 document 拼接后一起编码,能捕捉更精细的语义关系。
// 使用 Cohere Rerank API
interface RerankResult {
index: number;
relevanceScore: number;
}
/**
* 重排序:使用 Cross-Encoder 对初检结果精排
* Cohere Rerank 是目前最流行的重排序 API
*/
export async function rerankWithCohere(
query: string,
documents: string[],
topN: number = 5
): Promise<RerankResult[]> {
const response = await fetch('https://api.cohere.ai/v1/rerank', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.COHERE_API_KEY}`,
},
body: JSON.stringify({
model: 'rerank-english-v3.0', // 或 rerank-multilingual-v3.0
query,
documents,
top_n: topN,
return_documents: false,
}),
});
const data = await response.json();
return data.results.map((r: { index: number; relevance_score: number }) => ({
index: r.index,
relevanceScore: r.relevance_score,
}));
}
// 完整的检索 + 重排序流程
export async function retrieveAndRerank(
query: string,
topK: number = 5
): Promise<SearchResult[]> {
// 1. 初检索:宽泛检索更多候选(如 Top-20)
const queryEmbedding = await embed(query);
const candidates = await similaritySearch(queryEmbedding, topK * 4, 0.5);
if (candidates.length === 0) return [];
// 2. 重排序:用 Cross-Encoder 精排
const reranked = await rerankWithCohere(
query,
candidates.map(c => c.content),
topK
);
// 3. 按重排序分数返回
return reranked
.filter(r => r.relevanceScore > 0.3) // 过滤低相关性结果
.map(r => ({
...candidates[r.index],
similarity: r.relevanceScore,
}));
}
- Bi-Encoder(如 Embedding 模型):query 和 document 分别编码为向量,再算距离。速度快(可以预计算 document 向量),但精度有限
- Cross-Encoder(如 Cohere Rerank):query 和 document 拼接后一起输入模型。精度高,但速度慢(每对都需要计算)
因此生产中的标准做法是:Bi-Encoder 粗筛(Top-2050) -> Cross-Encoder 精排(Top-35)
八、查询预处理与 Prompt 构建
查询改写(Query Rewriting)
用户的原始查询往往口语化、模糊,需要改写为更适合检索的表述。
/**
* 查询改写策略
* 1. HyDE:让 LLM 先生成一个假设性回答,用假设回答去检索
* 2. Multi-Query:将一个问题拆解为多个子查询
* 3. Step-back:将具体问题抽象为更一般化的查询
*/
// HyDE(Hypothetical Document Embeddings)
export async function hydeRewrite(query: string): Promise<string> {
const prompt = `请针对以下问题,写一段假设性的回答(不需要完全准确,但要包含相关术语和概念)。
问题:${query}
假设性回答:`;
const hypotheticalAnswer = await callLLM(prompt);
// 用假设回答的 Embedding 去检索,比原始问题更容易匹配文档
return hypotheticalAnswer;
}
// Multi-Query:将问题分解为多个检索角度
export async function multiQueryRewrite(query: string): Promise<string[]> {
const prompt = `将以下问题从不同角度改写为 3 个检索查询,每行一个:
原始问题:${query}
改写查询:`;
const result = await callLLM(prompt);
return result.split('\n').filter(Boolean).slice(0, 3);
}
Prompt 构建
interface RAGContext {
question: string;
chunks: Array<{
content: string;
documentName: string;
page?: number;
similarity: number;
}>;
}
/**
* 构建 RAG Prompt:将检索到的文档片段组装为 LLM 可理解的上下文
* 关键原则:
* 1. 明确标注每个来源,方便 LLM 引用
* 2. 限制 Prompt 总长度,避免超出上下文窗口
* 3. 明确指令:仅基于提供的文档回答,不要编造
*/
export function buildRAGPrompt(ctx: RAGContext): string {
const contextParts = ctx.chunks.map((chunk, i) => {
const sourceLabel = `[来源${i + 1}] ${chunk.documentName}${chunk.page ? `(第${chunk.page}页)` : ''}`;
return `${sourceLabel}\n${chunk.content}`;
});
return `你是一个专业的知识库问答助手。请**严格基于以下参考文档**回答用户问题。
## 回答规则
1. **仅使用提供的参考文档**中的信息回答,不要使用你自己的知识
2. 在回答中用 [来源X] 标注信息来源
3. 如果参考文档中没有相关信息,请明确说明"在现有文档中未找到相关信息"
4. 回答要结构化、清晰、直接
## 参考文档
${contextParts.join('\n\n---\n\n')}
## 用户问题
${ctx.question}
## 回答`;
}
九、前端引用来源展示
在 AI 对话界面中展示引用来源,是 RAG 系统建立用户信任的关键。更多对话 UI 设计可参考 AI 对话界面设计。
import { useState, useRef, useEffect, type FC } from 'react';
interface Source {
documentId: string;
documentName: string;
chunkText: string;
similarity: number;
page?: number;
}
interface RAGAnswerProps {
answer: string; // 包含 [来源X] 标记的 Markdown 文本
sources: Source[];
isStreaming: boolean;
}
/**
* RAG 回答组件:渲染带引用标注的 AI 回答
* 特性:
* 1. 回答文本中的 [来源X] 可点击,hover 预览原文
* 2. 底部展示完整的引用来源列表
* 3. 支持流式渲染(回答逐字出现时即可交互引用)
*/
export const RAGAnswer: FC<RAGAnswerProps> = ({ answer, sources, isStreaming }) => {
const [expandedSource, setExpandedSource] = useState<number | null>(null);
const [hoveredSource, setHoveredSource] = useState<number | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// 将 [来源X] 转换为可交互的链接
const renderAnswerWithCitations = (text: string) => {
// 匹配 [来源1]、[来源2] 等标记
const parts = text.split(/(\[来源\d+\])/g);
return parts.map((part, i) => {
const match = part.match(/\[来源(\d+)\]/);
if (match) {
const sourceIndex = parseInt(match[1], 10) - 1;
const source = sources[sourceIndex];
if (!source) return <span key={i}>{part}</span>;
return (
<span
key={i}
className="citation-link"
onMouseEnter={() => setHoveredSource(sourceIndex)}
onMouseLeave={() => setHoveredSource(null)}
onClick={() => setExpandedSource(
expandedSource === sourceIndex ? null : sourceIndex
)}
>
<sup className="citation-badge">{match[1]}</sup>
{/* Hover 时显示来源预览 tooltip */}
{hoveredSource === sourceIndex && (
<div ref={tooltipRef} className="citation-tooltip">
<strong>{source.documentName}</strong>
{source.page && <span>(第{source.page}页)</span>}
<p>{source.chunkText.slice(0, 150)}...</p>
<span className="similarity">
相关度: {(source.similarity * 100).toFixed(0)}%
</span>
</div>
)}
</span>
);
}
// 非引用部分直接渲染 Markdown
return <span key={i} dangerouslySetInnerHTML={{ __html: part }} />;
});
};
return (
<div className="rag-answer">
{/* 回答内容 */}
<div className="answer-content">
{renderAnswerWithCitations(answer)}
{isStreaming && <span className="cursor-blink">|</span>}
</div>
{/* 引用来源列表 */}
{sources.length > 0 && !isStreaming && (
<div className="sources-section">
<h4>参考来源({sources.length})</h4>
{sources.map((source, i) => (
<details
key={i}
className="source-item"
open={expandedSource === i}
onToggle={(e) => {
if ((e.target as HTMLDetailsElement).open) {
setExpandedSource(i);
}
}}
>
<summary>
<span className="source-badge">[来源{i + 1}]</span>
<span className="source-name">{source.documentName}</span>
{source.page && <span className="source-page">第{source.page}页</span>}
<span className="similarity-bar">
<span
className="similarity-fill"
style={{ width: `${source.similarity * 100}%` }}
/>
<span className="similarity-text">
{(source.similarity * 100).toFixed(0)}%
</span>
</span>
</summary>
<blockquote className="source-text">
{source.chunkText}
</blockquote>
</details>
))}
</div>
)}
</div>
);
};
import { useState, useCallback, useRef, type FC, type FormEvent } from 'react';
interface RAGSearchResult {
answer: string;
sources: Source[];
}
/**
* RAG 搜索组件:集成文档搜索和 AI 回答的完整前端组件
* 支持流式回答和引用来源展示
*/
export const RAGSearchBox: FC = () => {
const [query, setQuery] = useState('');
const [result, setResult] = useState<RAGSearchResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [streamingAnswer, setStreamingAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const handleSearch = useCallback(async (e: FormEvent) => {
e.preventDefault();
if (!query.trim() || isLoading) return;
// 取消上一次请求
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsLoading(true);
setIsStreaming(true);
setStreamingAnswer('');
setResult(null);
try {
const response = await fetch('/api/rag/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: query }),
signal: controller.signal,
});
if (!response.ok) throw new Error('查询失败');
// 流式读取回答(参考:流式渲染与 SSE 文档)
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let fullAnswer = '';
let sources: Source[] = [];
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 格式
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.type === 'answer_chunk') {
fullAnswer += data.content;
setStreamingAnswer(fullAnswer);
} else if (data.type === 'sources') {
sources = data.sources;
}
}
}
}
}
setResult({ answer: fullAnswer, sources });
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('RAG 查询失败:', error);
}
} finally {
setIsLoading(false);
setIsStreaming(false);
}
}, [query, isLoading]);
return (
<div className="rag-search">
<form onSubmit={handleSearch} className="search-form">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索知识库..."
className="search-input"
/>
<button type="submit" disabled={isLoading} className="search-button">
{isLoading ? '搜索中...' : '搜索'}
</button>
</form>
{/* 流式回答展示 */}
{(isStreaming || result) && (
<RAGAnswer
answer={isStreaming ? streamingAnswer : (result?.answer || '')}
sources={result?.sources || []}
isStreaming={isStreaming}
/>
)}
</div>
);
};
十、文档上传与管理
import { useState, useCallback, type FC } from 'react';
interface UploadedDoc {
id: string;
name: string;
size: number;
status: 'uploading' | 'processing' | 'indexed' | 'error';
chunkCount?: number;
errorMessage?: string;
progress?: number;
}
const SUPPORTED_TYPES = ['.pdf', '.doc', '.docx', '.md', '.txt', '.html'];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
/**
* 文档上传组件:支持拖拽上传、进度跟踪、索引状态轮询
*/
export const DocumentUpload: FC = () => {
const [docs, setDocs] = useState<UploadedDoc[]>([]);
const [isDragging, setIsDragging] = useState(false);
const updateDoc = useCallback((id: string, updates: Partial<UploadedDoc>) => {
setDocs(prev => prev.map(d => d.id === id ? { ...d, ...updates } : d));
}, []);
const processFile = useCallback(async (file: File) => {
// 文件校验
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
if (!SUPPORTED_TYPES.includes(ext)) {
return; // 不支持的格式,静默跳过
}
if (file.size > MAX_FILE_SIZE) {
return; // 文件过大
}
const docId = crypto.randomUUID();
const doc: UploadedDoc = {
id: docId,
name: file.name,
size: file.size,
status: 'uploading',
progress: 0,
};
setDocs(prev => [...prev, doc]);
try {
// 1. 上传文件(带进度)
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
updateDoc(docId, { progress: Math.round((e.loaded / e.total) * 100) });
}
};
const uploadRes: { documentId: string } = await new Promise((resolve, reject) => {
xhr.onload = () => resolve(JSON.parse(xhr.responseText));
xhr.onerror = reject;
xhr.open('POST', '/api/rag/upload');
xhr.send(formData);
});
// 2. 触发索引
updateDoc(docId, { status: 'processing', progress: undefined });
await fetch(`/api/rag/index/${uploadRes.documentId}`, { method: 'POST' });
// 3. 轮询索引状态
const pollInterval = setInterval(async () => {
const res = await fetch(`/api/rag/status/${uploadRes.documentId}`);
const status = await res.json();
if (status.state === 'completed') {
clearInterval(pollInterval);
updateDoc(docId, { status: 'indexed', chunkCount: status.chunkCount });
} else if (status.state === 'error') {
clearInterval(pollInterval);
updateDoc(docId, { status: 'error', errorMessage: status.error });
}
}, 2000);
} catch (error) {
updateDoc(docId, {
status: 'error',
errorMessage: error instanceof Error ? error.message : '上传失败',
});
}
}, [updateDoc]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
files.forEach(processFile);
}, [processFile]);
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div
className={`doc-upload ${isDragging ? 'dragging' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<div className="upload-area">
<p>拖拽文件到此处,或</p>
<label className="upload-button">
选择文件
<input
type="file"
multiple
hidden
accept={SUPPORTED_TYPES.join(',')}
onChange={(e) => {
if (e.target.files) {
Array.from(e.target.files).forEach(processFile);
}
}}
/>
</label>
<p className="upload-hint">
支持 PDF、Word、Markdown、HTML、TXT,单个文件不超过 50MB
</p>
</div>
{docs.length > 0 && (
<div className="doc-list">
{docs.map(doc => (
<div key={doc.id} className={`doc-item doc-${doc.status}`}>
<span className="doc-name">{doc.name}</span>
<span className="doc-size">{formatSize(doc.size)}</span>
<span className="doc-status">
{doc.status === 'uploading' && `上传中 ${doc.progress || 0}%`}
{doc.status === 'processing' && '索引中...'}
{doc.status === 'indexed' && `已索引(${doc.chunkCount} 个片段)`}
{doc.status === 'error' && `失败: ${doc.errorMessage}`}
</span>
</div>
))}
</div>
)}
</div>
);
};
十一、RAG 评估指标
RAG 系统的评估需要同时关注检索质量和生成质量两个维度。
interface EvalSample {
question: string;
expectedAnswer: string; // 标准答案(人工标注)
expectedChunkIds: string[]; // 应该被检索到的文档片段 ID
}
interface EvalResult {
retrievalMetrics: {
recallAtK: number;
mrr: number;
precision: number;
};
generationMetrics: {
faithfulness: number; // 0-1,回答忠实于来源的程度
relevancy: number; // 0-1,回答与问题的相关度
completeness: number; // 0-1,回答的完整度
};
}
/**
* 评估 RAG 系统质量
* 使用 LLM-as-Judge 方法评估生成质量
*/
export async function evaluateRAG(samples: EvalSample[]): Promise<EvalResult> {
let totalRecall = 0;
let totalMRR = 0;
let totalPrecision = 0;
let totalFaithfulness = 0;
let totalRelevancy = 0;
let totalCompleteness = 0;
for (const sample of samples) {
// 1. 执行检索
const queryEmbedding = await embed(sample.question);
const results = await similaritySearch(queryEmbedding, 10, 0.0);
const retrievedIds = results.map(r => r.documentId);
// 2. 计算检索指标
// Recall@K:前 K 个结果中包含了多少期望结果
const hits = sample.expectedChunkIds.filter(id => retrievedIds.includes(id));
totalRecall += hits.length / sample.expectedChunkIds.length;
// MRR:第一个正确结果的排名倒数
const firstHitIndex = retrievedIds.findIndex(id =>
sample.expectedChunkIds.includes(id)
);
totalMRR += firstHitIndex >= 0 ? 1 / (firstHitIndex + 1) : 0;
// Precision@K
const relevantInTop5 = retrievedIds
.slice(0, 5)
.filter(id => sample.expectedChunkIds.includes(id));
totalPrecision += relevantInTop5.length / 5;
// 3. 生成回答并评估
const ragResult = await ragQuery(sample.question);
// 使用 LLM-as-Judge 评估生成质量
const evalPrompt = `请评估以下 AI 回答的质量。
## 用户问题
${sample.question}
## 参考答案(标准)
${sample.expectedAnswer}
## AI 回答
${ragResult.answer}
## 检索到的文档片段
${ragResult.sources.map(s => s.chunkText).join('\n---\n')}
请按以下维度打分(0-1):
1. faithfulness(忠实度):回答是否**仅**基于检索到的文档片段?是否编造了文档中没有的信息?
2. relevancy(相关性):回答是否切中问题要点?
3. completeness(完整性):对比参考答案,AI 回答是否涵盖了所有关键信息?
请严格按 JSON 格式返回:{"faithfulness": 0.9, "relevancy": 0.8, "completeness": 0.7}`;
const evalResult = await callLLM(evalPrompt, { responseFormat: 'json' });
const scores = JSON.parse(evalResult);
totalFaithfulness += scores.faithfulness;
totalRelevancy += scores.relevancy;
totalCompleteness += scores.completeness;
}
const n = samples.length;
return {
retrievalMetrics: {
recallAtK: totalRecall / n,
mrr: totalMRR / n,
precision: totalPrecision / n,
},
generationMetrics: {
faithfulness: totalFaithfulness / n,
relevancy: totalRelevancy / n,
completeness: totalCompleteness / n,
},
};
}
- 不要只看检索指标:检索到了正确文档不代表 LLM 能生成好的回答
- 评估集要有代表性:应覆盖不同类型的问题(事实查询、对比分析、操作步骤等)
- LLM-as-Judge 的偏差:LLM 评分会偏向自己生成的内容,可以使用不同于生成的模型来评估
- 关注 Faithfulness:这是 RAG 最核心的指标——如果回答编造了来源中没有的信息,RAG 的意义就失去了
十二、完整 RAG 查询流程
将上述所有环节串联起来的完整查询流程。
interface RAGPipelineConfig {
embeddingModel: string;
topK: number;
similarityThreshold: number;
enableReranking: boolean;
enableQueryRewriting: boolean;
hybridSearchAlpha: number; // 向量搜索权重
}
interface RAGResult {
answer: string;
sources: Array<{
documentId: string;
documentName: string;
chunkText: string;
similarity: number;
page?: number;
}>;
metadata: {
retrievalTimeMs: number;
rerankingTimeMs?: number;
generationTimeMs: number;
totalTimeMs: number;
chunksRetrieved: number;
chunksAfterRerank: number;
};
}
const DEFAULT_CONFIG: RAGPipelineConfig = {
embeddingModel: 'text-embedding-3-small',
topK: 5,
similarityThreshold: 0.7,
enableReranking: true,
enableQueryRewriting: true,
hybridSearchAlpha: 0.7,
};
/**
* 完整 RAG 流水线
* 查询改写 → 混合检索 → 重排序 → Prompt 构建 → LLM 生成
*/
export async function ragPipeline(
question: string,
config: Partial<RAGPipelineConfig> = {}
): Promise<RAGResult> {
const cfg = { ...DEFAULT_CONFIG, ...config };
const startTime = Date.now();
// 1. 查询预处理
let searchQueries = [question];
if (cfg.enableQueryRewriting) {
const rewrittenQueries = await multiQueryRewrite(question);
searchQueries = [question, ...rewrittenQueries];
}
// 2. 混合检索(对每个查询都执行,合并去重)
const retrievalStart = Date.now();
const allResults = new Map<string, SearchResult>();
for (const q of searchQueries) {
const results = await hybridSearch({
query: q,
topK: cfg.topK * 4, // 宽泛检索
alpha: cfg.hybridSearchAlpha,
});
results.forEach(r => {
if (!allResults.has(r.chunkId) || r.score > allResults.get(r.chunkId)!.score) {
allResults.set(r.chunkId, r);
}
});
}
let candidates = Array.from(allResults.values());
const retrievalTimeMs = Date.now() - retrievalStart;
// 3. 重排序
let rerankingTimeMs: number | undefined;
if (cfg.enableReranking && candidates.length > 0) {
const rerankStart = Date.now();
const reranked = await rerankWithCohere(
question,
candidates.map(c => c.content),
cfg.topK
);
candidates = reranked
.filter(r => r.relevanceScore > 0.3)
.map(r => ({ ...candidates[r.index], similarity: r.relevanceScore }));
rerankingTimeMs = Date.now() - rerankStart;
} else {
candidates = candidates.slice(0, cfg.topK);
}
// 4. 过滤低相似度
const relevantChunks = candidates.filter(
c => c.similarity >= cfg.similarityThreshold
);
if (relevantChunks.length === 0) {
return {
answer: '抱歉,在现有文档中没有找到与您问题相关的信息。请尝试换个方式提问,或上传相关文档。',
sources: [],
metadata: {
retrievalTimeMs,
rerankingTimeMs,
generationTimeMs: 0,
totalTimeMs: Date.now() - startTime,
chunksRetrieved: allResults.size,
chunksAfterRerank: 0,
},
};
}
// 5. 构建 Prompt 并生成回答
const generationStart = Date.now();
const prompt = buildRAGPrompt({
question,
chunks: relevantChunks.map(c => ({
content: c.content,
documentName: c.documentName,
similarity: c.similarity,
page: c.metadata?.page,
})),
});
const answer = await callLLM(prompt);
const generationTimeMs = Date.now() - generationStart;
return {
answer,
sources: relevantChunks.map(chunk => ({
documentId: chunk.documentId,
documentName: chunk.documentName,
chunkText: chunk.content,
similarity: chunk.similarity,
page: chunk.metadata?.page,
})),
metadata: {
retrievalTimeMs,
rerankingTimeMs,
generationTimeMs,
totalTimeMs: Date.now() - startTime,
chunksRetrieved: allResults.size,
chunksAfterRerank: relevantChunks.length,
},
};
}
常见面试问题
Q1: RAG 和模型微调(Fine-tuning)有什么区别?各自的适用场景?
答案:
| 维度 | RAG | Fine-tuning |
|---|---|---|
| 知识更新 | 实时(更新文档即可) | 需重新训练 |
| 成本 | 低(向量化 + 存储) | 高(训练费用 + GPU) |
| 可解释性 | 高(可展示引用来源) | 低(知识内化在权重中) |
| 准确性 | 依赖检索质量 | 模型内化知识,稳定性高 |
| 幻觉控制 | 好(有来源约束) | 较差(仍可能编造) |
| 延迟 | 较高(检索 + 生成两步) | 较低(直接生成) |
| 适用场景 | 知识库问答、文档搜索、实时知识 | 调整模型风格/语气/格式 |
实际项目中常结合使用:用 Fine-tuning 调整模型的回答风格和格式偏好,用 RAG 提供实时的领域知识。例如,一个企业客服机器人可以 Fine-tune 模型以使用公司的语气风格,同时用 RAG 接入最新的产品文档。
Q2: 文本分块(Chunking)策略有哪些?如何选择合适的分块参数?
答案:
四种主要策略:
- 固定大小分块:按字符/Token 数机械切割。实现最简单但可能截断句子,适合快速原型
- 按段落/句子分块:以双换行或句号分割。保证段落完整性,但块大小不均匀
- 递归分割:先按大分隔符(章节标题)分割,再按小分隔符(段落、句子)递归细分。生产环境推荐,LangChain 默认使用
- 语义分块:用 Embedding 计算相邻句子的语义相似度,在相似度骤降处切分。效果最好但需要 API 调用,成本高
参数调优原则:
- chunk_size:512-1024 Tokens 最常见。过大(>2000)会稀释语义信号,过小(<200)会丢失上下文
- chunk_overlap:通常为 chunk_size 的 10-20%。防止关键信息恰好被切在边界处
- 不同文档类型需要不同参数:FAQ 按 QA 对分块;代码文档需保证代码块完整;法律条款需小块精确匹配
Q3: 如何评估 RAG 系统的效果?有哪些关键指标?
答案:
RAG 评估包含三个层面:
1. 检索质量指标:
- Recall@K:前 K 个检索结果中包含正确答案片段的比例。反映检索的召回能力
- MRR(Mean Reciprocal Rank):第一个正确结果的排名倒数的均值。反映结果排序质量
- NDCG:归一化折损累积增益,考虑结果排序位置的综合指标
2. 生成质量指标(常用 LLM-as-Judge 方法):
- Faithfulness(忠实度):回答是否严格基于检索到的文档,不编造信息。这是 RAG 最核心的指标
- Relevancy(相关性):回答是否切中用户问题的要点
- Completeness(完整性):回答是否覆盖了所有相关的关键信息
3. 端到端指标:
- 用户满意度(thumbs up/down)
- 引用点击率
- 会话轮数(越少越好,说明一次就回答到位)
常用评估框架:RAGAS、DeepEval、TruLens。
Q4: 什么是混合检索(Hybrid Search)?为什么纯向量搜索不够?
答案:
纯向量搜索通过语义理解匹配内容,但有两个明显短板:
- 精确关键词不敏感:搜索 "useEffect" 可能匹配到 "副作用处理" 但忘了包含 useEffect 原文
- 稀有专有名词:Embedding 模型对训练数据中罕见的词汇(如产品名、代号)编码能力弱
混合检索 = 向量搜索(语义理解) + BM25 关键词搜索(精确匹配),用 RRF(Reciprocal Rank Fusion) 融合两种排序结果。
// RRF 公式:对每个文档 d,分数 = Σ 1/(k + rank_i(d))
// k 通常取 60,rank 是文档在每个检索结果列表中的排名
const rrfScore = alpha * (1 / (60 + vectorRank)) + (1 - alpha) * (1 / (60 + bm25Rank));
alpha 参数控制语义 vs 精确匹配的权重:
- alpha=0.7:偏向语义搜索(大部分通用场景)
- alpha=0.3:偏向关键词搜索(技术文档、代码搜索)
- alpha=0.5:两者均衡
Q5: 什么是重排序(Reranking)?为什么需要两阶段检索?
答案:
两阶段检索是因为精度和速度不可兼得:
- 第一阶段(Bi-Encoder):将 query 和 document 分别编码为向量,用 ANN 算法快速检索。document 向量可以预计算,所以非常快(毫秒级),但精度有限。检索 Top-20~50 候选
- 第二阶段(Cross-Encoder/Reranking):将 query 和每个候选 document 拼接后一起输入模型,做精细的语义匹配。精度远高于 Bi-Encoder,但速度慢(每对需要单独计算),所以只能处理少量候选。精排出 Top-3~5
// 流程示意
const coarseCandidates = await vectorSearch(query, topK: 50); // 粗筛:快
const fineCandidates = await rerank(query, coarseCandidates, topN: 5); // 精排:准
const answer = await generateWithLLM(query, fineCandidates);
常用重排序服务:Cohere Rerank(API 方式,最方便)、BGE-Reranker(开源,可本地部署)。
Q6: 前端在 RAG 系统中承担哪些关键职责?
答案:
前端是 RAG 系统的体验层,核心职责包括:
- 文档管理界面:文档上传(拖拽、批量)、上传进度、索引状态轮询、文档列表/搜索/删除
- 检索交互体验:搜索框自动补全、高级筛选(按文档类型/日期/标签)、搜索历史
- 引用来源展示:回答中 [来源X] 可交互(hover 预览、点击展开原文)、高亮匹配词
- 流式回答渲染:实现类 ChatGPT 的逐字出现效果(参考 流式渲染与 SSE)
- 反馈收集:有用/无用评价、标记错误引用、纠正回答——这些反馈数据是优化 RAG 的关键
- 知识库管理:文档分类/标签管理、权限控制界面、索引统计/健康度仪表盘
Q7: 如何处理 RAG 系统中的 "幻觉" 问题?
答案:
即使使用了 RAG,LLM 仍可能生成不忠实于来源文档的内容(幻觉)。应对策略:
- Prompt 工程:在系统 Prompt 中强调 "仅基于提供的文档回答"、"无法确定时明确说明"
- 降低 Temperature:设置较低的 temperature(如 0.1-0.3),减少随机性
- 引用标注:要求 LLM 在回答中标注 [来源X],便于用户验证
- Faithfulness 检查:用另一个 LLM 评估回答是否忠实于来源文档(后处理校验)
- 前端层面:
- 展示引用来源,让用户自行判断
- 提供 "标记不准确" 按钮收集反馈
- 如果没有检索到相关文档,明确提示而非让 LLM 凭空回答
- 检索质量优化:幻觉的根本原因往往是检索到了不相关的文档——优化分块、Embedding、重排序才是治本之道
Q8: Embedding 向量的维度如何影响 RAG 性能?如何在精度和成本间平衡?
答案:
| 维度 | 低维 (384) | 中维 (1024-1536) | 高维 (3072) |
|---|---|---|---|
| 存储成本 | 低 | 中 | 高 |
| 检索速度 | 快 | 中 | 慢 |
| 语义精度 | 一般 | 好 | 最好 |
| 适用场景 | 原型开发 | 生产环境 | 高精度需求 |
OpenAI text-embedding-3 系列支持维度截断(Matryoshka Representation Learning):可以只取前 N 维使用,在精度和成本间灵活权衡。例如,text-embedding-3-large 的 3072 维可以截断为 1024 维,损失极小。
// OpenAI 支持指定输出维度
const response = await openai.embeddings.create({
model: 'text-embedding-3-large',
input: text,
dimensions: 1024, // 截断为 1024 维
});
实际建议:先用 text-embedding-3-small(1536 维)上线,通过评估数据确定精度不够时再升级。更换模型需要重新向量化所有文档,成本不可忽视。
Q9: 如何优化 RAG 系统的检索延迟?
答案:
RAG 系统的延迟包括:Embedding 生成(50-200ms)+ 向量检索(10-100ms)+ Reranking(200-500ms)+ LLM 生成(1-10s)。
优化策略:
- Embedding 缓存:相同查询不重复调用 Embedding API,用 Redis/内存缓存
- 向量数据库索引优化:
- HNSW 索引参数:增大 ef_search 提升召回率,但会增加延迟
- 合理设置 top_k:不要过大,初检 20-50 即可
- 预过滤代替后过滤:用元数据(文档类型、日期)在向量搜索时直接过滤,减少候选数量
- 异步并行:向量搜索和 BM25 搜索并行执行
- 流式生成:LLM 生成是延迟最大的环节,用流式响应让用户尽早看到内容(参考 流式渲染与 SSE)
- Reranking 开关:简单查询可以跳过重排序,只对复杂查询启用
// 并行检索 + 流式生成
const [vectorResults, bm25Results] = await Promise.all([
vectorSearch(query, topK),
bm25Search(query, topK),
]);
// 立即开始流式生成,不等完整结果
const stream = await streamGenerateWithLLM(query, mergedResults);
Q10: 什么是 HyDE(Hypothetical Document Embeddings)?有什么优势?
答案:
HyDE 是一种查询改写策略,核心思路是:用户的简短问题和文档中的长篇回答在 Embedding 空间中可能距离较远(因为形式差异大)。HyDE 先让 LLM 生成一个"假设性回答",再用这个假设回答去检索。
优势:
- 假设回答和实际文档在形式上更接近(都是长文本、都包含相关术语),Embedding 相似度更高
- 对于简短或模糊的用户问题,效果提升明显
劣势:
- 多了一次 LLM 调用(约 0.5-1s 延迟)
- 如果假设回答方向完全错误,可能检索到不相关的文档
建议:可以同时用原始问题和 HyDE 假设回答检索,合并去重,取两者的并集。
Q11: 如何构建多轮对话的 RAG 系统?和单轮查询有什么区别?
答案:
单轮 RAG 每次查询都是独立的。多轮对话 RAG 需要处理上下文依赖——用户的后续问题可能引用前文("它的缺点是什么?"中的"它"指什么?)。
关键技术:对话历史压缩 + 问题重写
interface ConversationTurn {
role: 'user' | 'assistant';
content: string;
}
// 将多轮对话中的追问改写为独立的检索查询
async function rewriteWithContext(
currentQuestion: string,
history: ConversationTurn[]
): Promise<string> {
if (history.length === 0) return currentQuestion;
const recentHistory = history.slice(-6); // 只取最近 3 轮
const prompt = `基于以下对话历史,将用户的最新问题改写为一个**独立的、无需上下文即可理解**的检索查询。
## 对话历史
${recentHistory.map(t => `${t.role}: ${t.content}`).join('\n')}
## 用户最新问题
${currentQuestion}
## 改写后的独立查询:`;
return callLLM(prompt);
}
// 使用示例
// 对话历史:["React 的虚拟 DOM 是什么?", "它通过 Diff 算法对比新旧虚拟 DOM..."]
// 用户追问:"它的时间复杂度是多少?"
// 改写后:"React 虚拟 DOM Diff 算法的时间复杂度是多少?"
Q12: pgvector 和专用向量数据库(如 Pinecone)该怎么选?
答案:
| 维度 | pgvector | Pinecone/Qdrant 等专用向量 DB |
|---|---|---|
| 运维成本 | 零(复用现有 PG) | 需额外部署/付费 |
| 数据规模 | 百万级够用,千万级需调优 | 轻松处理数十亿级 |
| 元数据查询 | SQL 级(JOIN、复杂条件) | 有限的过滤能力 |
| 事务支持 | 完整 ACID | 最终一致性 |
| 检索性能 | HNSW 索引后毫秒级 | 毫秒级(更优化) |
| 生态集成 | 任何支持 PG 的 ORM | 需要专用 SDK |
选型建议:
- 已有 PostgreSQL + 数据量 < 500 万条:直接用 pgvector,零运维成本
- 需要混合查询(向量 + SQL JOIN):pgvector 天然优势
- 数据量 > 1000 万条或对检索延迟极敏感:考虑 Qdrant/Milvus
- 想快速上线 + 不想运维:Pinecone(全托管)
- 已有 Supabase 栈:Supabase Vector(底层也是 pgvector)
Q13: 如何处理 RAG 中的大文档(如 500 页 PDF)?
答案:
大文档带来三个挑战:解析慢、分块多、检索噪声大。
处理策略:
- 异步处理:将文档处理放入消息队列,前端轮询状态(参考上文的 DocumentUpload 组件)
- 分层索引:
- 第一层:对每一章/节生成摘要,建立摘要级索引
- 第二层:每一节内部按段落分块,建立细粒度索引
- 检索时先匹配摘要确定章节,再在章节内细搜
- 元数据丰富化:保留页码、章节标题、目录层级等元数据,支持精准过滤
- 增量索引:文档更新时只重新处理变化的部分,而非全量重建
- Map-Reduce 摘要:对超长文档先分块摘要,再对摘要做二次摘要,最终得到全文概述
// 分层索引示意
interface HierarchicalChunk {
level: 'document' | 'section' | 'paragraph';
content: string;
summary?: string; // section 和 document 级别有摘要
parentId?: string;
metadata: { page?: number; sectionTitle?: string };
}
Q14: RAG 系统中如何保障数据安全和隐私?
答案:
RAG 系统涉及企业私有数据,安全尤为重要。更多 AI 安全话题参考 AI 应用安全。
关键安全措施:
- 访问控制:
- 文档级权限:不同用户只能检索其有权限的文档
- 在向量搜索时通过 metadata filter 实现权限过滤
- 数据隔离:
- 多租户场景下,不同租户的向量存储在不同的 namespace/collection
- Embedding API 选择:
- 敏感数据考虑本地部署 Embedding 模型(如 BGE),避免数据外泄到第三方 API
- Prompt 注入防护:
- 检索到的文档可能包含恶意指令("忽略之前的指令...")
- 需要在 Prompt 中明确区分 system/context/user 部分
- 审计日志:记录谁查询了什么、检索到了哪些文档、生成了什么回答
// 带权限过滤的向量搜索
const results = await vectorDB.search({
vector: queryEmbedding,
topK: 10,
filter: {
// 只检索当前用户有权限的文档
allowedUserIds: { $contains: currentUserId },
// 或按部门/角色过滤
department: currentUser.department,
},
});
相关链接
- LangChain RAG 教程
- RAGAS 评估框架
- Pinecone 文档
- pgvector GitHub
- Cohere Rerank API
- AI 基础概念与前端应用 - LLM 原理、Token 概念
- 向量搜索与语义化 - Embedding 原理、相似度算法
- 前端接入大模型 API - API 调用、流式响应基础
- 流式渲染与 SSE - 流式回答的前端实现
- AI 对话界面设计 - 对话 UI 的完整设计方案
- Function Calling 与 AI Agent - Agent 架构与工具调用
- AI 应用安全 - Prompt 注入、数据安全