资讯动态

第4讲:RAG 从零实现——让大模型学会查你的数据库

发布时间:2026/10/30 2:32:51 来源:尧图企业网站定制
一、为什么需要 RAG纯 LLM 的局限 ┌──────────────────────────────────────────────┐ │ 用户我们公司去年的营收是多少 │ │ LLM抱歉我的训练数据截止到2025年初 │ │ 无法获取您公司的最新数据。 │ └──────────────────────────────────────────────┘ RAG 方案 ┌──────────────────────────────────────────────┐ │ 用户我们公司去年的营收是多少 │ │ │ │ 1. 将问题转为向量 → 检索向量数据库 │ │ 2. 找到相关文档片段 │ │ 3. 将文档片段作为上下文注入 Prompt │ │ 4. LLM 基于上下文回答 │ │ │ │ LLM根据公司财报数据2025年营收为 12.8亿 │ │ 同比增长 23%。 │ └──────────────────────────────────────────────┘RAG Retrieval-Augmented Generation先检索相关知识再让 LLM 基于检索结果生成答案。二、RAG 完整架构用户提问 │ ▼ ┌──────────────────────────────────────────────┐ │ Query 处理层 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 问题理解 │ │ 查询改写 │ │ 意图识别 │ │ │ └──────────┘ └──────────┘ └──────────┘ │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ Embedding 层 │ │ 将 query 转为向量 (768/1536维) │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ 检索层 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 向量检索 │ │ 关键词检索│ │ 混合检索 │ │ │ │(余弦相似) │ │ (BM25) │ │(RRF融合) │ │ │ └──────────┘ └──────────┘ └──────────┘ │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ 重排层 │ │ 对 top-K 结果进行精排提升相关性 │ └──────────────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ 生成层 │ │ 将检索结果 原始问题 → 构造 Prompt → LLM │ └──────────────────────────────────────────────┘ │ ▼ 回答三、文档预处理与切分// rag/document.go package rag import ( crypto/md5 encoding/hex fmt regexp strings unicode/utf8 ) // Document 原始文档 type Document struct { ID string json:id Title string json:title Content string json:content Metadata map[string]string json:metadata Source string json:source CreatedAt string json:created_at } // Chunk 文档切片 type Chunk struct { ID string json:id DocID string json:doc_id Content string json:content Index int json:index TokenCount int json:token_count Metadata map[string]string json:metadata Embedding []float64 json:embedding,omitempty } // Splitter 文档切分器 type Splitter struct { chunkSize int chunkOverlap int separators []string } // SplitterOption 切分配置 type SplitterOption struct { ChunkSize int // 每片大小字符数 ChunkOverlap int // 重叠字符数 Separators []string // 分隔符优先级 } // DefaultSplitterOption 默认配置 func DefaultSplitterOption() SplitterOption { return SplitterOption{ ChunkSize: 500, ChunkOverlap: 50, Separators: []string{\n\n, \n, 。, ., , }, } } // NewSplitter 创建切分器 func NewSplitter(opt SplitterOption) *Splitter { return Splitter{ chunkSize: opt.ChunkSize, chunkOverlap: opt.ChunkOverlap, separators: opt.Separators, } } // SplitDocuments 批量切分文档 func (s *Splitter) SplitDocuments(docs []Document) []Chunk { var chunks []Chunk for _, doc : range docs { docChunks : s.SplitDocument(doc) chunks append(chunks, docChunks...) } return chunks } // SplitDocument 切分单个文档 func (s *Splitter) SplitDocument(doc Document) []Chunk { texts : s.splitText(doc.Content) var chunks []Chunk for i, text : range texts { chunk : Chunk{ ID: generateChunkID(doc.ID, i), DocID: doc.ID, Content: text, Index: i, TokenCount: estimateTokens(text), Metadata: doc.Metadata, } chunks append(chunks, chunk) } return chunks } // splitText 递归切分文本 func (s *Splitter) splitText(text string) []string { // 如果文本长度小于 chunkSize直接返回 if utf8.RuneCountInString(text) s.chunkSize { return []string{text} } // 尝试用各级分隔符切分 for _, sep : range s.separators { if sep { break } splits : strings.Split(text, sep) if len(splits) 1 { continue } // 合并切片直到达到 chunkSize var chunks []string current : for _, part : range splits { candidate : current if candidate ! { candidate sep } candidate part if utf8.RuneCountInString(candidate) s.chunkSize current ! { chunks append(chunks, current) // 保留 overlap current s.getOverlap(current) if current ! { current sep } current part } else { current candidate } } if current ! { chunks append(chunks, current) } if len(chunks) 1 { return chunks } } // 最后手段按字符数硬切 return s.hardSplit(text) } // hardSplit 硬切分 func (s *Splitter) hardSplit(text string) []string { var chunks []string runes : []rune(text) length : len(runes) for i : 0; i length; i s.chunkSize - s.chunkOverlap { end : i s.chunkSize if end length { end length } chunks append(chunks, string(runes[i:end])) } return chunks } // getOverlap 获取前一片的尾部作为重叠 func (s *Splitter) getOverlap(text string) string { runes : []rune(text) if len(runes) s.chunkOverlap { return } return string(runes[len(runes)-s.chunkOverlap:]) } // estimateTokens 估算 Token 数中文约 1.5 字/token英文约 4 字母/token func estimateTokens(text string) int { chineseChars : 0 englishLetters : 0 for _, r : range text { if r 127 { chineseChars } else if (r a r z) || (r A r Z) { englishLetters } } return chineseChars*2/3 englishLetters/4 10 // 10 保底 } func generateChunkID(docID string, index int) string { h : md5.New() h.Write([]byte(fmt.Sprintf(%s:%d, docID, index))) return hex.EncodeToString(h.Sum(nil))[:16] } // ─── 语义切分增强版 ─── // SemanticSplitter 语义切分器按段落/标题切分 type SemanticSplitter struct { *Splitter headingPattern *regexp.Regexp } func NewSemanticSplitter(opt SplitterOption) *SemanticSplitter { return SemanticSplitter{ Splitter: NewSplitter(opt), headingPattern: regexp.MustCompile((?m)^#{1,6}\s.$), } } // SplitByHeadings 按 Markdown 标题切分 func (s *SemanticSplitter) SplitByHeadings(doc Document) []Chunk { content : doc.Content headings : s.headingPattern.FindAllStringIndex(content, -1) if len(headings) 1 { return s.SplitDocument(doc) } var chunks []Chunk for i : 0; i len(headings); i { start : headings[i][0] end : len(content) if i1 len(headings) { end headings[i1][0] } sectionContent : strings.TrimSpace(content[start:end]) if sectionContent { continue } chunk : Chunk{ ID: generateChunkID(doc.ID, i), DocID: doc.ID, Content: sectionContent, Index: i, TokenCount: estimateTokens(sectionContent), Metadata: doc.Metadata, } chunks append(chunks, chunk) } return chunks }四、向量数据库抽象层// rag/vectorstore.go package rag import ( context fmt math sort sync ) // VectorStore 向量数据库接口 type VectorStore interface { Insert(ctx context.Context, chunks []Chunk) error Search(ctx context.Context, query []float64, topK int) ([]SearchResult, error) Delete(ctx context.Context, ids []string) error Stats(ctx context.Context) (*VectorStoreStats, error) } // SearchResult 搜索结果 type SearchResult struct { Chunk Chunk json:chunk Score float64 json:score Rank int json:rank } // VectorStoreStats 向量库统计 type VectorStoreStats struct { TotalChunks int json:total_chunks Dimension int json:dimension } // ─── 内存向量存储开发/测试用 ─── type MemoryVectorStore struct { chunks []Chunk dimension int mu sync.RWMutex } func NewMemoryVectorStore(dimension int) *MemoryVectorStore { return MemoryVectorStore{ chunks: make([]Chunk, 0), dimension: dimension, } } func (s *MemoryVectorStore) Insert(ctx context.Context, chunks []Chunk) error { s.mu.Lock() defer s.mu.Unlock() for i : range chunks { if len(chunks[i].Embedding) ! s.dimension { return fmt.Errorf(embedding dimension mismatch: expected %d, got %d, s.dimension, len(chunks[i].Embedding)) } s.chunks append(s.chunks, chunks[i]) } return nil } func (s *MemoryVectorStore) Search(ctx context.Context, query []float64, topK int) ([]SearchResult, error) { s.mu.RLock() defer s.mu.RUnlock() if len(query) ! s.dimension { return nil, fmt.Errorf(query dimension mismatch: expected %d, got %d, s.dimension, len(query)) } type scored struct { index int score float64 } scores : make([]scored, len(s.chunks)) for i, chunk : range s.chunks { score : cosineSimilarity(query, chunk.Embedding) scores[i] scored{index: i, score: score} } // 按分数降序排列 sort.Slice(scores, func(i, j int) bool { return scores[i].score scores[j].score }) // 取 topK if topK len(scores) { topK len(scores) } results : make([]SearchResult, topK) for i : 0; i topK; i { results[i] SearchResult{ Chunk: s.chunks[scores[i].index], Score: scores[i].score, Rank: i 1, } } return results, nil } func (s *MemoryVectorStore) Delete(ctx context.Context, ids []string) error { s.mu.Lock() defer s.mu.Unlock() idSet : make(map[string]bool) for _, id : range ids { idSet[id] true } filtered : make([]Chunk, 0) for _, chunk : range s.chunks { if !idSet[chunk.ID] { filtered append(filtered, chunk) } } s.chunks filtered return nil } func (s *MemoryVectorStore) Stats(ctx context.Context) (*VectorStoreStats, error) { s.mu.RLock() defer s.mu.RUnlock() return VectorStoreStats{ TotalChunks: len(s.chunks), Dimension: s.dimension, }, nil } // ─── 余弦相似度 ─── func cosineSimilarity(a, b []float64) float64 { if len(a) ! len(b) { return 0 } var dotProduct, normA, normB float64 for i : 0; i len(a); i { dotProduct a[i] * b[i] normA a[i] * a[i] normB b[i] * b[i] } if normA 0 || normB 0 { return 0 } return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) }五、Embedding 服务// rag/embedding.go package rag import ( bytes context encoding/json fmt io net/http time ) // Embedder Embedding 接口 type Embedder interface { Embed(ctx context.Context, texts []string) ([][]float64, error) Dimension() int } // ─── OpenAI Embedding ─── type OpenAIEmbedder struct { apiKey string baseURL string model string dimension int client *http.Client } func NewOpenAIEmbedder(apiKey string) *OpenAIEmbedder { return OpenAIEmbedder{ apiKey: apiKey, baseURL: https://api.openai.com/v1, model: text-embedding-3-small, dimension: 1536, client: http.Client{Timeout: 30 * time.Second}, } } func (e *OpenAIEmbedder) Embed(ctx context.Context, texts []string) ([][]float64, error) { req : openAIEmbedRequest{ Model: e.model, Input: texts, } data, _ : json.Marshal(req) httpReq, _ : http.NewRequestWithContext(ctx, POST, e.baseURL/embeddings, bytes.NewReader(data)) httpReq.Header.Set(Authorization, Bearer e.apiKey) httpReq.Header.Set(Content-Type, application/json) resp, err : e.client.Do(httpReq) if err ! nil { return nil, fmt.Errorf(embedding request: %w, err) } defer resp.Body.Close() body, _ : io.ReadAll(resp.Body) if resp.StatusCode ! 200 { return nil, fmt.Errorf(embedding error %d: %s, resp.StatusCode, string(body)) } var embedResp openAIEmbedResponse if err : json.Unmarshal(body, embedResp); err ! nil { return nil, fmt.Errorf(parse embedding response: %w, err) } embeddings : make([][]float64, len(embedResp.Data)) for i, d : range embedResp.Data { embeddings[i] d.Embedding } return embeddings, nil } func (e *OpenAIEmbedder) Dimension() int { return e.dimension } type openAIEmbedRequest struct { Model string json:model Input []string json:input } type openAIEmbedResponse struct { Data []struct { Embedding []float64 json:embedding Index int json:index } json:data Model string json:model Usage struct { PromptTokens int json:prompt_tokens TotalTokens int json:total_tokens } json:usage } // ─── 本地 Embedding开发测试用 ─── type LocalEmbedder struct { dimension int } func NewLocalEmbedder(dimension int) *LocalEmbedder { return LocalEmbedder{dimension: dimension} } func (e *LocalEmbedder) Embed(ctx context.Context, texts []string) ([][]float64, error) { embeddings : make([][]float64, len(texts)) for i, text : range texts { // 用文本哈希生成伪向量仅供开发测试 emb : make([]float64, e.dimension) h : 0.0 for _, r : range text { h h*0.618 float64(r) } for j : 0; j e.dimension; j { emb[j] math.Sin(h float64(j)*0.1) } embeddings[i] emb } return embeddings, nil } func (e *LocalEmbedder) Dimension() int { return e.dimension }六、RAG 引擎核心// rag/engine.go package rag import ( context fmt log time ) // RAGEngine RAG 引擎 type RAGEngine struct { embedder Embedder store VectorStore splitter *Splitter topK int minScore float64 } // RAGOption RAG 配置 type RAGOption struct { TopK int // 返回 topK 结果 MinScore float64 // 最低相似度阈值 } // NewRAGEngine 创建 RAG 引擎 func NewRAGEngine(embedder Embedder, store VectorStore, splitter *Splitter, opt RAGOption) *RAGEngine { if opt.TopK 0 { opt.TopK 5 } if opt.MinScore 0 { opt.MinScore 0.7 } return RAGEngine{ embedder: embedder, store: store, splitter: splitter, topK: opt.TopK, minScore: opt.MinScore, } } // IndexDocument 索引文档 func (e *RAGEngine) IndexDocument(ctx context.Context, doc Document) error { start : time.Now() // 1. 切分文档 chunks : e.splitter.SplitDocument(doc) log.Printf([RAG] Split document into %d chunks, len(chunks)) // 2. 批量生成 Embedding texts : make([]string, len(chunks)) for i, chunk : range chunks { texts[i] chunk.Content } embeddings, err : e.embedder.Embed(ctx, texts) if err ! nil { return fmt.Errorf(generate embeddings: %w, err) } // 3. 关联 Embedding for i : range chunks { chunks[i].Embedding embeddings[i] } // 4. 存入向量库 if err : e.store.Insert(ctx, chunks); err ! nil { return fmt.Errorf(insert to vector store: %w, err) } log.Printf([RAG] Indexed document %s in %v (%d chunks), doc.Title, time.Since(start), len(chunks)) return nil } // IndexDocuments 批量索引文档 func (e *RAGEngine) IndexDocuments(ctx context.Context, docs []Document) error { for _, doc : range docs { if err : e.IndexDocument(ctx, doc); err ! nil { return fmt.Errorf(index document %s: %w, doc.Title, err) } } return nil } // Retrieve 检索相关文档 func (e *RAGEngine) Retrieve(ctx context.Context, query string) ([]SearchResult, error) { start : time.Now() // 1. 生成查询向量 embeddings, err : e.embedder.Embed(ctx, []string{query}) if err ! nil { return nil, fmt.Errorf(generate query embedding: %w, err) } // 2. 向量检索 results, err : e.store.Search(ctx, embeddings[0], e.topK) if err ! nil { return nil, fmt.Errorf(search vector store: %w, err) } // 3. 过滤低分结果 filtered : make([]SearchResult, 0) for _, r : range results { if r.Score e.minScore { filtered append(filtered, r) } } log.Printf([RAG] Retrieved %d/%d results for query in %v, len(filtered), len(results), time.Since(start)) return filtered, nil } // BuildContext 构建 RAG 上下文用于 Prompt func (e *RAGEngine) BuildContext(ctx context.Context, query string, maxTokens int) (string, []SearchResult, error) { results, err : e.Retrieve(ctx, query) if err ! nil { return , nil, err } if len(results) 0 { return 未找到相关文档。, results, nil } // 构建上下文限制 Token 数 var contextBuilder string contextBuilder 以下是与用户问题相关的参考资料\n\n totalTokens : estimateTokens(contextBuilder) for i, r : range results { chunkTokens : estimateTokens(r.Chunk.Content) if totalTokenschunkTokens maxTokens { break } contextBuilder fmt.Sprintf(【参考资料 %d】相似度: %.2f\n%s\n\n, i1, r.Score, r.Chunk.Content) totalTokens chunkTokens } return contextBuilder, results, nil } // GeneratePrompt 生成完整的 RAG Prompt func (e *RAGEngine) GeneratePrompt(ctx context.Context, query string, systemPrompt string) (string, error) { context, results, err : e.BuildContext(ctx, query, 3000) if err ! nil { return , err } if systemPrompt { systemPrompt 你是一个专业的问答助手。请基于提供的参考资料回答问题。如果参考资料不足以回答问题请如实说明。 } prompt : fmt.Sprintf(%s %s 用户问题%s 请基于以上参考资料回答用户问题。如果参考资料中没有相关信息请说根据现有资料无法回答。, systemPrompt, context, query) _ results // 可用于后续记录引用来源 return prompt, nil }七、完整使用示例// rag/example.go package rag import ( context fmt log ) func Example() { ctx : context.Background() // 1. 初始化组件 embedder : NewLocalEmbedder(128) // 开发用生产用 OpenAI store : NewMemoryVectorStore(128) splitter : NewSplitter(DefaultSplitterOption()) engine : NewRAGEngine(embedder, store, splitter, RAGOption{ TopK: 3, MinScore: 0.5, }) // 2. 准备文档 docs : []Document{ { ID: doc-001, Title: 产品退货政策, Content: 退货政策2025年版 一、退货时效 1. 普通商品收货后7天内可申请无理由退货 2. 电子产品收货后15天内出现质量问题可退货 3. 定制商品不支持无理由退货 二、退货条件 1. 商品保持完好不影响二次销售 2. 赠品需一并退回 3. 运费由买家承担质量问题除外 三、退款流程 1. 在订单页面提交退货申请 2. 等待审核1-2个工作日 3. 审核通过后寄回商品 4. 仓库验收后3-5个工作日退款, Metadata: map[string]string{category: policy, department: after-sale}, }, { ID: doc-002, Title: VIP会员权益说明, Content: VIP会员权益 金牌会员年消费≥10000元 - 免费极速退款 - 专属客服优先接入 - 生日礼包 - 双倍积分 银牌会员年消费≥5000元 - 免费退货上门取件 - 专属客服 - 生日折扣券 铜牌会员年消费≥1000元 - 延长退货至15天 - 积分加速, Metadata: map[string]string{category: membership, department: operation}, }, } // 3. 索引文档 if err : engine.IndexDocuments(ctx, docs); err ! nil { log.Fatal(err) } // 4. 测试检索 queries : []string{ 手机退货需要什么条件, 我是金牌会员退货有什么特殊权益, 你们公司什么时候成立的, // 无相关文档 } for _, q : range queries { fmt.Printf(\n 问题: %s \n, q) // 检索 results, err : engine.Retrieve(ctx, q) if err ! nil { log.Fatal(err) } fmt.Printf(检索到 %d 条相关结果:\n, len(results)) for _, r : range results { fmt.Printf( [%.2f] %s...\n, r.Score, truncate(r.Chunk.Content, 80)) } // 生成 Prompt prompt, err : engine.GeneratePrompt(ctx, q, ) if err ! nil { log.Fatal(err) } fmt.Printf(\n生成的 Prompt (前200字):\n%s...\n, truncate(prompt, 400)) fmt.Println(---) } // 5. 查看统计 stats, _ : store.Stats(ctx) fmt.Printf(\n向量库统计: %d 个文档块, 维度: %d\n, stats.TotalChunks, stats.Dimension) } func truncate(s string, n int) string { runes : []rune(s) if len(runes) n { return s } return string(runes[:n]) ... }八、生产坑位#坑位现象解决方案1切分不合理​跨段落截断导致语义断裂按 Markdown 标题/段落语义切分2Embedding 精度不足​检索结果不相关选用更优模型 混合检索(BM25向量)3上下文窗口溢出​Token 超出模型限制动态截断 按分数排序保留最相关片段4数据更新延迟​旧数据仍被检索到增量更新 定时重建索引5检索噪声​召回大量无关结果提高 min_score 阈值 重排模型精排6冷启动​新入库文档未被检索预热索引 主动 Embedding 缓存九、本讲小结┌─────────────────────────────────────────────────────────────┐ │ 第4讲 核心收获 │ │ │ │ ✅ 理解了 RAG 的核心流程切分 → Embedding → 检索 → 生成 │ │ ✅ 实现了多种文档切分策略按字符/语义/Markdown 标题 │ │ ✅ 搭建了向量数据库抽象层内存实现 接口定义 │ │ ✅ 集成了 Embedding 服务OpenAI 本地测试 │ │ ✅ 实现了完整的 RAG 引擎检索 上下文构建 Prompt 生成 │ │ ✅ 了解了 6 个常见坑位及其解决方案 │ │ │ │ 核心理念RAG 让 LLM 不再局限于训练数据能实时访问私域知识 │ └─────────────────────────────────────────────────────────────┘十、配套工具推荐 开发之余的小工具推荐在调试 RAG 系统时经常需要检查文档切分效果、对比 Embedding 相似度、分析检索结果的 JSON 结构JSON 格式化zz365.top 的 JSON 工具可以将检索结果展开直观查看每个 Chunk 的元数据和得分文本差异对比对比不同切分策略的效果看哪种子串保留了更完整的语义Base64 编解码处理某些 Embedding 模型返回的 Base64 编码向量UUID 生成器为每个文档和 Chunk 分配唯一 ID同类可备站点tool.lu综合在线工具箱360站长工具zz360.cc偏 SEO/建站方向提醒所有涉及业务数据和用户信息的操作务必先在本地脱敏再使用在线工具。下一讲预告《Function Calling 实战——让 AI 真的能帮你干活》你会学到Tool 定义规范、参数解析、工具注册与动态调用、错误处理与重试、完整的 Function Calling 框架。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