Week 2 Day 14:RAG System Design - 苏格拉底教学

Week 2的压轴!今天你会把过去7天学到的所有技术点串成一张完整的系统设计图,并练习如何在面试中自信地讲出来。


第一部分:问题驱动

🤔 问题1:如果面试官让你"设计一个企业知识库问答系统",你的第一句话说什么?

错误开场:

  • "好的,我会用RAG,先建向量数据库……"(直接跳到方案,没有澄清需求)
  • "这取决于很多因素……"(废话,没有给出结构)

正确开场:

"我先确认几个关键问题,然后给出架构建议。 首先——规模方面,我们大概有多少文档?每天多少查询? 其次——质量要求,允许的答案延迟是多少?准确率目标? 第三——运维约束,是否有成本预算限制?能用哪些云服务?"

为什么这样开场:

  • 展示了系统性思维
  • 澄清约束条件,避免设计方向错误
  • 给自己30秒思考时间

🤔 问题2:这个系统的核心权衡是什么?

引导问题:

  1. Chunk size大一点好还是小一点好?(权衡recall和精度)
  2. 只用向量检索够吗?什么时候需要hybrid?
  3. Rerank同步做还是异步做?(权衡延迟和准确率)
  4. 实时索引还是批量索引?(权衡数据新鲜度和吞吐量)

每个问题都没有绝对答案,但你需要能说出权衡点并给出推荐。


🤔 问题3:面试官追问"能支持1000万文档吗?",你怎么估算?

引导思路:

  1. 1000万文档,平均每篇多少tokens?
  2. 每个chunk转成向量,向量维度多少,每个向量占多少字节?
  3. 总存储需要多少?
  4. 检索一次要多少计算?

你需要能做这个估算,并且说出数字!


第二部分:完整RAG架构图

graph TB
    subgraph 写入路径 Ingestion Path
        A[文档来源<br/>Confluence / S3 / 爬虫] --> B[Document Parser<br/>PDF/Markdown/HTML]
        B --> C[Chunker<br/>段落分割 + overlap]
        C --> D[Embedding Service<br/>text-embedding-3-small]
        D --> E[(Vector DB<br/>Qdrant / Pinecone)]
        C --> F[(Metadata Store<br/>PostgreSQL)]
        E -.同步ID.-> F
    end

    subgraph 读取路径 Query Path
        G[用户查询] --> H[Query Rewriter<br/>澄清歧义 / 扩展关键词]
        H --> I{Hybrid Search}
        I --> J[Vector Search<br/>余弦相似度 Top-20]
        I --> K[Keyword Search<br/>BM25 / Elasticsearch]
        J --> L[RRF Fusion<br/>合并排名]
        K --> L
        L --> M[Reranker<br/>CrossEncoder / Cohere]
        M --> N[Top-5 Chunks]
        N --> O[Context Builder<br/>组装Prompt]
        F --> O
        O --> P[LLM Generator<br/>GPT-4 / Claude]
        P --> Q[答案 + 来源引用]
    end

    subgraph 质量保证 Quality
        Q --> R[Eval Runner<br/>Day 12]
        R --> S[Error Analyzer<br/>Day 13]
        S --> T[改进反馈]
        T --> C
        T --> O
    end

    subgraph 监控 Observability
        G --> U[请求日志]
        Q --> U
        U --> V[Metrics Dashboard<br/>P50/P95延迟, 评分趋势]
    end

第三部分:关键权衡分析

权衡1:Chunk Size vs Recall

