跳到主要内容

RAG 检索增强生成

问题

什么是 RAG(Retrieval-Augmented Generation)?前端如何参与构建基于 RAG 的知识库问答系统?文档预处理、分块策略、检索方案、重排序和效果评估的完整技术链路是怎样的?

答案

RAG 是解决 LLM 知识时效性幻觉问题的核心技术架构——先从外部知识库中检索与用户问题相关的文档片段,再将其作为上下文提供给 LLM 生成回答。与微调(Fine-tuning)不同,RAG 不修改模型权重,而是在推理时动态注入外部知识,因此更新知识的成本极低(更新文档即可,无需重新训练)。

前端在 RAG 系统中扮演重要角色:文档上传与管理、检索体验设计、引用来源展示、反馈收集等均需要精心的前端工程。

核心价值

RAG 之所以成为企业 AI 应用的首选方案,是因为它同时解决了三个问题:

  1. 知识时效性:LLM 训练数据有截止日期,RAG 可以实时接入最新文档
  2. 领域知识缺失:企业内部文档、私有知识库不在公开训练数据中
  3. 幻觉问题:通过提供真实来源约束 LLM 输出,并可展示引用供用户验证

一、RAG 整体架构

一个完整的 RAG 系统分为两个阶段:离线索引阶段(Indexing)和在线查询阶段(Querying)。离线阶段负责将文档处理为可检索的向量索引;在线阶段负责接收用户问题、检索相关片段、生成回答。

架构理解要点

离线和在线两个阶段使用同一个 Embedding 模型是关键——文档分块和用户问题必须被编码到同一个向量空间中,否则相似度搜索将毫无意义。更换 Embedding 模型意味着需要重新向量化所有文档

二、文档预处理流水线

文档预处理是 RAG 系统的第一道防线——垃圾进垃圾出(Garbage In, Garbage Out)。不同格式的文档需要不同的解析策略。

文档解析器

server/rag/document-loader.ts
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);
}

文本清洗

server/rag/text-cleaner.ts
// 文本预处理:清洗噪声,保留有意义的内容
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 系统中影响检索质量最大的因素之一。块太大则包含过多无关信息,稀释相关内容的信号;块太小则丢失上下文,语义不完整。

三种主要分块策略

策略一:固定大小分块

server/rag/chunking/fixed-size.ts
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;
}

策略二:递归分割分块(推荐)

server/rag/chunking/recursive.ts
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,
};

策略三:语义分块

