Week 2 Day 12:Eval Dataset - 苏格拉底教学

没有eval,优化就是瞎猜。今天你会建立量化RAG系统质量的完整框架,从数据生成到自动化评测,让每次改进都有数据支撑。


第一部分:问题驱动

🤔 问题1:你怎么知道你的RAG系统变好了还是变差了?

引导问题:

  1. 你换了一个新的chunk大小,答案质量如何变化?你能量化吗?
  2. 你的老板问"上周改的embedding模型有没有提升效果",你怎么回答?
  3. 如果没有eval,你改了代码能放心上线吗?

答案揭示:

  • 没有eval,所有"感觉更好了"都是主观臆断
  • Eval是RAG系统的单元测试,是质量的护城河
  • 没有eval就上线 = 在生产环境里做实验,风险极高

你应该理解:

  • Eval dataset是一组固定的问答对(ground truth)
  • 每次改动后跑eval,对比分数变化
  • 分数提升 = 有理由相信改进有效

🤔 问题2:一个好的eval dataset长什么样?

引导问题:

  1. 你的问题集需要覆盖哪些类别?
  2. Ground truth从哪里来?人工标注还是自动生成?
  3. 如果query有歧义,expected_answer应该是什么?

答案揭示:

  • 需要覆盖各种类型:事实类、操作类、比较类、边缘情况
  • Ground truth可以混合来源:人工标注 + LLM辅助生成 + 用户历史反馈
  • Eval set应该冻结,不能因为系统表现差就删掉难题

🤔 问题3:评测什么?怎样评测?

引导问题:

  1. 检索出了5个chunks,相关的有3个,这个"准确率"是多少?
  2. 应该返回的chunk排在第几位?排名越靠前越好?
  3. 最终生成的答案对不对,谁来判断?

答案揭示:

  • Precision@K:检索的K个结果中有多少是相关的
  • Recall@K:所有相关结果中有多少被检索出来了
  • MRR(Mean Reciprocal Rank):第一个相关结果的排名
  • NDCG:考虑排名权重的综合指标
  • LLM-as-Judge:用LLM来打分生成答案的质量(1-5分)

第二部分:关键概念

评估指标详解

Precision@K:

检索出K个结果,其中有多少是相关的? 例子:K=5,检索出 [相关, 不相关, 相关, 相关, 不相关] Precision@5 = 3/5 = 0.6

Recall@K:

所有应该被找到的结果中,有多少被找到了? 例子:ground truth有4个相关文档,K=5只找到了3个 Recall@5 = 3/4 = 0.75

MRR(Mean Reciprocal Rank):

第一个相关结果在第几位?取倒数,然后平均。 例子: 查询1:第一个相关结果在第2位 → 1/2 查询2:第一个相关结果在第1位 → 1/1 查询3:第一个相关结果在第3位 → 1/3 MRR = (1/2 + 1/1 + 1/3) / 3 = 0.611

NDCG(Normalized Discounted Cumulative Gain):

排名越靠前的相关结果,贡献越大。 DCG@K = sum(rel_i / log2(i+1)) for i in 1..K NDCG@K = DCG@K / IDCG@K (IDCG是理想排序的DCG)

LLM-as-Judge:

给LLM看:用户问题 + 检索到的chunks + 生成的答案 让LLM打1-5分并给出理由 1分:完全错误或无法回答 2分:部分相关但主要内容缺失 3分:基本正确但不完整 4分:正确且较完整 5分:完全正确、完整、清晰

Eval Dataset格式:JSONL

每一行是一个eval case:

{"query": "如何重置SSO密码?", "expected_answer": "访问SSO门户,点击忘记密码,输入邮箱,按邮件中的链接操作", "expected_sources": ["faq_001_chunk_0", "faq_001_chunk_1"], "category": "操作类", "difficulty": "easy"}
{"query": "审批流程是什么?", "expected_answer": "所有工单创建需要L2及以上审批,流程:草稿→人工审核→批准或拒绝", "expected_sources": ["faq_001_chunk_2"], "category": "流程类", "difficulty": "medium"}
{"query": "SSO和LDAP有什么区别?", "expected_answer": "SSO是单点登录协议,LDAP是目录服务协议,两者常配合使用但功能不同", "expected_sources": ["faq_002_chunk_0", "faq_003_chunk_1"], "category": "比较类", "difficulty": "hard"}

字段说明:

  • query:用户的实际问题
  • expected_answer:理想的参考答案
  • expected_sources:应该被检索到的chunk ID列表
  • category:问题类别,用于分类分析
  • difficulty:难度,用于识别系统在哪类问题上弱

