用 Go 实现一个文档索引器:读取 → 分块 → Embedding → 存储
🦞 一只用 AI Agent 搭副业产线的程序员
前三篇我们分别讲了 RAG 原理、分块策略、向量数据库。是时候把它们焊在一起了。
这篇的目标:一个命令,把一文件夹 Markdown 文档变成可搜索的知识库。完整 Go 代码,可编译运行。
索引器要做什么
你的 Markdown 文件夹/ ├── redis-cache.md ├── mysql-optimization.md └── k8s-deploy.md │ ▼ [文档索引器] ← 这篇我们要写的 │ ▼ Qdrant 向量数据库 ├── [向量1] → "Redis 缓存淘汰策略包括..." ├── [向量2] → "MySQL 索引优化首先需要..." ├── [向量3] → "Kubernetes 中 Deployment..." └── ...共 500 个文档片段四步流水线:读取文件 → 分块 → 调 Embedding API → 写入 Qdrant。
第一步:项目结构
doc-indexer/ ├── main.go ├── go.mod ├── internal/ │ ├── reader/ # 读取 Markdown 文件 │ │ └── reader.go │ ├── chunker/ # 文档分块(上一篇的代码) │ │ └── chunker.go │ ├── embedder/ # Embedding API 调用 │ │ └── embedder.go │ └── store/ # Qdrant 写入 │ └── qdrant.go第二步:读取 Markdown 文件
// internal/reader/reader.gopackagereaderimport("os""path/filepath""strings")typeDocumentstruct{Namestring// 文件名(去路径去后缀)Pathstring// 完整路径Contentstring// 完整内容}// ReadDir 读取目录下所有 .md 文件funcReadDir(dirstring)([]Document,error){vardocs[]Document err:=filepath.Walk(dir,func(pathstring,info os.FileInfo,errerror)error{iferr!=nil{returnerr}ifinfo.IsDir()||!strings.HasSuffix(path,".md"){returnnil}content,err:=os.ReadFile(path)iferr!=nil{returnerr}name:=strings.TrimSuffix(info.Name(),".md")docs=append(docs,Document{Name:name,Path:path,Content:string(content),})returnnil})returndocs,err}filepath.Walk递归读取所有子目录里的.md文件。一个函数搞定。
第三步:分块(复用上篇的 Chunker)
// internal/chunker/chunker.gopackagechunkerimport"strings"typeChunkerstruct{ChunkSizeintOverlapint}funcNewChunker(size,overlapint)*Chunker{return&Chunker{ChunkSize:size,Overlap:overlap}}// Chunk 递归切分 Markdown 文本func(c*Chunker)Chunk(textstring)[]string{// 先按 ## 标题切sections:=strings.Split(text,"\n## ")varchunks[]stringfor_,section:=rangesections{runes:=[]rune(section)iflen(runes)<=c.ChunkSize{iflen(strings.TrimSpace(section))>0{chunks=append(chunks,section)}continue}// 太长,按空行再切paragraphs:=strings.Split(section,"\n\n")for_,para:=rangeparagraphs{paraRunes:=[]rune(para)iflen(paraRunes)<=c.ChunkSize{iflen(strings.TrimSpace(para))>0{chunks=append(chunks,para)}continue}// 还是太长,硬截断fori:=0;i<len(paraRunes);i+=c.ChunkSize{end:=i+c.ChunkSizeifend>len(paraRunes){end=len(paraRunes)}chunk:=string(paraRunes[i:end])iflen(strings.TrimSpace(chunk))>0{chunks=append(chunks,chunk)}}}}returnchunks}第四步:调 Embedding API
// internal/embedder/embedder.gopackageembedderimport("bytes""encoding/json""fmt""net/http""time")typeEmbedderstruct{apiKeystringbaseURLstringmodelstringhttpClient*http.Client}funcNewEmbedder(apiKeystring)*Embedder{return&Embedder{apiKey:apiKey,baseURL:"https://api.deepseek.com/anthropic",model:"deepseek-v4-pro",httpClient:&http.Client{Timeout:30*time.Second,},}}// Embed 将文本转为向量func(e*Embedder)Embed(textstring)([]float64,error){reqBody:=map[string]any{"model":e.model,"input":text,}body,_:=json.Marshal(reqBody)req,err:=http.NewRequest("POST",e.baseURL+"/v1/embeddings",bytes.NewReader(body))iferr!=nil{returnnil,err}req.Header.Set("x-api-key",e.apiKey)req.Header.Set("Content-Type","application/json")resp,err:=e.httpClient.Do(req)iferr!=nil{returnnil,fmt.Errorf("embedding 请求失败: %w",err)}deferresp.Body.Close()varresultstruct{Data[]struct{Embedding[]float64`json:"embedding"`}`json:"data"`}iferr:=json.NewDecoder(resp.Body).Decode(&result);err!=nil{returnnil,fmt.Errorf("解析响应失败: %w",err)}iflen(result.Data)==0{returnnil,fmt.Errorf("embedding 返回为空")}returnresult.Data[0].Embedding,nil}// EmbedBatch 批量转向量(一次 API 调用处理多条,省钱)func(e*Embedder)EmbedBatch(texts[]string)([][]float64,error){inputs:=make([]string,len(texts))copy(inputs,texts)reqBody:=map[string]any{"model":e.model,"input":inputs,}body,_:=json.Marshal(reqBody)req,_:=http.NewRequest("POST",e.baseURL+"/v1/embeddings",bytes.NewReader(body))req.Header.Set("x-api-key",e.apiKey)req.Header.Set("Content-Type","application/json")resp,err:=e.httpClient.Do(req)iferr!=nil{returnnil,err}deferresp.Body.Close()varresultstruct{Data[]struct{Embedding[]float64`json:"embedding"`}`json:"data"`}json.NewDecoder(resp.Body).Decode(&result)varembeddings[][]float64for_,d:=rangeresult.Data{embeddings=append(embeddings,d.Embedding)}returnembeddings,nil}注意EmbedBatch——一次 API 调用传入多个文本,比单独调用 N 次省时间和 Token。
第五步:写入 Qdrant
// internal/store/qdrant.gopackagestoreimport("bytes""encoding/json""fmt""net/http")typeQdrantStorestruct{baseURLstringcollectionstringhttpClient*http.Client}typePointstruct{IDuint64`json:"id"`Vector[]float64`json:"vector"`Payloadmap[string]any`json:"payload"`}funcNewQdrantStore(url,collectionstring)*QdrantStore{return&QdrantStore{baseURL:url,collection:collection,httpClient:&http.Client{},}}// EnsureCollection 如果集合不存在则创建func(s*QdrantStore)EnsureCollection(vectorSizeint,)error{checkURL:=fmt.Sprintf("%s/collections/%s",s.baseURL,s.collection)resp,_:=s.httpClient.Get(checkURL)ifresp.StatusCode==200{returnnil// 已存在}reqBody:=map[string]any{"name":s.collection,"vectors":map[string]any{"size":vectorSize,"distance":"Cosine",},}body,_:=json.Marshal(reqBody)req,_:=http.NewRequest("PUT",s.baseURL+"/collections/"+s.collection,bytes.NewReader(body))req.Header.Set("Content-Type","application/json")resp,err:=s.httpClient.Do(req)iferr!=nil{returnfmt.Errorf("创建集合失败: %w",err)}ifresp.StatusCode!=200{returnfmt.Errorf("创建集合返回 %d",resp.StatusCode)}returnnil}// Upsert 批量插入向量func(s*QdrantStore)Upsert(points[]Point)error{reqBody:=map[string]any{"points":points,}body,_:=json.Marshal(reqBody)url:=fmt.Sprintf("%s/collections/%s/points",s.baseURL,s.collection)req,_:=http.NewRequest("PUT",url,bytes.NewReader(body))req.Header.Set("Content-Type","application/json")resp,err:=s.httpClient.Do(req)iferr!=nil{returnfmt.Errorf("upsert 失败: %w",err)}deferresp.Body.Close()ifresp.StatusCode!=200{returnfmt.Errorf("upsert 返回 %d",resp.StatusCode)}returnnil}组装:主流程
// main.gopackagemainimport("fmt""log""os""doc-indexer/internal/chunker""doc-indexer/internal/embedder""doc-indexer/internal/reader""doc-indexer/internal/store")funcmain(){apiKey:=os.Getenv("DEEPSEEK_API_KEY")ifapiKey==""{log.Fatal("请设置环境变量 DEEPSEEK_API_KEY")}// 读取目录下所有 Markdown 文档docs,err:=reader.ReadDir("./docs")iferr!=nil{log.Fatalf("读取文档失败: %v",err)}fmt.Printf("读取到 %d 个 Markdown 文件\n",len(docs))c:=chunker.NewChunker(400,50)emb:=embedder.NewEmbedder(apiKey)qdrant:=store.NewQdrantStore("http://localhost:6333","tech_docs")// 确保集合存在(1536 维向量)iferr:=qdrant.EnsureCollection(1536);err!=nil{log.Fatalf("创建集合失败: %v",err)}vartotalChunksintfor_,doc:=rangedocs{chunks:=c.Chunk(doc.Content)fmt.Printf(" %s → %d 个片段\n",doc.Name,len(chunks))// 批量生成 Embedding(每批最多 20 条)fori:=0;i<len(chunks);i+=20{end:=i+20ifend>len(chunks){end=len(chunks)}batch:=chunks[i:end]embeddings,err:=emb.EmbedBatch(batch)iferr!=nil{log.Printf("Embedding 失败: %v",err)continue}// 构造 Qdrant pointsvarpoints[]store.Pointforj,embedding:=rangeembeddings{points=append(points,store.Point{ID:uint64(totalChunks+1),Vector:embedding,Payload:map[string]any{"text":batch[j],"doc_name":doc.Name,"chunk":i+j+1,},})totalChunks++}iferr:=qdrant.Upsert(points);err!=nil{log.Printf("写入 Qdrant 失败: %v",err)}}}fmt.Printf("\n✅ 索引完成!共 %d 个文档片段已写入 Qdrant\n",totalChunks)}跑起来
# 1. 启动 Qdrant(如果没有)dockerrun-d-p6333:6333-p6334:6334 qdrant/qdrant# 2. 准备文档mkdir-pdocscp/your/project/docs/*.md docs/# 3. 设置 API KeyexportDEEPSEEK_API_KEY="sk-your-key"# 4. 索引go run main.go# 输出:# 读取到 12 个 Markdown 文件# redis-cache → 8 个片段# mysql-optimization → 12 个片段# ...# ✅ 索引完成!共 87 个文档片段已写入 Qdrant打开浏览器访问http://localhost:6333/dashboard,你能在 Qdrant 的 Web UI 里看到每个向量的内容和 payload。
我踩过的坑
- 批量调用比逐个快 5 倍。Embedding API 支持一次传多个文本,返回多个向量。如果不看文档,一个一个调,10 个文档能跑 5 分钟。
- Qdrant 的集合要先创建。不像 MongoDB 自动建库。不创建就写会报 404。
- 中文文档的编码:Go 的
os.ReadFile默认 UTF-8,但有些 Windows 导出的 Markdown 是 GBK。如果发现乱码,加一个编码检测。
本篇核心收获
100 行 Go 代码,把任意一文件夹 Markdown 文档变成了可语义搜索的知识库。你现在拥有的是一个 RAG 系统的「写链路」——文档进来,向量出去。
下一篇我们做「读链路」:语义搜索。当用户提问时,查询重写 + 结果排序,让命中率再提一个档次。
关注我,别错过。
🦞 一只用 AI Agent 搭副业产线的程序员
全平台同名:虾哥不加班
需要定制 AI 工具?来聊聊 → lob_ai源码:GitHub - lobster-bujiaban/doc-indexer