小Chunk(200-500 tokens): + 检索更精准(向量更聚焦) + LLM处理Context噪音少 - 跨chunk的信息丢失 - Chunk总数多,存储和索引成本高 大Chunk(1000-2000 tokens): + 保留更多上下文 + Chunk总数少,存储成本低 - 向量语义被稀释,检索不精准 - LLM的Context被冗余信息填满 推荐策略: - 默认从800-1000 tokens开始 - 用Day 12的eval数据驱动调整 - 对不同类型文档用不同chunk size(技术文档用小chunk,FAQ用大chunk)
Pure Vector Search: + 实现简单,只需要一个向量数据库 + 语义理解能力强(能处理同义词) - 对精确词汇匹配弱(产品名、版本号、缩写) - 黑盒,难以debug Pure Keyword Search (BM25): + 精确匹配能力强 + 结果可解释 - 无法理解语义("帮我改下这个"找不到"修改文档") Hybrid (推荐): + 兼顾语义和精确匹配 + 通过RRF融合,互补优势 - 实现复杂度翻倍 - 需要调整向量/关键词的权重比例 判断标准: - 如果查询多含产品名、型号、精确词汇 → 必须hybrid - 如果查询以自然语言为主 → pure vector可以先试

权衡3:同步Rerank vs 异步Rerank

同步Rerank(在请求路径中): + 每次查询都享受rerank提升的质量 - 增加P50延迟约100-500ms - Reranker服务故障会影响整个查询 异步Rerank(后台预计算): + 不影响查询延迟 - 预计算结果可能过期 - 适合文档不频繁更新的场景 推荐: - 默认同步,把Reranker单独部署(容错) - 对延迟要求极高(<500ms)才考虑异步

权衡4:实时索引 vs 批量索引

实时索引: + 文档更新后立刻可查询 - 写入峰值可能压垮Embedding Service - 实现复杂(需要消息队列) 批量索引(每小时/每天): + 实现简单,批量处理效率高 - 数据有延迟(最多N小时) 推荐: - 优先批量(每小时一次),满足大多数场景 - 只有实时性要求极高的场景才做实时索引 - 用消息队列(Kafka/NATS)解耦写入和索引

第四部分:规模估算

场景:1000万文档,每天1万次查询

存储估算

文档数量:10,000,000 平均文档大小:2000 tokens ≈ 8KB(字符) 每篇分成 2000/900 ≈ 2.2个chunks → 约2200万个chunks 向量存储: embedding维度:1536(text-embedding-3-small) 每维度:4 bytes(float32) 每个向量:1536 × 4 = 6144 bytes ≈ 6KB 总向量存储:2200万 × 6KB = 132GB 原文存储(chunks文本): 平均chunk大小:900 tokens ≈ 3600 chars ≈ 3.5KB 总文本存储:2200万 × 3.5KB = 77GB Metadata(PostgreSQL): 每条记录约1KB 总metadata:2200万 × 1KB = 22GB 总存储:132 + 77 + 22 ≈ 231GB ≈ 约250GB(含20%冗余)

查询延迟估算

每次查询路径耗时: Query Embedding:50ms(API调用) Vector Search(Qdrant,250GB索引):20-50ms Keyword Search(ES):10-30ms RRF Fusion:<5ms Reranker(CrossEncoder):100-300ms LLM Generation(GPT-4,200 token输出):800-2000ms P50总延迟:约 1200ms P95总延迟:约 2500ms 优化路径: 1. 缓存热门查询结果(LRU,Day 12算法题)→ 节省 60-70%的LLM调用 2. 用更小的生成模型(GPT-4o-mini)→ 延迟降低 50% 3. Reranker换lightweight版本 → 降低 100-200ms

并发估算

每天1万查询,分布估算: 日活高峰期:9:00-18:00(工作时间9小时) 高峰期查询量:1万 × 70% ÷ 9小时 = 778次/小时 ≈ 13次/分钟 峰值QPS(乘以峰值系数3x):≈ 0.65 QPS 结论: - 单台服务器完全可以处理 - Qdrant和LLM API是主要瓶颈 - Qdrant可以单机支持250GB索引 - LLM API需要合理控制并发(避免超过rate limit)

第五部分:成本估算

初次索引成本(一次性)

Embedding 1000万文档: 平均每文档 2000 tokens 总tokens:2000万 × 2000 = 200亿 tokens text-embedding-3-small 价格:$0.02 / 1M tokens 总成本:20,000M ÷ 1M × $0.02 = $400 结论:初始建索引成本约 $400(极便宜)