第三部分:动手实现

第一步:自动生成Eval Set

核心思路: 用LLM从你已有的文档中生成问答对

// evals/generate_dataset.go
package main

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"time"
)

// EvalCase 单个评测案例
type EvalCase struct {
	Query           string   `json:"query"`
	ExpectedAnswer  string   `json:"expected_answer"`
	ExpectedSources []string `json:"expected_sources"`
	Category        string   `json:"category"`
	Difficulty      string   `json:"difficulty"`
	GeneratedAt     string   `json:"generated_at"`
}

// Chunk 从Day 8复用的结构
type Chunk struct {
	ID     string `json:"id"`
	DocID  string `json:"doc_id"`
	Title  string `json:"title"`
	Text   string `json:"text"`
	Source string `json:"source"`
}

// QAGeneratePrompt 生成问答对的prompt
func buildQAGeneratePrompt(chunk Chunk) string {
	return fmt.Sprintf(`你是一个QA数据集生成专家。
根据以下文档片段,生成2-3个高质量的问答对。

文档标题:%s
文档内容:
%s

要求:
1. 问题应该模拟真实用户的提问方式
2. 答案必须完全基于文档内容,不要凭空捏造
3. 包含不同难度:easy(直接查找)、medium(需要理解)、hard(需要推理)
4. 返回JSON数组格式:
[
  {
    "query": "问题",
    "expected_answer": "答案",
    "category": "类别(操作类/流程类/概念类/比较类)",
    "difficulty": "难度(easy/medium/hard)"
  }
]
只返回JSON,不要其他内容。`, chunk.Title, chunk.Text)
}

// GenerateEvalCases 从chunks生成eval案例
func GenerateEvalCases(chunks []Chunk, llmClient LLMClient) ([]EvalCase, error) {
	var allCases []EvalCase

	for _, chunk := range chunks {
		prompt := buildQAGeneratePrompt(chunk)

		// 调用LLM生成
		resp, err := llmClient.Complete(context.Background(), prompt)
		if err != nil {
			fmt.Printf("跳过chunk %s: %v\n", chunk.ID, err)
			continue
		}

		// 解析JSON
		var rawCases []struct {
			Query          string `json:"query"`
			ExpectedAnswer string `json:"expected_answer"`
			Category       string `json:"category"`
			Difficulty     string `json:"difficulty"`
		}

		if err := json.Unmarshal([]byte(resp), &rawCases); err != nil {
			fmt.Printf("解析失败 chunk %s: %v\n", chunk.ID, err)
			continue
		}

		// 补充source信息
		for _, rc := range rawCases {
			evalCase := EvalCase{
				Query:           rc.Query,
				ExpectedAnswer:  rc.ExpectedAnswer,
				ExpectedSources: []string{chunk.ID},
				Category:        rc.Category,
				Difficulty:      rc.Difficulty,
				GeneratedAt:     time.Now().Format(time.RFC3339),
			}
			allCases = append(allCases, evalCase)
		}

		// 避免rate limit
		time.Sleep(500 * time.Millisecond)
	}

	return allCases, nil
}

// WriteDataset 写入JSONL文件
func WriteDataset(cases []EvalCase, outputPath string) error {
	file, err := os.Create(outputPath)
	if err != nil {
		return fmt.Errorf("创建文件失败: %w", err)
	}
	defer file.Close()

	encoder := json.NewEncoder(file)
	for _, c := range cases {
		if err := encoder.Encode(c); err != nil {
			return fmt.Errorf("编码失败: %w", err)
		}
	}

	fmt.Printf("写入 %d 个eval cases 到 %s\n", len(cases), outputPath)
	return nil
}

// LoadDataset 读取JSONL文件
func LoadDataset(path string) ([]EvalCase, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	var cases []EvalCase
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		var c EvalCase
		if err := json.Unmarshal([]byte(line), &c); err != nil {
			return nil, fmt.Errorf("解析行失败: %w", err)
		}
		cases = append(cases, c)
	}

	return cases, scanner.Err()
}

第二步:完整Eval Runner

// evals/runner.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"math"
	"os"
	"time"
)

