1. AI 聊天機器人中的 RAG-為什麼需要它?
LLM只知道訓練資料。聊天機器人企業需要根據以下情況做出回應 私人數據 組織的資訊:文件、政策、產品資訊、常見問題。 RAG(檢索增強生成)解決了這個問題。
┌──────────────────────── RAG PIPELINE ────────────────────────────┐
│ │
│ INGESTION (Offline) RETRIEVAL (Online) │
│ ┌──────────┐ ┌──────────┐ │
│ │ Documents│──▶ Parse ──▶ Chunk ──▶ │ Query │ │
│ │ PDF/HTML │ │ │ │ │ │ │
│ │ DOCX/MD │ ▼ ▼ │ └────┬─────┘ │
│ └──────────┘ Clean Embed │ │ │
│ │ │ │ ┌────▼─────┐ │
│ ▼ ▼ │ │ Hybrid │ │
│ ┌──────┐ ┌──────┐ │ │ Search │ │
│ │ BM25 │ │Vector│ │ │BM25+Embd │ │
│ │Index │ │Store │ │ └────┬─────┘ │
│ └──────┘ └──────┘ │ │ │
│ │ ┌────▼─────┐ │
│ │ │ Re-rank │ │
│ │ └────┬─────┘ │
│ │ │ │
│ │ ┌────▼─────┐ ┌──────────┐ │
│ │ │ Format │─▶│ Citation │ │
│ │ │ Context │ │ Generate │ │
│ │ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
2. 文件攝取管道
interface DocumentParser {
parse(input: Buffer, mimeType: string): Promise<ParsedDocument>;
}
interface ParsedDocument {
title: string;
content: string; // Plain text content
sections: Section[]; // Structured sections with headings
metadata: {
source: string;
mimeType: string;
pageCount?: number;
lastModified?: Date;
language?: string;
};
}
class IngestionPipeline {
private parsers: Map<string, DocumentParser> = new Map([
['application/pdf', new PDFParser()],
['text/html', new HTMLParser()],
['text/markdown', new MarkdownParser()],
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', new DOCXParser()],
['text/plain', new PlainTextParser()],
['application/json', new JSONParser()],
]);
async ingest(
tenantId: string,
knowledgeBaseId: string,
file: UploadedFile,
): Promise<IngestionResult> {
// 1. Parse document
const parser = this.parsers.get(file.mimeType);
if (!parser) throw new UnsupportedFormatError(file.mimeType);
const parsed = await parser.parse(file.buffer, file.mimeType);
// 2. Clean text
const cleaned = this.cleanText(parsed.content);
// 3. Chunk
const chunks = await this.chunker.chunk(cleaned, {
strategy: 'recursive',
chunkSize: 512,
chunkOverlap: 50,
});
// 4. Generate embeddings
const embeddings = await this.embeddingService.embedBatch(
chunks.map(c => c.content),
);
// 5. Store in vector database
const points = chunks.map((chunk, i) => ({
id: crypto.randomUUID(),
vector: embeddings[i],
payload: {
tenantId,
knowledgeBaseId,
documentId: file.id,
content: chunk.content,
chunkIndex: i,
metadata: {
title: parsed.title,
section: chunk.section,
source: file.name,
pageNumber: chunk.pageNumber,
},
},
}));
await this.vectorStore.upsert('knowledge_chunks', points);
// 6. Index for BM25 full-text search
await this.bm25Index.index(points.map(p => ({
id: p.id,
content: p.payload.content,
metadata: p.payload.metadata,
})));
return {
documentId: file.id,
chunksCreated: chunks.length,
totalTokens: chunks.reduce((sum, c) => sum + c.tokenCount, 0),
};
}
private cleanText(text: string): string {
return text
.replace(/\s+/g, ' ') // Normalize whitespace
.replace(/\n{3,}/g, '\n\n') // Max 2 newlines
.replace(/[^\S\n]+/g, ' ') // Collapse spaces (keep newlines)
.trim();
}
}
3. 分塊策略
| 策略 | 它是如何運作的 | 最適合 |
|---|---|---|
| 固定尺寸 | 每 N 個代幣進行分割 | 簡單、通用 |
| 遞迴 | 按標題→段落→句子分割 | 結構化文件 |
| 語意學 | 當嵌入相似度下降時進行分割 | 非結構化的敘述性文本 |
| 句子視窗 | 塊=句子,上下文=周圍的句子 | 精準檢索+上下文 |
| 親子 | 用於搜尋的小塊,傳回上下文的父級 | 長文件 |
class RecursiveChunker {
private readonly SEPARATORS = [
'\n## ', // H2 headers
'\n### ', // H3 headers
'\n\n', // Paragraphs
'\n', // Lines
'. ', // Sentences
' ', // Words
];
async chunk(
text: string,
config: { chunkSize: number; chunkOverlap: number },
): Promise<Chunk[]> {
return this.recursiveSplit(text, this.SEPARATORS, config);
}
private recursiveSplit(
text: string,
separators: string[],
config: { chunkSize: number; chunkOverlap: number },
): Chunk[] {
if (text.length <= config.chunkSize) {
return [{ content: text, tokenCount: this.estimateTokens(text) }];
}
const separator = separators[0];
const parts = text.split(separator);
const chunks: Chunk[] = [];
let currentChunk = '';
for (const part of parts) {
const candidate = currentChunk
? currentChunk + separator + part
: part;
if (this.estimateTokens(candidate) > config.chunkSize) {
if (currentChunk) {
chunks.push({
content: currentChunk.trim(),
tokenCount: this.estimateTokens(currentChunk),
});
}
if (this.estimateTokens(part) > config.chunkSize && separators.length > 1) {
// Recursively split with next separator
chunks.push(...this.recursiveSplit(part, separators.slice(1), config));
} else {
currentChunk = part;
}
} else {
currentChunk = candidate;
}
}
if (currentChunk) {
chunks.push({
content: currentChunk.trim(),
tokenCount: this.estimateTokens(currentChunk),
});
}
return this.addOverlap(chunks, config.chunkOverlap);
}
private estimateTokens(text: string): number {
return Math.ceil(text.length / 4); // Rough estimate
}
}
4. 混合搜尋——BM25 + 語意
class HybridSearchEngine {
constructor(
private vectorStore: VectorStore, // Qdrant
private bm25Index: BM25Index, // Elasticsearch/Meilisearch
private embeddingService: EmbeddingService,
) {}
async search(
query: string,
options: SearchOptions,
): Promise<SearchResult[]> {
// Run both searches in parallel
const queryEmbedding = await this.embeddingService.embed(query);
const [semanticResults, bm25Results] = await Promise.all([
this.vectorStore.search({
collection: 'knowledge_chunks',
vector: queryEmbedding,
filter: {
tenantId: options.tenantId,
knowledgeBaseId: options.knowledgeBaseId,
},
limit: options.topK * 2,
}),
this.bm25Index.search({
query,
filter: {
tenantId: options.tenantId,
knowledgeBaseId: options.knowledgeBaseId,
},
limit: options.topK * 2,
}),
]);
// Reciprocal Rank Fusion (RRF) to combine results
return this.reciprocalRankFusion(semanticResults, bm25Results, options.topK);
}
private reciprocalRankFusion(
semanticResults: SearchResult[],
bm25Results: SearchResult[],
topK: number,
k: number = 60, // RRF constant
): SearchResult[] {
const scoreMap = new Map<string, { result: SearchResult; score: number }>();
// Score semantic results
semanticResults.forEach((result, rank) => {
const rrf = 1 / (k + rank + 1);
const existing = scoreMap.get(result.id);
if (existing) {
existing.score += rrf;
} else {
scoreMap.set(result.id, { result, score: rrf });
}
});
// Score BM25 results
bm25Results.forEach((result, rank) => {
const rrf = 1 / (k + rank + 1);
const existing = scoreMap.get(result.id);
if (existing) {
existing.score += rrf;
} else {
scoreMap.set(result.id, { result, score: rrf });
}
});
// Sort by combined RRF score
return Array.from(scoreMap.values())
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(entry => ({ ...entry.result, score: entry.score }));
}
}
5. 重新排序-交叉編碼器
interface ReRanker {
rerank(query: string, documents: SearchResult[], topK: number): Promise<SearchResult[]>;
}
class CohereReRanker implements ReRanker {
constructor(private apiKey: string) {}
async rerank(
query: string,
documents: SearchResult[],
topK: number,
): Promise<SearchResult[]> {
const response = await fetch('https://api.cohere.ai/v1/rerank', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'rerank-english-v3.0',
query,
documents: documents.map(d => d.content),
top_n: topK,
return_documents: false,
}),
});
const data = await response.json();
return data.results.map((r: { index: number; relevance_score: number }) => ({
...documents[r.index],
score: r.relevance_score,
}));
}
}
6. 引文生成
class CitationGenerator {
formatContextWithCitations(results: SearchResult[]): {
context: string;
citations: Citation[];
} {
const citations: Citation[] = [];
const context = results
.map((result, index) => {
const citationId = `[${index + 1}]`;
citations.push({
id: citationId,
documentId: result.metadata.documentId,
chunkId: result.id,
content: result.content.substring(0, 200),
source: result.metadata.source,
pageNumber: result.metadata.pageNumber,
score: result.score,
});
return `${citationId} ${result.content}`;
})
.join('\n\n');
return { context, citations };
}
// System prompt instruction for citation
getCitationInstruction(): string {
return `When answering, cite sources using [1], [2], etc.
Only cite information that directly comes from the provided context.
If the context doesn't contain the answer, say you don't have enough information.
Do NOT make up information or citations.`;
}
}
7. 完整的RAG管道服務
class RAGPipeline {
constructor(
private searchEngine: HybridSearchEngine,
private reRanker: ReRanker,
private citationGenerator: CitationGenerator,
private queryTransformer: QueryTransformer,
) {}
async search(
query: string,
options: RAGOptions,
): Promise<RAGResult> {
// 1. Query transformation (expand, decompose, HyDE)
const transformedQuery = await this.queryTransformer.transform(query);
// 2. Hybrid search (BM25 + Semantic)
const searchResults = await this.searchEngine.search(transformedQuery, {
tenantId: options.tenantId,
knowledgeBaseId: options.knowledgeBaseId,
topK: 20, // Retrieve more for re-ranking
});
// 3. Re-rank
const reranked = await this.reRanker.rerank(query, searchResults, options.topK ?? 5);
// 4. Format with citations
const { context, citations } = this.citationGenerator.formatContextWithCitations(reranked);
return { context, citations, totalResults: searchResults.length };
}
}
第 5 課總結
| 舞台 | 組件 | 關鍵決策 |
|---|---|---|
| 食入 | 解析器 + 分塊器 + 嵌入器 | 區塊大小 512 個標記,50 個重疊 |
| 儲存 | 向量存儲 + BM25 索引 | Qdrant 用於語義,Meilisearch 用於關鍵字 |
| 檢索 | 混合搜尋 | 用於合併分數的 RRF 融合 (k=60) |
| 重新排名 | 交叉編碼器 | Cohere rerank-v3 或本地交叉編碼器 |
| 引文 | 引文產生器 | 內嵌引用 [1][2] 以及來源鏈接 |
下一篇: 提示工程引擎-模板系統、思維鏈、動態提示組裝、角色管理和提示A/B測試。