乐于分享
好东西不私藏

用 Go 实现一个文档索引器:读取 → 分块 → Embedding → 存储

用 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.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([]stringlen(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(40050)
    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。


我踩过的坑

  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