// EvalResult 单次评测结果
type EvalResult struct {
	Query           string    `json:"query"`
	Category        string    `json:"category"`
	Difficulty      string    `json:"difficulty"`
	RetrievedIDs    []string  `json:"retrieved_ids"`
	ExpectedIDs     []string  `json:"expected_ids"`
	GeneratedAnswer string    `json:"generated_answer"`
	ExpectedAnswer  string    `json:"expected_answer"`
	PrecisionAt5    float64   `json:"precision_at_5"`
	RecallAt5       float64   `json:"recall_at_5"`
	MRR             float64   `json:"mrr"`
	NDCG            float64   `json:"ndcg"`
	LLMScore        float64   `json:"llm_score"`       // 1-5分
	LLMReason       string    `json:"llm_reason"`
	LatencyMs       int64     `json:"latency_ms"`
	Timestamp       string    `json:"timestamp"`
}

// EvalSummary 汇总统计
type EvalSummary struct {
	TotalCases       int                        `json:"total_cases"`
	AvgPrecisionAt5  float64                    `json:"avg_precision_at_5"`
	AvgRecallAt5     float64                    `json:"avg_recall_at_5"`
	AvgMRR           float64                    `json:"avg_mrr"`
	AvgNDCG          float64                    `json:"avg_ndcg"`
	AvgLLMScore      float64                    `json:"avg_llm_score"`
	AvgLatencyMs     float64                    `json:"avg_latency_ms"`
	ByCategory       map[string]CategoryMetrics `json:"by_category"`
	ByDifficulty     map[string]CategoryMetrics `json:"by_difficulty"`
}

type CategoryMetrics struct {
	Count       int     `json:"count"`
	AvgPrecision float64 `json:"avg_precision"`
	AvgRecall   float64 `json:"avg_recall"`
	AvgLLMScore float64 `json:"avg_llm_score"`
}

// RAGPipeline 你的RAG pipeline接口
type RAGPipeline interface {
	Retrieve(ctx context.Context, query string, k int) ([]string, error) // 返回chunk IDs
	Generate(ctx context.Context, query string, chunkIDs []string) (string, error)
}

// LLMClient LLM调用接口
type LLMClient interface {
	Complete(ctx context.Context, prompt string) (string, error)
}

// EvalRunner 评测框架主体
type EvalRunner struct {
	pipeline  RAGPipeline
	llmClient LLMClient
	k         int // 检索Top-K
}

func NewEvalRunner(pipeline RAGPipeline, llmClient LLMClient, k int) *EvalRunner {
	return &EvalRunner{
		pipeline:  pipeline,
		llmClient: llmClient,
		k:         k,
	}
}

// RunSingle 对单个case评测
func (r *EvalRunner) RunSingle(ctx context.Context, ec EvalCase) EvalResult {
	start := time.Now()
	result := EvalResult{
		Query:       ec.Query,
		Category:    ec.Category,
		Difficulty:  ec.Difficulty,
		ExpectedIDs: ec.ExpectedSources,
		ExpectedAnswer: ec.ExpectedAnswer,
		Timestamp:   start.Format(time.RFC3339),
	}

	// Step 1: 检索
	retrievedIDs, err := r.pipeline.Retrieve(ctx, ec.Query, r.k)
	if err != nil {
		fmt.Printf("检索失败 [%s]: %v\n", ec.Query, err)
		return result
	}
	result.RetrievedIDs = retrievedIDs

	// Step 2: 计算检索指标
	result.PrecisionAt5 = calcPrecisionAtK(retrievedIDs, ec.ExpectedSources, r.k)
	result.RecallAt5 = calcRecallAtK(retrievedIDs, ec.ExpectedSources, r.k)
	result.MRR = calcMRR(retrievedIDs, ec.ExpectedSources)
	result.NDCG = calcNDCG(retrievedIDs, ec.ExpectedSources, r.k)

	// Step 3: 生成答案
	generatedAnswer, err := r.pipeline.Generate(ctx, ec.Query, retrievedIDs)
	if err != nil {
		fmt.Printf("生成失败 [%s]: %v\n", ec.Query, err)
	}
	result.GeneratedAnswer = generatedAnswer

	// Step 4: LLM评分
	score, reason := r.llmJudge(ctx, ec.Query, generatedAnswer, ec.ExpectedAnswer)
	result.LLMScore = score
	result.LLMReason = reason

	result.LatencyMs = time.Since(start).Milliseconds()
	return result
}

// Run 完整评测
func (r *EvalRunner) Run(ctx context.Context, dataset []EvalCase) (*EvalSummary, []EvalResult, error) {
	var results []EvalResult

	for i, ec := range dataset {
		fmt.Printf("评测 [%d/%d]: %s\n", i+1, len(dataset), ec.Query)
		result := r.RunSingle(ctx, ec)
		results = append(results, result)
	}

	summary := r.summarize(results)
	return summary, results, nil
}