每日运营成本

查询成本(每天1万次): Embedding(query): 每次查询 ≈ 50 tokens 1万次 × 50 tokens = 50万 tokens/天 $0.02 / 1M × 0.5M = $0.01/天 Reranker(Cohere Rerank API): 每次查询20个候选,$0.0001/次 1万次 × $0.0001 = $1/天 LLM生成(GPT-4o): 输入:5个chunks × 200 tokens + query 50 tokens = 1050 tokens 输出:200 tokens GPT-4o 价格:输入 $2.5/1M,输出 $10/1M 输入成本:1万次 × 1050 tokens / 1M × $2.5 = $26.25/天 输出成本:1万次 × 200 tokens / 1M × $10 = $20/天 LLM小计:$46.25/天 基础设施(Vector DB + 服务器): Qdrant Cloud(250GB):约$300/月 = $10/天 应用服务器(2核4G):约$50/月 = $1.7/天 每日总成本:$0.01 + $1 + $46.25 + $10 + $1.7 ≈ $59/天 ≈ $1800/月 成本优化选项: 1. 用GPT-4o-mini替代GPT-4o:LLM成本降低 90% → 每月节省 $1200 2. 缓存热门查询(LRU):命中率30%则节省30%的LLM成本 3. 自建Reranker模型:节省Cohere费用 4. 优化后成本:约 $200-400/月

第六部分:面试答题框架

2分钟Pitch版本(面试开场用)

"我会设计一个三层架构的RAG系统: 第一层是文档处理管道: 文档进来后,先解析(PDF、Markdown等), 然后按段落分成约900token的chunks, 带50token的重叠避免信息截断, 最后用text-embedding-3-small转成向量, 存入Qdrant向量数据库,元数据存PostgreSQL。 第二层是查询处理管道: 用户问题同时走向量检索(语义匹配)和关键词检索(BM25), 用RRF算法融合两个结果列表,取Top-20, 经过CrossEncoder Reranker精排,取Top-5 chunks, 组装成Prompt交给LLM生成答案。 第三层是质量保证: 维护一个eval dataset,每次改动后自动跑评测, 指标包括Precision@5、MRR、LLM-as-Judge评分。 这套方案在1000万文档规模下, 存储约250GB,P50延迟约1.2秒,每月成本约$200-1800。"

5分钟深入版本(面试追问用)

当面试官问"你怎么保证答案准确性":

"从两个维度: 检索准确性:用Precision@5和Recall@5量化, 通过hybrid search和reranker提升。 目前我们基线Precision@5约0.7, 改成hybrid后提升到了0.82。 生成准确性:用LLM-as-Judge(1-5分), prompt中明确要求只使用检索到的文档, 禁止使用外部知识,减少幻觉。 同时对每个答案附上来源chunk, 用户可以验证。"

当面试官问"如果文档更新了怎么处理":

"分两种情况: 局部更新(修改一篇文档): 删除该文档的所有chunk向量, 重新解析、分块、embedding, 写入向量数据库。 这是一个幂等操作,可以安全重试。 批量更新(大规模内容变更): 用消息队列(Kafka)接收变更事件, Worker消费消息,异步处理索引更新。 高峰期限流,避免压垮Embedding Service。 对于非常重要的文档(如政策文件), 还会标记version字段, 回答时显示文档版本,提示用户核实最新版本。"

当面试官问"如何处理多语言文档":

"三个层面: Embedding模型:选支持多语言的模型, 如text-embedding-3-large支持100+语言。 Chunking:对中文不能按英文空格分词, 需要用结巴分词或按句子分割。 LLM生成:prompt中指定回答语言, 或者做query language detection后决定回答语言。"

当面试官问"如何降低成本":

