当前位置: 首页 > news >正文

用 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。


我踩过的坑

  1. 批量调用比逐个快 5 倍。Embedding API 支持一次传多个文本,返回多个向量。如果不看文档,一个一个调,10 个文档能跑 5 分钟。
  2. Qdrant 的集合要先创建。不像 MongoDB 自动建库。不创建就写会报 404。
  3. 中文文档的编码:Go 的os.ReadFile默认 UTF-8,但有些 Windows 导出的 Markdown 是 GBK。如果发现乱码,加一个编码检测。

本篇核心收获

100 行 Go 代码,把任意一文件夹 Markdown 文档变成了可语义搜索的知识库。你现在拥有的是一个 RAG 系统的「写链路」——文档进来,向量出去。

下一篇我们做「读链路」:语义搜索。当用户提问时,查询重写 + 结果排序,让命中率再提一个档次。

关注我,别错过。


🦞 一只用 AI Agent 搭副业产线的程序员

全平台同名:虾哥不加班
需要定制 AI 工具?来聊聊 → lob_ai

源码:GitHub - lobster-bujiaban/doc-indexer

http://www.jsqmd.com/news/960822/

相关文章:

  • 告别STM32!用NVIDIA TX2串口+C语言搞定大疆C620电机控制(附完整代码)
  • 自然语言驱动的客户分群分析系统实战
  • 别再傻等!UiPath恢复依赖项卡住的3个真正原因与保姆级解决流程
  • 2026最新诚信优选长沙市黄金回收白银回收铂金回收彩金回收高口碑靠谱门店TOP5权威排行榜+联系方式推荐 - 前途无量YY
  • MariaDB-backup 数据库物理备份恢复最佳实践(10.6 版本适配)
  • 【三明+连锁老店+黄金回收实时报价与上门服务盘点】 - 余生黄金回收
  • 别再凭感觉挑照片了!用FaceQnet给你的AI人脸识别系统做个‘质检员’
  • Nginx 升级指南:从 1.24.0 升级到 1.30.0
  • Synopsys ICC GUI高效操作秘籍:除了鼠标点击,这些键盘热键和隐藏技巧让你布局布线快人一步
  • 代码背后的守护者|一名MES技术老师的“破案”日常 用AI提效部署图绘制实践
  • 2026年广州会议系统供应商口碑排行榜揭晓
  • UiPath恢复依赖项卡住?别傻等!这4个方法(含手动复制包路径)亲测有效
  • Java版Spark电商数据处理实战包:含源码、文档与本地实测环境
  • 利用java11新特性与快马平台,大幅提升日常编码效率
  • 2026最新诚信优选长垣市黄金回收白银回收铂金回收彩金回收高口碑靠谱门店TOP5权威排行榜+联系方式推荐 - 前途无量YY
  • SpringBoot项目升级Swagger3.0后,swagger-ui.html页面404?别慌,一个注解搞定
  • 从Verilog到SystemVerilog:为什么logic能一统江湖?聊聊wire和reg的‘历史遗留问题’
  • 免费投票小程序横评:众星评选 VS 3款主流竞品,性价比之王毫无悬念 - 微信投票小程序
  • 语义搜索实战:查询重写与结果排序
  • 吃透Claude Code动态工作流,用法、场景与实战技巧,告别AI任务失效问题
  • 知识付费下半场:创客匠人用“工具+陪跑+AI”重新定义IP变现
  • 实战避坑:Jenkins Pipeline中多容器Pod Agent的权限与日志问题解决指南
  • 石墨电热板哪个厂家有实力,产品有优势
  • 2026年靖江大平层全屋高端定制企业选型指南
  • 别再依赖在线服务了!手把手教你用Fast Downward在本地搭建PDDL规划器(附VSCode配置避坑指南)
  • 2026最新诚信优选长治市黄金回收白银回收铂金回收彩金回收高口碑靠谱门店TOP5权威排行榜+联系方式推荐 - 前途无量YY
  • 编程新手福音:用快马平台把你的第一个网站idea轻松变成现实
  • Python转Java系列:前言
  • 从一次Ping不通的故障说起:深入Linux内核看MTU、分片与网络性能调优
  • 实战嵌入式项目:基于快马AI生成ESP32智能盆栽监测与自动浇水系统完整代码