// llmJudge 用LLM评分答案质量
func (r *EvalRunner) llmJudge(ctx context.Context, query, generated, expected string) (float64, string) {
	prompt := fmt.Sprintf(`你是一个严格的QA评测专家。
用户问题:%s
参考答案:%s
系统生成的答案:%s

请评分(1-5分)并说明理由:
1分:完全错误或无关
2分:有部分相关内容,但主要信息缺失
3分:基本正确,但不完整或有轻微偏差
4分:正确且较完整
5分:完全正确、完整、清晰

只返回JSON格式:{"score": 数字, "reason": "简短理由"}`, query, expected, generated)

	resp, err := r.llmClient.Complete(ctx, prompt)
	if err != nil {
		return 0, "LLM评分失败"
	}

	var judgeResult struct {
		Score  float64 `json:"score"`
		Reason string  `json:"reason"`
	}
	if err := json.Unmarshal([]byte(resp), &judgeResult); err != nil {
		return 0, "解析评分失败"
	}

	return judgeResult.Score, judgeResult.Reason
}

// summarize 汇总统计
func (r *EvalRunner) summarize(results []EvalResult) *EvalSummary {
	summary := &EvalSummary{
		TotalCases:   len(results),
		ByCategory:   make(map[string]CategoryMetrics),
		ByDifficulty: make(map[string]CategoryMetrics),
	}

	var totalPrec, totalRecall, totalMRR, totalNDCG, totalScore, totalLatency float64

	catMap := make(map[string][]EvalResult)
	diffMap := make(map[string][]EvalResult)

	for _, r := range results {
		totalPrec += r.PrecisionAt5
		totalRecall += r.RecallAt5
		totalMRR += r.MRR
		totalNDCG += r.NDCG
		totalScore += r.LLMScore
		totalLatency += float64(r.LatencyMs)

		catMap[r.Category] = append(catMap[r.Category], r)
		diffMap[r.Difficulty] = append(diffMap[r.Difficulty], r)
	}

	n := float64(len(results))
	summary.AvgPrecisionAt5 = totalPrec / n
	summary.AvgRecallAt5 = totalRecall / n
	summary.AvgMRR = totalMRR / n
	summary.AvgNDCG = totalNDCG / n
	summary.AvgLLMScore = totalScore / n
	summary.AvgLatencyMs = totalLatency / n

	// 按类别汇总
	for cat, rs := range catMap {
		var p, rec, s float64
		for _, r := range rs {
			p += r.PrecisionAt5
			rec += r.RecallAt5
			s += r.LLMScore
		}
		cnt := float64(len(rs))
		summary.ByCategory[cat] = CategoryMetrics{
			Count:        len(rs),
			AvgPrecision: p / cnt,
			AvgRecall:    rec / cnt,
			AvgLLMScore:  s / cnt,
		}
	}

	// 按难度汇总
	for diff, rs := range diffMap {
		var p, rec, s float64
		for _, r := range rs {
			p += r.PrecisionAt5
			rec += r.RecallAt5
			s += r.LLMScore
		}
		cnt := float64(len(rs))
		summary.ByDifficulty[diff] = CategoryMetrics{
			Count:        len(rs),
			AvgPrecision: p / cnt,
			AvgRecall:    rec / cnt,
			AvgLLMScore:  s / cnt,
		}
	}

	return summary
}

第三步:指标计算实现

// evals/metrics.go
package main

import "math"

// setContains 判断元素是否在集合中
func setContains(set []string, item string) bool {
	for _, s := range set {
		if s == item {
			return true
		}
	}
	return false
}

// calcPrecisionAtK Precision@K
func calcPrecisionAtK(retrieved, expected []string, k int) float64 {
	if len(retrieved) == 0 || k == 0 {
		return 0
	}
	top := retrieved
	if len(top) > k {
		top = retrieved[:k]
	}

	relevant := 0
	for _, id := range top {
		if setContains(expected, id) {
			relevant++
		}
	}
	return float64(relevant) / float64(len(top))
}

// calcRecallAtK Recall@K
func calcRecallAtK(retrieved, expected []string, k int) float64 {
	if len(expected) == 0 {
		return 1.0 // 没有期望结果,算作完美
	}
	top := retrieved
	if len(top) > k {
		top = retrieved[:k]
	}

	found := 0
	for _, exp := range expected {
		if setContains(top, exp) {
			found++
		}
	}
	return float64(found) / float64(len(expected))
}