"三个策略: 缓存:热门查询用Redis缓存, TTL设24小时,预期命中率30-40%, LLM成本直接降低30%。 分级模型:简单问题用GPT-4o-mini, 复杂问题才用GPT-4o, 通过问题复杂度分类器路由, 整体成本降低50%。 批量处理:非实时性查询(如报告生成) 放入批处理队列,用Batch API(约50%折扣)。"

第七部分:完整Go架构骨架代码

// internal/rag/pipeline.go
package rag

import (
	"context"
	"fmt"
	"log/slog"
	"time"
)

// Config RAG Pipeline配置
type Config struct {
	VectorWeight  float64 // Hybrid search向量权重
	KeywordWeight float64 // Hybrid search关键词权重
	TopK          int     // 初始检索K
	RerankTopN    int     // Rerank后保留N个
	ChunkSize     int     // 分块大小(tokens)
	OverlapSize   int     // 重叠大小(tokens)
	CacheEnabled  bool    // 是否启用查询缓存
	CacheTTL      time.Duration
}

func DefaultConfig() Config {
	return Config{
		VectorWeight:  0.7,
		KeywordWeight: 0.3,
		TopK:          20,
		RerankTopN:    5,
		ChunkSize:     900,
		OverlapSize:   50,
		CacheEnabled:  true,
		CacheTTL:      24 * time.Hour,
	}
}

// Pipeline 完整RAG Pipeline
type Pipeline struct {
	config    Config
	embedder  Embedder
	vectorDB  VectorDB
	keywordDB KeywordDB
	reranker  Reranker
	llm       LLMGenerator
	cache     QueryCache
	logger    *slog.Logger
}

// QueryResult 查询结果
type QueryResult struct {
	Answer   string        `json:"answer"`
	Sources  []ChunkSource `json:"sources"`
	Chunks   []string      `json:"chunks"`
	Latency  LatencyBreakdown `json:"latency"`
	CacheHit bool          `json:"cache_hit"`
}

type ChunkSource struct {
	ChunkID  string `json:"chunk_id"`
	DocTitle string `json:"doc_title"`
	Score    float64 `json:"score"`
}

type LatencyBreakdown struct {
	EmbedMs    int64 `json:"embed_ms"`
	SearchMs   int64 `json:"search_ms"`
	RerankMs   int64 `json:"rerank_ms"`
	GenerateMs int64 `json:"generate_ms"`
	TotalMs    int64 `json:"total_ms"`
}

// Query 执行完整RAG查询
func (p *Pipeline) Query(ctx context.Context, query string) (*QueryResult, error) {
	start := time.Now()
	latency := LatencyBreakdown{}

	// Step 0: 查询缓存
	if p.config.CacheEnabled {
		if cached := p.cache.Get(query); cached != nil {
			cached.CacheHit = true
			return cached, nil
		}
	}

	// Step 1: Embed query
	embedStart := time.Now()
	queryVec, err := p.embedder.Embed(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("embed失败: %w", err)
	}
	latency.EmbedMs = time.Since(embedStart).Milliseconds()

	// Step 2: Hybrid Search
	searchStart := time.Now()
	vectorIDs, err := p.vectorDB.Search(ctx, queryVec, p.config.TopK)
	if err != nil {
		return nil, fmt.Errorf("向量检索失败: %w", err)
	}
	keywordIDs, err := p.keywordDB.Search(ctx, query, p.config.TopK)
	if err != nil {
		p.logger.Warn("关键词检索失败,降级到pure vector", "error", err)
		keywordIDs = vectorIDs // 降级
	}
	// RRF融合
	fusedIDs := RRF(vectorIDs, keywordIDs, p.config.TopK)
	latency.SearchMs = time.Since(searchStart).Milliseconds()

	// Step 3: 获取chunks文本
	chunks, err := p.vectorDB.GetChunks(ctx, fusedIDs)
	if err != nil {
		return nil, fmt.Errorf("获取chunks失败: %w", err)
	}

	// Step 4: Rerank
	rerankStart := time.Now()
	rankedChunks, err := p.reranker.Rerank(ctx, query, chunks, p.config.RerankTopN)
	if err != nil {
		p.logger.Warn("Rerank失败,使用原排序", "error", err)
		rankedChunks = chunks
		if len(rankedChunks) > p.config.RerankTopN {
			rankedChunks = rankedChunks[:p.config.RerankTopN]
		}
	}
	latency.RerankMs = time.Since(rerankStart).Milliseconds()

	// Step 5: 构建Prompt并生成答案
	genStart := time.Now()
	prompt := buildRAGPrompt(query, rankedChunks)
	answer, err := p.llm.Generate(ctx, prompt)
	if err != nil {
		return nil, fmt.Errorf("生成答案失败: %w", err)
	}
	latency.GenerateMs = time.Since(genStart).Milliseconds()
	latency.TotalMs = time.Since(start).Milliseconds()

	// Step 6: 构建结果
	result := &QueryResult{
		Answer:  answer,
		Chunks:  extractTexts(rankedChunks),
		Sources: extractSources(rankedChunks),
		Latency: latency,
	}

	// 写入缓存
	if p.config.CacheEnabled {
		p.cache.Set(query, result, p.config.CacheTTL)
	}

	p.logger.Info("query完成",
		"query", query,
		"total_ms", latency.TotalMs,
		"cache_hit", false,
		"chunks_used", len(rankedChunks),
	)

	return result, nil
}

