🦞 一只用 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.go
package reader
import (
"os"
"path/filepath"
"strings"
)
type Document struct {
Name string// 文件名(去路径去后缀)
Path string// 完整路径
Content string// 完整内容
}
// ReadDir 读取目录下所有 .md 文件
funcReadDir(dir string)([]Document, error) {
var docs []Document
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error)error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(path, ".md") {
returnnil
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
name := strings.TrimSuffix(info.Name(), ".md")
docs = append(docs, Document{
Name: name,
Path: path,
Content: string(content),
})
returnnil
})
return docs, err
}
filepath.Walk 递归读取所有子目录里的 .md 文件。一个函数搞定。
第三步:分块(复用上篇的 Chunker)
// internal/chunker/chunker.go
package chunker
import"strings"
type Chunker struct {
ChunkSize int
Overlap int
}
funcNewChunker(size, overlap int) *Chunker {
return &Chunker{ChunkSize: size, Overlap: overlap}
}
// Chunk 递归切分 Markdown 文本
func(c *Chunker)Chunk(text string) []string {
// 先按 ## 标题切
sections := strings.Split(text, "\n## ")
var chunks []string
for _, section := range sections {
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 := range paragraphs {
paraRunes := []rune(para)
iflen(paraRunes) <= c.ChunkSize {
iflen(strings.TrimSpace(para)) > 0 {
chunks = append(chunks, para)
}
continue
}
// 还是太长,硬截断
for i := 0; i < len(paraRunes); i += c.ChunkSize {
end := i + c.ChunkSize
if end > len(paraRunes) {
end = len(paraRunes)
}
chunk := string(paraRunes[i:end])
iflen(strings.TrimSpace(chunk)) > 0 {
chunks = append(chunks, chunk)
}
}
}
}
return chunks
}
第四步:调 Embedding API
// internal/embedder/embedder.go
package embedder
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Embedder struct {
apiKey string
baseURL string
model string
httpClient *http.Client
}
funcNewEmbedder(apiKey string) *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(text string)([]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))
if err != nil {
returnnil, err
}
req.Header.Set("x-api-key", e.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
returnnil, fmt.Errorf("embedding 请求失败: %w", err)
}
defer resp.Body.Close()
var result struct {
Data []struct {
Embedding []float64`json:"embedding"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
returnnil, fmt.Errorf("解析响应失败: %w", err)
}
iflen(result.Data) == 0 {
returnnil, fmt.Errorf("embedding 返回为空")
}
return result.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)
if err != nil {
returnnil, err
}
defer resp.Body.Close()
var result struct {
Data []struct {
Embedding []float64`json:"embedding"`
} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&result)
var embeddings [][]float64
for _, d := range result.Data {
embeddings = append(embeddings, d.Embedding)
}
return embeddings, nil
}
注意 EmbedBatch——一次 API 调用传入多个文本,比单独调用 N 次省时间和 Token。
第五步:写入 Qdrant
// internal/store/qdrant.go
package store
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type QdrantStore struct {
baseURL string
collection string
httpClient *http.Client
}
type Point struct {
ID uint64`json:"id"`
Vector []float64`json:"vector"`
Payload map[string]any `json:"payload"`
}
funcNewQdrantStore(url, collection string) *QdrantStore {
return &QdrantStore{
baseURL: url,
collection: collection,
httpClient: &http.Client{},
}
}
// EnsureCollection 如果集合不存在则创建
func(s *QdrantStore)EnsureCollection(
vectorSize int,
)error {
checkURL := fmt.Sprintf("%s/collections/%s",
s.baseURL, s.collection)
resp, _ := s.httpClient.Get(checkURL)
if resp.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)
if err != nil {
return fmt.Errorf("创建集合失败: %w", err)
}
if resp.StatusCode != 200 {
return fmt.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)
if err != nil {
return fmt.Errorf("upsert 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("upsert 返回 %d", resp.StatusCode)
}
returnnil
}
组装:主流程
// main.go
package main
import (
"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")
if apiKey == "" {
log.Fatal("请设置环境变量 DEEPSEEK_API_KEY")
}
// 读取目录下所有 Markdown 文档
docs, err := reader.ReadDir("./docs")
if err != 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 维向量)
if err := qdrant.EnsureCollection(1536); err != nil {
log.Fatalf("创建集合失败: %v", err)
}
var totalChunks int
for _, doc := range docs {
chunks := c.Chunk(doc.Content)
fmt.Printf(" %s → %d 个片段\n", doc.Name, len(chunks))
// 批量生成 Embedding(每批最多 20 条)
for i := 0; i < len(chunks); i += 20 {
end := i + 20
if end > len(chunks) {
end = len(chunks)
}
batch := chunks[i:end]
embeddings, err := emb.EmbedBatch(batch)
if err != nil {
log.Printf("Embedding 失败: %v", err)
continue
}
// 构造 Qdrant points
var points []store.Point
for j, embedding := range embeddings {
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++
}
if err := qdrant.Upsert(points); err != nil {
log.Printf("写入 Qdrant 失败: %v", err)
}
}
}
fmt.Printf("\n✅ 索引完成!共 %d 个文档片段已写入 Qdrant\n",
totalChunks)
}
跑起来
# 1. 启动 Qdrant(如果没有)
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant
# 2. 准备文档
mkdir -p docs
cp /your/project/docs/*.md docs/
# 3. 设置 API Key
export DEEPSEEK_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
夜雨聆风