server/rag/chunking/semantic.ts
/**
* 语义分块:基于 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 RecursiveCharacterTextSplitterLangChain SemanticChunker
分块参数调优

没有放之四海而皆准的分块参数。需要根据文档类型和查询模式实验调优:

  • 技术文档:chunk_size=5121024, overlap=50100(代码块需要完整性)
  • 法律/合同:chunk_size=256~512, overlap=100(条款需精确匹配)
  • FAQ 问答对:直接按 QA 对分块,无需 overlap
  • 长篇叙事:chunk_size=1024~2048, overlap=200(需要更多上下文)

四、Embedding 模型选型

Embedding 模型将文本转换为高维向量,是 RAG 检索质量的决定性因素。选择合适的 Embedding 模型直接影响检索准确率。

更多 Embedding 原理和代码实现详见 向量搜索与语义化

server/rag/embedding.ts
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-smallOpenAI15368191$0.02高性价比首选
text-embedding-3-largeOpenAI30728191$0.13高精度需求
embed-v4Cohere1024512一般$0.10英文搜索
bge-large-zh-v1.5BAAI1024512优秀免费(本地部署)中文场景、隐私敏感
m3e-baseMoka AI768512优秀免费(本地部署)中文轻量级
all-MiniLM-L6-v2Sentence Transformers384256免费(本地部署)英文原型开发
Voyage-3Voyage AI102416000$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云服务-专有丰富数十亿全托管快速上线、免运维
pgvectorPG 扩展CIVFFlat/HNSWSQL 级数百万复用现有 PG全栈应用、已有 PG
Chroma开源PythonHNSW基础数百万本地/Docker原型开发、小规模
Qdrant开源RustHNSW丰富数十亿自建/云高性能、大规模
Milvus开源Go/C++IVF/HNSW/DiskANN丰富数百亿分布式集群企业级、超大规模
Weaviate开源GoHNSWGraphQL数十亿自建/云多模态、GraphQL 生态
Supabase Vector云服务-pgvectorSQL 级数百万全托管Supabase 全家桶
pgvector 为什么值得关注

如果你的项目已经在用 PostgreSQL(大部分 Web 应用都是),pgvector 是零运维成本的选择——不需要额外部署向量数据库,只需安装 PG 扩展。对于百万级以下的向量规模,pgvector 的 HNSW 索引性能完全够用。

server/rag/vector-store-pgvector.ts
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 系统的回答质量。单纯的向量相似度搜索往往不够,需要结合多种检索策略。

最基础的检索方式,将查询向量化后在向量数据库中找最近邻。

  • 优点:语义理解能力强,能匹配同义词和意近表述
  • 缺点:对精确关键词(如专有名词、代码名称)不敏感

2. 关键词搜索(BM25)

传统的全文检索算法,基于词频和逆文档频率。

  • 优点:精确关键词匹配能力强
  • 缺点:无法理解语义

3. 混合检索(Hybrid Search)推荐

server/rag/retrieval/hybrid-search.ts
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 结果都来自同一段内容的不同分块。

server/rag/retrieval/mmr.ts
/**
* 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 拼接后一起编码,能捕捉更精细的语义关系。

server/rag/reranking.ts
// 使用 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 vs Cross-Encoder
  • Bi-Encoder(如 Embedding 模型):query 和 document 分别编码为向量,再算距离。速度快(可以预计算 document 向量),但精度有限
  • Cross-Encoder(如 Cohere Rerank):query 和 document 拼接后一起输入模型。精度高,但速度慢(每对都需要计算)

因此生产中的标准做法是:Bi-Encoder 粗筛(Top-2050) -> Cross-Encoder 精排(Top-35)

八、查询预处理与 Prompt 构建

查询改写(Query Rewriting)

用户的原始查询往往口语化、模糊,需要改写为更适合检索的表述。

server/rag/query-rewriting.ts
/**
* 查询改写策略
* 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 构建

server/rag/prompt-builder.ts
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 对话界面设计

components/RAGAnswer.tsx
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>
);
};
components/RAGSearchBox.tsx
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>
);
};

十、文档上传与管理

components/DocumentUpload.tsx
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、HTMLTXT,单个文件不超过 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 系统的评估需要同时关注检索质量生成质量两个维度。

server/rag/evaluation.ts
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,
},
};
}
评估的常见陷阱
  1. 不要只看检索指标:检索到了正确文档不代表 LLM 能生成好的回答
  2. 评估集要有代表性:应覆盖不同类型的问题(事实查询、对比分析、操作步骤等)
  3. LLM-as-Judge 的偏差:LLM 评分会偏向自己生成的内容,可以使用不同于生成的模型来评估
  4. 关注 Faithfulness:这是 RAG 最核心的指标——如果回答编造了来源中没有的信息,RAG 的意义就失去了

十二、完整 RAG 查询流程

将上述所有环节串联起来的完整查询流程。

server/rag/pipeline.ts
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)有什么区别?各自的适用场景?

答案

维度RAGFine-tuning
知识更新实时(更新文档即可)需重新训练
成本低(向量化 + 存储)高(训练费用 + GPU)
可解释性高(可展示引用来源)低(知识内化在权重中)
准确性依赖检索质量模型内化知识,稳定性高
幻觉控制好(有来源约束)较差(仍可能编造)
延迟较高(检索 + 生成两步)较低(直接生成)
适用场景知识库问答、文档搜索、实时知识调整模型风格/语气/格式

实际项目中常结合使用:用 Fine-tuning 调整模型的回答风格和格式偏好,用 RAG 提供实时的领域知识。例如,一个企业客服机器人可以 Fine-tune 模型以使用公司的语气风格,同时用 RAG 接入最新的产品文档。

Q2: 文本分块(Chunking)策略有哪些?如何选择合适的分块参数?

答案

四种主要策略:

  1. 固定大小分块:按字符/Token 数机械切割。实现最简单但可能截断句子,适合快速原型
  2. 按段落/句子分块:以双换行或句号分割。保证段落完整性,但块大小不均匀
  3. 递归分割:先按大分隔符(章节标题)分割,再按小分隔符(段落、句子)递归细分。生产环境推荐,LangChain 默认使用
  4. 语义分块:用 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)
  • 引用点击率
  • 会话轮数(越少越好,说明一次就回答到位)

常用评估框架:RAGASDeepEvalTruLens

Q4: 什么是混合检索(Hybrid Search)?为什么纯向量搜索不够?

答案

纯向量搜索通过语义理解匹配内容,但有两个明显短板:

  1. 精确关键词不敏感:搜索 "useEffect" 可能匹配到 "副作用处理" 但忘了包含 useEffect 原文
  2. 稀有专有名词: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 系统的体验层,核心职责包括:

  1. 文档管理界面:文档上传(拖拽、批量)、上传进度、索引状态轮询、文档列表/搜索/删除
  2. 检索交互体验:搜索框自动补全、高级筛选(按文档类型/日期/标签)、搜索历史
  3. 引用来源展示:回答中 [来源X] 可交互(hover 预览、点击展开原文)、高亮匹配词
  4. 流式回答渲染:实现类 ChatGPT 的逐字出现效果(参考 流式渲染与 SSE
  5. 反馈收集:有用/无用评价、标记错误引用、纠正回答——这些反馈数据是优化 RAG 的关键
  6. 知识库管理:文档分类/标签管理、权限控制界面、索引统计/健康度仪表盘

Q7: 如何处理 RAG 系统中的 "幻觉" 问题?

答案

即使使用了 RAG,LLM 仍可能生成不忠实于来源文档的内容(幻觉)。应对策略:

  1. Prompt 工程:在系统 Prompt 中强调 "仅基于提供的文档回答"、"无法确定时明确说明"
  2. 降低 Temperature:设置较低的 temperature(如 0.1-0.3),减少随机性
  3. 引用标注:要求 LLM 在回答中标注 [来源X],便于用户验证
  4. Faithfulness 检查:用另一个 LLM 评估回答是否忠实于来源文档(后处理校验)
  5. 前端层面
    • 展示引用来源,让用户自行判断
    • 提供 "标记不准确" 按钮收集反馈
    • 如果没有检索到相关文档,明确提示而非让 LLM 凭空回答
  6. 检索质量优化:幻觉的根本原因往往是检索到了不相关的文档——优化分块、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)。

优化策略:

  1. Embedding 缓存:相同查询不重复调用 Embedding API,用 Redis/内存缓存
  2. 向量数据库索引优化
    • HNSW 索引参数:增大 ef_search 提升召回率,但会增加延迟
    • 合理设置 top_k:不要过大,初检 20-50 即可
  3. 预过滤代替后过滤:用元数据(文档类型、日期)在向量搜索时直接过滤,减少候选数量
  4. 异步并行:向量搜索和 BM25 搜索并行执行
  5. 流式生成:LLM 生成是延迟最大的环节,用流式响应让用户尽早看到内容(参考 流式渲染与 SSE
  6. 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 需要处理上下文依赖——用户的后续问题可能引用前文("它的缺点是什么?"中的"它"指什么?)。

关键技术:对话历史压缩 + 问题重写

server/rag/conversational-rag.ts
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)该怎么选?

答案

维度pgvectorPinecone/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)?

答案

大文档带来三个挑战:解析慢、分块多、检索噪声大。

处理策略:

  1. 异步处理:将文档处理放入消息队列,前端轮询状态(参考上文的 DocumentUpload 组件)
  2. 分层索引
    • 第一层:对每一章/节生成摘要,建立摘要级索引
    • 第二层:每一节内部按段落分块,建立细粒度索引
    • 检索时先匹配摘要确定章节,再在章节内细搜
  3. 元数据丰富化:保留页码、章节标题、目录层级等元数据,支持精准过滤
  4. 增量索引:文档更新时只重新处理变化的部分,而非全量重建
  5. 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 应用安全

关键安全措施:

  1. 访问控制
    • 文档级权限:不同用户只能检索其有权限的文档
    • 在向量搜索时通过 metadata filter 实现权限过滤
  2. 数据隔离
    • 多租户场景下,不同租户的向量存储在不同的 namespace/collection
  3. Embedding API 选择
    • 敏感数据考虑本地部署 Embedding 模型(如 BGE),避免数据外泄到第三方 API
  4. Prompt 注入防护
    • 检索到的文档可能包含恶意指令("忽略之前的指令...")
    • 需要在 Prompt 中明确区分 system/context/user 部分
  5. 审计日志:记录谁查询了什么、检索到了哪些文档、生成了什么回答
// 带权限过滤的向量搜索
const results = await vectorDB.search({
vector: queryEmbedding,
topK: 10,
filter: {
// 只检索当前用户有权限的文档
allowedUserIds: { $contains: currentUserId },
// 或按部门/角色过滤
department: currentUser.department,
},
});

相关链接