func buildRAGPrompt(query string, chunks []Chunk) string {
	context := ""
	for i, c := range chunks {
		context += fmt.Sprintf("[文档%d - 来源:%s]\n%s\n\n", i+1, c.Source, c.Text)
	}

	return fmt.Sprintf(`你是一个企业知识库问答助手。

重要规则:
1. 只使用以下文档中的信息回答问题
2. 如果文档中没有相关信息,明确说"根据现有文档,无法回答此问题"
3. 不要使用任何文档之外的知识
4. 回答结构清晰,不超过300字
5. 在回答末尾注明使用的文档编号(如"参考:文档1、文档3")

参考文档:
%s

用户问题:%s

回答:`, context, query)
}

func extractTexts(chunks []Chunk) []string {
	texts := make([]string, len(chunks))
	for i, c := range chunks {
		texts[i] = c.Text
	}
	return texts
}

func extractSources(chunks []Chunk) []ChunkSource {
	sources := make([]ChunkSource, len(chunks))
	for i, c := range chunks {
		sources[i] = ChunkSource{
			ChunkID:  c.ID,
			DocTitle: c.Title,
		}
	}
	return sources
}

// Chunk 复用Day 8定义
type Chunk struct {
	ID     string
	Text   string
	Title  string
	Source string
}

// 接口定义
type Embedder interface {
	Embed(ctx context.Context, text string) ([]float32, error)
}

type VectorDB interface {
	Search(ctx context.Context, vec []float32, k int) ([]string, error)
	GetChunks(ctx context.Context, ids []string) ([]Chunk, error)
}

type KeywordDB interface {
	Search(ctx context.Context, query string, k int) ([]string, error)
}

type Reranker interface {
	Rerank(ctx context.Context, query string, chunks []Chunk, n int) ([]Chunk, error)
}

type LLMGenerator interface {
	Generate(ctx context.Context, prompt string) (string, error)
}

type QueryCache interface {
	Get(key string) *QueryResult
	Set(key string, val *QueryResult, ttl time.Duration)
}

第八部分:自测清单

  • 能在2分钟内完整讲出RAG系统架构吗?
  • 能说出chunk size大小的权衡点吗?
  • Hybrid search是什么?RRF是什么?
  • 1000万文档的向量存储大概需要多少GB?
  • 每天1万次查询,每月LLM成本大概是多少?
  • 当面试官问"准确性如何保证"时,你的答案结构是什么?
  • 文档更新时,索引如何同步?有哪两种策略?
  • Go接口在这个架构里起什么作用?(可替换性!)

