Go 向量数据库实战——Qdrant Go SDK 完整使用与 RAG 系统构建
Go 向量数据库实战——Qdrant Go SDK 完整使用与 RAG 系统构建
适读人群:想用 Go 构建 RAG 系统的工程师 | 阅读时长:约 17 分钟 | 核心价值:Qdrant Go SDK 完整操作手册 + RAG 系统搭建,包含踩坑记录和性能调优
去年有个做法律科技的团队找我,他们要做一个法律条文问答系统。用户输入一个法律问题,系统能从几万条法规中快速找到最相关的条文,再由 AI 给出解释。
这是一个典型的 RAG(Retrieval Augmented Generation)场景,向量数据库是核心组件。他们后端是 Go,我推荐了 Qdrant——它有官方 Go SDK,性能出色,部署简单(单个 Docker 容器就能跑起来),对这种中等规模的法规数据集来说是最合适的选择。
为什么选 Qdrant 而不是 Milvus / Weaviate
| 项目 | Qdrant | Milvus | Weaviate |
|---|---|---|---|
| Go SDK 成熟度 | 官方,稳定 | 官方,文档较少 | 官方,较完善 |
| 单机部署难度 | 极简 | 需要 etcd/Pulsar | 中等 |
| 内存占用 | 低 | 较高 | 中等 |
| 过滤+向量联合查询 | 优秀 | 良好 | 良好 |
| 适合规模 | <1亿向量 | >1亿向量 | 中大规模 |
对于几万到几百万向量的业务场景,Qdrant 的简洁性和性能都是首选。
环境搭建
# 启动 Qdrant
docker run -d --name qdrant \
-p 6333:6333 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
# 安装 Go SDK
go get github.com/qdrant/go-client完整实现
package qdrant
import (
"context"
"fmt"
"log"
pb "github.com/qdrant/go-client/qdrant"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// QdrantClient 向量数据库客户端
type QdrantClient struct {
conn *grpc.ClientConn
collections pb.CollectionsClient
points pb.PointsClient
}
func NewQdrantClient(addr string) (*QdrantClient, error) {
conn, err := grpc.Dial(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("connect to qdrant: %w", err)
}
return &QdrantClient{
conn: conn,
collections: pb.NewCollectionsClient(conn),
points: pb.NewPointsClient(conn),
}, nil
}
func (c *QdrantClient) Close() error {
return c.conn.Close()
}
// CreateCollection 创建向量集合
func (c *QdrantClient) CreateCollection(ctx context.Context, name string, vectorSize uint64) error {
_, err := c.collections.Create(ctx, &pb.CreateCollection{
CollectionName: name,
VectorsConfig: &pb.VectorsConfig{
Config: &pb.VectorsConfig_Params{
Params: &pb.VectorParams{
Size: vectorSize,
Distance: pb.Distance_Cosine, // 法律文本用余弦相似度
},
},
},
// 优化小数据集的内存使用
OptimizersConfig: &pb.OptimizersConfigDiff{
IndexingThreshold: func() *uint64 { v := uint64(10000); return &v }(),
},
})
return err
}
// LawDocument 法律文档结构
type LawDocument struct {
ID string
Title string
Content string
LawType string // 民法、刑法、行政法...
ArticleNo string // 条文编号
Vector []float32
}
// UpsertDocuments 批量写入文档
func (c *QdrantClient) UpsertDocuments(ctx context.Context, collectionName string, docs []LawDocument) error {
var points []*pb.PointStruct
for i, doc := range docs {
points = append(points, &pb.PointStruct{
Id: &pb.PointId{
PointIdOptions: &pb.PointId_Num{Num: uint64(i + 1)},
},
Vectors: &pb.Vectors{
VectorsOptions: &pb.Vectors_Vector{
Vector: &pb.Vector{Data: doc.Vector},
},
},
Payload: map[string]*pb.Value{
"id": {Kind: &pb.Value_StringValue{StringValue: doc.ID}},
"title": {Kind: &pb.Value_StringValue{StringValue: doc.Title}},
"content": {Kind: &pb.Value_StringValue{StringValue: doc.Content}},
"law_type": {Kind: &pb.Value_StringValue{StringValue: doc.LawType}},
"article_no": {Kind: &pb.Value_StringValue{StringValue: doc.ArticleNo}},
},
})
}
// 批量写入,每批 100 个
batchSize := 100
for i := 0; i < len(points); i += batchSize {
end := i + batchSize
if end > len(points) {
end = len(points)
}
batch := points[i:end]
wait := true
_, err := c.points.Upsert(ctx, &pb.UpsertPoints{
CollectionName: collectionName,
Wait: &wait,
Points: batch,
})
if err != nil {
return fmt.Errorf("upsert batch %d-%d: %w", i, end, err)
}
}
return nil
}
// SearchResult 搜索结果
type SearchResult struct {
Score float32
Title string
Content string
LawType string
ArticleNo string
}
// Search 向量相似度搜索,支持过滤条件
func (c *QdrantClient) Search(
ctx context.Context,
collectionName string,
queryVector []float32,
topK uint64,
lawTypeFilter string, // 可选:只搜索特定法律类型
) ([]SearchResult, error) {
req := &pb.SearchPoints{
CollectionName: collectionName,
Vector: queryVector,
Limit: topK,
WithPayload: &pb.WithPayloadSelector{SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: true}},
}
// 如果有类型过滤条件
if lawTypeFilter != "" {
req.Filter = &pb.Filter{
Must: []*pb.Condition{
{
ConditionOneOf: &pb.Condition_Field{
Field: &pb.FieldCondition{
Key: "law_type",
Match: &pb.Match{
MatchValue: &pb.Match_Keyword{Keyword: lawTypeFilter},
},
},
},
},
},
}
}
resp, err := c.points.Search(ctx, req)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
var results []SearchResult
for _, point := range resp.GetResult() {
payload := point.GetPayload()
results = append(results, SearchResult{
Score: point.GetScore(),
Title: payload["title"].GetStringValue(),
Content: payload["content"].GetStringValue(),
LawType: payload["law_type"].GetStringValue(),
ArticleNo: payload["article_no"].GetStringValue(),
})
}
return results, nil
}Embedding 生成:把文本变成向量
向量数据库存的是向量,文本需要先转成向量。这里用 OpenAI 的 embedding API:
package embedding
import (
"context"
"fmt"
"os"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
)
type EmbeddingClient struct {
client *openai.Client
model openai.EmbeddingModel
}
func NewEmbeddingClient() *EmbeddingClient {
return &EmbeddingClient{
client: openai.NewClient(option.WithAPIKey(os.Getenv("OPENAI_API_KEY"))),
model: openai.EmbeddingModelTextEmbedding3Small,
}
}
// Embed 对单条文本生成向量(1536维)
func (e *EmbeddingClient) Embed(ctx context.Context, text string) ([]float32, error) {
resp, err := e.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.F(e.model),
Input: openai.F(openai.EmbeddingNewParamsInputUnion(
openai.EmbeddingNewParamsInputArrayOfStrings([]string{text}),
)),
})
if err != nil {
return nil, fmt.Errorf("embedding API: %w", err)
}
floats := make([]float32, len(resp.Data[0].Embedding))
for i, v := range resp.Data[0].Embedding {
floats[i] = float32(v)
}
return floats, nil
}
// EmbedBatch 批量生成向量(每次最多 2048 条)
func (e *EmbeddingClient) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) {
const batchSize = 100 // 控制每批数量,避免超限
var allVectors [][]float32
for i := 0; i < len(texts); i += batchSize {
end := i + batchSize
if end > len(texts) {
end = len(texts)
}
batch := texts[i:end]
resp, err := e.client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.F(e.model),
Input: openai.F(openai.EmbeddingNewParamsInputUnion(
openai.EmbeddingNewParamsInputArrayOfStrings(batch),
)),
})
if err != nil {
return nil, fmt.Errorf("batch %d-%d embedding: %w", i, end, err)
}
for _, d := range resp.Data {
floats := make([]float32, len(d.Embedding))
for j, v := range d.Embedding {
floats[j] = float32(v)
}
allVectors = append(allVectors, floats)
}
}
return allVectors, nil
}RAG 完整流程
package rag
import (
"context"
"fmt"
"strings"
"your-project/embedding"
"your-project/qdrant"
anthropic "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"os"
)
type RAGSystem struct {
embedder *embedding.EmbeddingClient
db *qdrant.QdrantClient
llm *anthropic.Client
collection string
}
func NewRAGSystem(collection string) (*RAGSystem, error) {
db, err := qdrant.NewQdrantClient("localhost:6334")
if err != nil {
return nil, err
}
return &RAGSystem{
embedder: embedding.NewEmbeddingClient(),
db: db,
llm: anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))),
collection: collection,
}, nil
}
func (r *RAGSystem) Answer(ctx context.Context, question string) (string, error) {
// Step 1: 把问题转成向量
queryVec, err := r.embedder.Embed(ctx, question)
if err != nil {
return "", fmt.Errorf("embed question: %w", err)
}
// Step 2: 搜索相关法条(取 top 5)
results, err := r.db.Search(ctx, r.collection, queryVec, 5, "")
if err != nil {
return "", fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return "未找到相关法律条文", nil
}
// Step 3: 构建 context
var contextBuilder strings.Builder
contextBuilder.WriteString("以下是相关法律条文:\n\n")
for i, result := range results {
contextBuilder.WriteString(fmt.Sprintf("【%d】%s %s\n%s\n(相似度:%.2f)\n\n",
i+1, result.LawType, result.ArticleNo, result.Content, result.Score))
}
// Step 4: 让 LLM 基于法条回答
prompt := fmt.Sprintf(`%s
用户问题:%s
请基于上述法律条文,给出准确、清晰的解答。
- 引用具体条文时请注明来源
- 如果条文不足以回答问题,请说明
- 不要编造法律条文`, contextBuilder.String(), question)
resp, err := r.llm.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.ModelClaude3_5SonnetLatest),
MaxTokens: anthropic.F(int64(2048)),
Messages: anthropic.F([]anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
}),
})
if err != nil {
return "", fmt.Errorf("LLM: %w", err)
}
return resp.Content[0].(anthropic.TextBlock).Text, nil
}踩坑实录
踩坑 1:向量维度不匹配导致创建 collection 失败
现象:切换了 embedding 模型(从 text-embedding-ada-002 换到 text-embedding-3-small),向量写入时报维度错误。
原因:ada-002 是 1536 维,3-small 也是 1536 维,但如果用 3-large 是 3072 维。Collection 创建时指定的维度和实际向量维度必须一致。
解法:把向量维度作为配置,不要硬编码。切换模型时必须重建 Collection 并重新 Embed 所有数据。
踩坑 2:相似度分数偏低,召回质量差
现象:搜索"劳动合同解除",返回了一些相关性很低的结果,相似度分数只有 0.6 左右。
原因:法律文本的专业术语和用户的口语化表达之间存在语义鸿沟,短文本 embedding 效果也不如长文本。
解法:
- 在 embedding 时扩充文本:把条文标题和内容拼接,增加语义信息
- 对用户 query 做同义词扩展("解除合同" → "终止劳动关系")
- 相似度阈值设为 0.75,低于此值不返回结果
踩坑 3:Qdrant gRPC 连接在高并发下不稳定
现象:并发量上来后,偶发 connection refused 或 deadline exceeded 错误。
原因:每次请求新建 gRPC 连接太慢,且连接数过多。
解法:使用长连接 + 连接复用。gRPC 本身支持多路复用,一个连接可以处理并发请求,不需要连接池。关键是做好重连逻辑和超时设置。
成本估算
法律 RAG 系统的实测数据:
- Embedding:text-embedding-3-small,$0.02/1M tokens
- 5 万条法规,平均每条 200 tokens:共 10M tokens,$0.20 一次性建库成本
- 每次查询 embedding:约 30 tokens,$0.0000006,可忽略
- LLM 回答:claude-3-5-sonnet,输入约 3000 tokens(5条法条+问题),输出约 500 tokens
- 每次查询成本约 $0.009 + $0.0075 = $0.017
- 按每天 1000 次查询,月成本约 $510
如果换成 claude-3-haiku 做法条解读,月成本可降到 $50 以下,但对于专业法律解读,建议保留 Sonnet。