// calcMRR Mean Reciprocal Rank
func calcMRR(retrieved, expected []string) float64 {
	for i, id := range retrieved {
		if setContains(expected, id) {
			return 1.0 / float64(i+1)
		}
	}
	return 0
}

// calcNDCG Normalized Discounted Cumulative Gain
func calcNDCG(retrieved, expected []string, k int) float64 {
	top := retrieved
	if len(top) > k {
		top = retrieved[:k]
	}

	// DCG
	dcg := 0.0
	for i, id := range top {
		if setContains(expected, id) {
			dcg += 1.0 / math.Log2(float64(i+2)) // i+2 因为log2(1)=0
		}
	}

	// IDCG(理想情况:所有相关结果排在最前面)
	idcg := 0.0
	idealCount := len(expected)
	if idealCount > k {
		idealCount = k
	}
	for i := 0; i < idealCount; i++ {
		idcg += 1.0 / math.Log2(float64(i+2))
	}

	if idcg == 0 {
		return 0
	}
	return dcg / idcg
}

第四步:主程序入口

// evals/main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
)

func main() {
	// 1. 加载eval dataset
	dataset, err := LoadDataset("evals/dataset.jsonl")
	if err != nil {
		fmt.Fprintf(os.Stderr, "加载dataset失败: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("加载了 %d 个eval cases\n", len(dataset))

	// 2. 初始化pipeline和LLM客户端
	// (实际使用时替换为你的实现)
	pipeline := NewMockRAGPipeline()
	llmClient := NewMockLLMClient()

	// 3. 创建runner
	runner := NewEvalRunner(pipeline, llmClient, 5) // Top-5

	// 4. 运行评测
	ctx := context.Background()
	summary, results, err := runner.Run(ctx, dataset)
	if err != nil {
		fmt.Fprintf(os.Stderr, "评测失败: %v\n", err)
		os.Exit(1)
	}

	// 5. 打印汇总
	printSummary(summary)

	// 6. 保存详细结果
	if err := saveResults(results, "evals/results.jsonl"); err != nil {
		fmt.Fprintf(os.Stderr, "保存结果失败: %v\n", err)
	}

	// 7. 保存汇总
	if err := saveSummary(summary, "evals/summary.json"); err != nil {
		fmt.Fprintf(os.Stderr, "保存汇总失败: %v\n", err)
	}

	fmt.Println("\n评测完成!查看 evals/summary.json 了解详情")
}

func printSummary(s *EvalSummary) {
	fmt.Println("\n========== 评测结果汇总 ==========")
	fmt.Printf("总案例数:%d\n", s.TotalCases)
	fmt.Printf("Precision@5:%.3f\n", s.AvgPrecisionAt5)
	fmt.Printf("Recall@5:   %.3f\n", s.AvgRecallAt5)
	fmt.Printf("MRR:        %.3f\n", s.AvgMRR)
	fmt.Printf("NDCG:       %.3f\n", s.AvgNDCG)
	fmt.Printf("LLM评分:    %.2f / 5.0\n", s.AvgLLMScore)
	fmt.Printf("平均延迟:   %.0f ms\n", s.AvgLatencyMs)

	fmt.Println("\n---------- 按类别 ----------")
	for cat, m := range s.ByCategory {
		fmt.Printf("  %s(%d个): P=%.3f R=%.3f Score=%.2f\n",
			cat, m.Count, m.AvgPrecision, m.AvgRecall, m.AvgLLMScore)
	}

	fmt.Println("\n---------- 按难度 ----------")
	for diff, m := range s.ByDifficulty {
		fmt.Printf("  %s(%d个): P=%.3f R=%.3f Score=%.2f\n",
			diff, m.Count, m.AvgPrecision, m.AvgRecall, m.AvgLLMScore)
	}
}

func saveResults(results []EvalResult, path string) error {
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()
	enc := json.NewEncoder(file)
	for _, r := range results {
		enc.Encode(r)
	}
	return nil
}

func saveSummary(summary *EvalSummary, path string) error {
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()
	enc := json.NewEncoder(file)
	enc.SetIndent("", "  ")
	return enc.Encode(summary)
}

// MockRAGPipeline 用于测试的mock实现
type MockRAGPipeline struct{}

func NewMockRAGPipeline() *MockRAGPipeline { return &MockRAGPipeline{} }

func (m *MockRAGPipeline) Retrieve(_ context.Context, query string, k int) ([]string, error) {
	// 实际项目里替换为真实的向量检索
	return []string{"faq_001_chunk_0", "faq_001_chunk_1", "faq_002_chunk_0"}, nil
}

func (m *MockRAGPipeline) Generate(_ context.Context, query string, chunkIDs []string) (string, error) {
	return fmt.Sprintf("根据文档,关于'%s'的回答是:[mock答案]", query), nil
}

// MockLLMClient 用于测试的mock实现
type MockLLMClient struct{}

func NewMockLLMClient() *MockLLMClient { return &MockLLMClient{} }

func (m *MockLLMClient) Complete(_ context.Context, prompt string) (string, error) {
	return `{"score": 3, "reason": "mock评分"}`, nil
}

第四部分:算法题

LRU Cache(高频面试题)

面试场景: 你的eval runner需要缓存LLM评分结果,避免重复调用

问题: 实现一个支持 GetPut 的LRU Cache,时间复杂度O(1)

引导思考:

  1. LRU需要记住"最近使用",用什么数据结构?
  2. O(1)查找 → 用map;O(1)维护顺序 → 用双向链表
  3. 两者结合:map存key→node,链表维护使用顺序
// 面试实现:LRU Cache
package main

import "container/list"

type LRUCache struct {
	cap   int
	cache map[int]*list.Element
	list  *list.List
}

type entry struct {
	key, val int
}

func NewLRUCache(cap int) *LRUCache {
	return &LRUCache{
		cap:   cap,
		cache: make(map[int]*list.Element),
		list:  list.New(),
	}
}

func (c *LRUCache) Get(key int) int {
	if elem, ok := c.cache[key]; ok {
		c.list.MoveToFront(elem)
		return elem.Value.(*entry).val
	}
	return -1
}

func (c *LRUCache) Put(key, val int) {
	if elem, ok := c.cache[key]; ok {
		// 已存在:更新值并移到头部
		elem.Value.(*entry).val = val
		c.list.MoveToFront(elem)
		return
	}

	// 新元素
	e := &entry{key, val}
	elem := c.list.PushFront(e)
	c.cache[key] = elem

	// 超容量:删除最久未使用的(链表尾部)
	if c.list.Len() > c.cap {
		tail := c.list.Back()
		if tail != nil {
			c.list.Remove(tail)
			delete(c.cache, tail.Value.(*entry).key)
		}
	}
}

复杂度: Get O(1),Put O(1),空间O(capacity)


Implement Trie(前缀树)

面试场景: 你的eval系统需要快速查找query前缀,辅助测试用例管理

问题: 实现Trie,支持 InsertSearchStartsWith

type TrieNode struct {
	children [26]*TrieNode
	isEnd    bool
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{}}
}

func (t *Trie) Insert(word string) {
	cur := t.root
	for _, ch := range word {
		idx := ch - 'a'
		if cur.children[idx] == nil {
			cur.children[idx] = &TrieNode{}
		}
		cur = cur.children[idx]
	}
	cur.isEnd = true
}

func (t *Trie) Search(word string) bool {
	cur := t.root
	for _, ch := range word {
		idx := ch - 'a'
		if cur.children[idx] == nil {
			return false
		}
		cur = cur.children[idx]
	}
	return cur.isEnd
}

func (t *Trie) StartsWith(prefix string) bool {
	cur := t.root
	for _, ch := range prefix {
		idx := ch - 'a'
		if cur.children[idx] == nil {
			return false
		}
		cur = cur.children[idx]
	}
	return true
}

Design HashMap

面试场景: 理解哈希表内部结构,面试常考

问题: 不用Go内置map,实现一个支持Put/Get/Remove的HashMap

type MyHashMap struct {
	buckets [][]pair
	size    int
}

type pair struct{ key, val int }

func NewMyHashMap() *MyHashMap {
	size := 1024
	return &MyHashMap{
		buckets: make([][]pair, size),
		size:    size,
	}
}

func (m *MyHashMap) hash(key int) int {
	return key % m.size
}

func (m *MyHashMap) Put(key, val int) {
	h := m.hash(key)
	for i, p := range m.buckets[h] {
		if p.key == key {
			m.buckets[h][i].val = val
			return
		}
	}
	m.buckets[h] = append(m.buckets[h], pair{key, val})
}

func (m *MyHashMap) Get(key int) int {
	h := m.hash(key)
	for _, p := range m.buckets[h] {
		if p.key == key {
			return p.val
		}
	}
	return -1
}

func (m *MyHashMap) Remove(key int) {
	h := m.hash(key)
	for i, p := range m.buckets[h] {
		if p.key == key {
			m.buckets[h] = append(m.buckets[h][:i], m.buckets[h][i+1:]...)
			return
		}
	}
}

第五部分:自测清单

在进入Day 13之前,确认自己能回答:

  • Precision@5和Recall@5分别衡量什么,有什么区别?
  • MRR为什么只看第一个相关结果?
  • NDCG相比Precision的优势是什么?
  • LLM-as-Judge的prompt应该包含哪几个部分?
  • dataset.jsonl里每行包含哪些字段?
  • 为什么eval set一旦生成就不应该随意修改?
  • LRU Cache为什么要用map+双向链表?只用map能实现吗?

第六部分:实战作业

任务1:生成你的Eval Dataset

  • 从Day 8/9的chunks中生成至少30个eval cases
  • 覆盖至少3种category(操作类、流程类、概念类)
  • 每种difficulty各至少5个(easy/medium/hard)
  • 保存到 evals/dataset.jsonl

任务2:接入真实Pipeline

  • 把Day 11的RAG pipeline接入eval runner
  • 替换MockRAGPipeline为真实实现
  • 运行完整评测,得到第一份基线数据

任务3:分析基线数据

  • 哪个category得分最低?
  • easy/medium/hard的差距有多大?
  • 延迟是否在可接受范围(<3秒)?
  • 记录基线:Precision@5=?, Recall@5=?, MRR=?, LLMScore=?

任务4:尝试提升

  • 改变一个参数(比如chunk_size或Top-K)
  • 重新跑eval,对比数字变化
  • 写下:改了什么 → 结果如何 → 结论是什么

第七部分:常见问题

Q: eval dataset应该有多少条才够?

A: 经验值:

  • 快速验证:30-50条(每次改动后快速检查)
  • 完整评测:100-200条(覆盖各种场景)
  • 生产级:500+条(加入真实用户反馈)

从30条开始,随着系统成熟再扩充。

Q: LLM-as-Judge会不会因为LLM版本不同而不一致?

A: 会!解决方案:

  • 固定Judge模型版本(比如 gpt-4o-2024-08-06)
  • 固定prompt,不随意改动
  • 在每次eval时记录使用的Judge模型版本
  • 做完全客观的比较时,同一批eval用同一个Judge

Q: 如果expected_sources里的chunk被重新chunking后ID变了怎么办?

A: 两种策略:

  1. 用内容哈希作为ID(内容不变则ID不变)
  2. 重新生成eval dataset(破坏性改动时不得不为之)

建议在早期就用内容哈希做chunk ID。

Q: 评测跑得太慢(LLM调用多)怎么优化?

A:

// 用goroutine并发评测,但注意rate limit
sem := make(chan struct{}, 5) // 最多5个并发
var wg sync.WaitGroup
for _, ec := range dataset {
    wg.Add(1)
    sem <- struct{}{}
    go func(ec EvalCase) {
        defer wg.Done()
        defer func() { <-sem }()
        result := runner.RunSingle(ctx, ec)
        // 存结果...
    }(ec)
}
wg.Wait()


配套算法题

题1:LRU Cache (Medium)

题目: 设计一个满足 LRU(最近最少使用)缓存约束的数据结构。实现 LRUCache(capacity)Get(key)(O(1))和 Put(key, value)(O(1))。容量满时淘汰最久未使用的键。

思路: 双向链表 + 哈希表。

  • 哈希表:key → 链表节点,实现 O(1) 查找
  • 双向链表:维护使用顺序(头部=最近使用,尾部=最久未使用)
  • Get 时将节点移到头部;Put 时若已存在则更新并移头,若超容量则删除尾部节点
import "container/list"

type LRUCache struct {
    cap   int
    cache map[int]*list.Element
    list  *list.List
}

type entry struct {
    key, val int
}

func Constructor(capacity int) LRUCache {
    return LRUCache{
        cap:   capacity,
        cache: make(map[int]*list.Element),
        list:  list.New(),
    }
}

func (c *LRUCache) Get(key int) int {
    if elem, ok := c.cache[key]; ok {
        c.list.MoveToFront(elem)
        return elem.Value.(*entry).val
    }
    return -1
}

func (c *LRUCache) Put(key, value int) {
    if elem, ok := c.cache[key]; ok {
        // 已存在:更新值并移到头部
        elem.Value.(*entry).val = value
        c.list.MoveToFront(elem)
        return
    }
    // 新元素:插入头部
    e := &entry{key, value}
    elem := c.list.PushFront(e)
    c.cache[key] = elem
    // 超出容量:淘汰尾部
    if c.list.Len() > c.cap {
        tail := c.list.Back()
        if tail != nil {
            c.list.Remove(tail)
            delete(c.cache, tail.Value.(*entry).key)
        }
    }
}

复杂度: Get O(1),Put O(1),空间 O(capacity)

面试关键点: 必须说清"为什么需要双向链表而不是单向链表"——删除尾部节点时需要拿到尾部前驱,单向链表做不到 O(1)。


题2:Implement Trie (Prefix Tree) (Medium)

题目: 实现一个前缀树,支持 Insert(word)Search(word)(精确匹配)和 StartsWith(prefix)(前缀匹配)三个操作。

思路: 每个 TrieNode 有26个子节点指针(对应小写字母 a-z)和一个 isEnd 标记表示是否为单词结尾。Insert 逐字符创建节点;Search 遍历到末尾后检查 isEnd;StartsWith 遍历到末尾即可(不检查 isEnd)。

type TrieNode struct {
    children [26]*TrieNode
    isEnd    bool
}

type Trie struct {
    root *TrieNode
}

func NewTrie() Trie {
    return Trie{root: &TrieNode{}}
}

func (t *Trie) Insert(word string) {
    cur := t.root
    for _, ch := range word {
        idx := ch - 'a'
        if cur.children[idx] == nil {
            cur.children[idx] = &TrieNode{}
        }
        cur = cur.children[idx]
    }
    cur.isEnd = true
}

func (t *Trie) Search(word string) bool {
    cur := t.root
    for _, ch := range word {
        idx := ch - 'a'
        if cur.children[idx] == nil {
            return false
        }
        cur = cur.children[idx]
    }
    return cur.isEnd // 必须是完整单词
}

func (t *Trie) StartsWith(prefix string) bool {
    cur := t.root
    for _, ch := range prefix {
        idx := ch - 'a'
        if cur.children[idx] == nil {
            return false
        }
        cur = cur.children[idx]
    }
    return true // 前缀存在即可,不要求 isEnd
}

复杂度: Insert/Search/StartsWith 均为 O(m)(m 为字符串长度),空间 O(总字符数 × 26)

Agent 上下文联想: Trie 是自动补全、命令前缀匹配的核心数据结构,在 Agent 的 tool registry 快速按名称前缀查找工具时可以派上用场。


题3:Design HashMap (Easy)

题目: 不使用任何内置哈希表库,实现 MyHashMap,支持 Put(key, value)Get(key)(不存在返回-1)和 Remove(key) 操作。

思路: 拉链法(Separate Chaining)。预分配固定数量的桶(bucket),每个桶是一个 []pair 切片。哈希函数:key % bucketSize。碰撞时在同一桶的切片中线性查找(链式处理)。

type MyHashMap struct {
    buckets [][]pair
    size    int
}

type pair struct {
    key, val int
}

func NewMyHashMap() MyHashMap {
    size := 1024
    return MyHashMap{
        buckets: make([][]pair, size),
        size:    size,
    }
}

func (m *MyHashMap) hash(key int) int {
    return key % m.size
}

func (m *MyHashMap) Put(key, value int) {
    h := m.hash(key)
    for i, p := range m.buckets[h] {
        if p.key == key {
            m.buckets[h][i].val = value // 已存在,更新值
            return
        }
    }
    m.buckets[h] = append(m.buckets[h], pair{key, value})
}

func (m *MyHashMap) Get(key int) int {
    h := m.hash(key)
    for _, p := range m.buckets[h] {
        if p.key == key {
            return p.val
        }
    }
    return -1
}

func (m *MyHashMap) Remove(key int) {
    h := m.hash(key)
    for i, p := range m.buckets[h] {
        if p.key == key {
            // 删除第 i 个元素(顺序无关紧要)
            m.buckets[h] = append(m.buckets[h][:i], m.buckets[h][i+1:]...)
            return
        }
    }
}

复杂度: 平均 O(1)(桶内元素少),最坏 O(n/bucketSize)。空间 O(bucketSize + n)

面试延伸: 可以追问"如何动态扩容(rehash)"——当 load factor 超过阈值(如0.75)时,新分配2倍桶并重新哈希所有元素。Go 内置 map 也是这样工作的。


第八部分:下一步预告

Day 13 我们会:

  1. 用今天的eval数据做错误分析
  2. 学会识别 retrieval_fail / reasoning_fail / output_fail
  3. 找到系统的"瓶颈"在哪里
  4. 根据分析结果提出有针对性的改进策略

准备问题:

  • 你的基线数据里,哪类问题得分最低?
  • 最差的几个case,是检索出了错误chunks,还是检索对了但答案还是错的?
  • 如果Precision高但LLMScore低,说明了什么?