第九部分:实战作业

任务1:画出你的系统架构图

  • 用Mermaid画出你的RAG系统完整架构
  • 标注每个组件的具体选型(用了哪个库/服务)
  • 标注数据流方向

任务2:完成规模估算

  • 假设你的场景是:100万文档,每天5000次查询
  • 估算:总存储量、P50延迟、每月成本
  • 写出计算过程(面试时展示计算思路比答案更重要)

任务3:补全Pipeline代码

  • 实现 QueryCache(用Day 12的LRU Cache)
  • 实现 MockReranker(按顺序返回前N个,不真正rerank)
  • 跑通完整的 Pipeline.Query 流程

任务4:2分钟Pitch练习

  • 对着镜子说出2分钟版本
  • 录音回放,检查是否超时、是否清晰
  • 重复直到流畅

第十部分:常见面试问题

Q: RAG和Fine-tuning什么时候用哪个?

A:

RAG适合: - 知识需要频繁更新(文档每周变) - 需要来源引用(用户要知道答案从哪来) - 知识库很大,fine-tuning成本太高 Fine-tuning适合: - 知识固定且大量(如法律条文全集) - 需要特定风格或格式输出 - 对延迟极敏感(不能每次都检索) 实际上两者常结合:先fine-tune基础能力,再用RAG补充实时知识。

Q: 向量数据库怎么选?

A:

Qdrant:开源、性能强、支持自部署,推荐用于生产 Pinecone:托管服务,运维成本低,按用量计费 Weaviate:支持内置keyword search,混合场景好用 pgvector:如果已经有PostgreSQL,简单场景够用 选择逻辑: - 预算有限 + 自运维能力强 → Qdrant - 不想管基础设施 → Pinecone - 已有PG且规模不大 → pgvector

Q: 如何处理用户问了文档里没有答案的问题?

A:

三层防护: 1. 检索层:如果所有chunks的相似度都低于阈值(如0.7), 直接返回"无法在知识库中找到相关信息",不调LLM 2. 生成层:Prompt中要求"无相关信息时明确说无法回答", 不要凭空编造 3. 事后监控:记录LLM回答中包含"无法回答"的比例, 如果很高,说明知识库需要补充内容

Q: 系统如何处理私密文档的权限控制?

A:

两层控制: 1. 查询时的元数据过滤: vectorDB.Search(ctx, vec, k, Filter{Department: user.Department}) Qdrant支持在向量搜索时加filter,只搜索用户有权限的chunks 2. Chunk存储时打标: 每个chunk的metadata包含 allowed_roles 字段 检索后在应用层二次过滤 这样既保证了安全性,又不影响向量检索性能。

第十一部分:Week 3预告

恭喜完成Week 2!你现在拥有了:

  • 完整的RAG Pipeline(文档处理、向量检索、Hybrid Search、Rerank、生成)
  • 量化评测系统(Eval Dataset + Eval Runner)
  • 系统性错误分析能力
  • 面试级别的系统设计能力

Week 3将深入:

  1. Day 15: Tool Calling - 让RAG不只是问答,而是能执行操作
  2. Day 16: Agent Loop - ReAct框架,自主推理+行动
  3. Day 17: Memory - 短期记忆(对话历史)+ 长期记忆(用户偏好)
  4. Day 18: Multi-Agent - 多个专家Agent协作完成复杂任务
  5. Day 19: Auth & Multi-tenancy - 生产级权限控制
  6. Day 20: Observability - 链路追踪、指标监控、告警

Week 3核心能力: 从RAG系统到完整的AI Agent,能执行操作、记忆上下文、多智能体协作。

准备问题:

  • Tool Calling和函数调用是什么关系?
  • Agent loop里的ReAct是什么意思?(Reasoning + Acting)
  • 如果Agent调用了外部API失败,应该怎么处理?

Week 2总结:你已经能构建、评测、优化、并设计一个生产级RAG系统。