基于向量的语义相似检索
2026年4月17日 · 635 字 · 3 分钟
如何使用Qdrant向量数据库实现语义相似检索,构建高效的RAG系统 在传统的搜索引擎中,我们使用关键词匹配来查找文档。但这种方法有很大的局限性: 向量检索通过将文本转换为向量,然后在向量空间中计算相似度,可以解决这些问题。 Qdrant是一个高性能的向量数据库,专门用于向量相似度搜索。它具有以下特点: 使用单例模式创建Qdrant客户端,避免重复连接: 索引器负责将文档向量化并存入Qdrant: 关键配置说明: 为了使用内积距离计算相似度,需要将向量归一化(模长为1): 为什么要归一化? 归一化后,向量的模长为1,此时: 将文档存入向量数据库: 根据查询文本检索相似文档: 过滤条件的作用: 在实际应用中,我们经常需要在向量搜索的同时进行元数据过滤。例如: Qdrant支持先过滤再搜索,效率很高。 输出: 关键观察: 使用内积距离时,确保向量已归一化: TopK过大: TopK过小: 过滤掉相似度过低的文档,提高结果质量。 先过滤再搜索,减少搜索范围,提高效率。为什么需要向量检索
工作原理
用户查询: "美国多少岁可以退休?"
↓
转换为向量: [0.12, 0.34, -0.56, ...]
↓
在向量数据库中搜索相似向量
↓
返回最相似的文档: "美国平均退休年龄:66.33岁"
Qdrant向量数据库
代码实现
依赖安装
go get github.com/qdrant/go-client/qdrant
go get github.com/cloudwego/eino-ext/components/indexer/qdrant
go get github.com/cloudwego/eino-ext/components/retriever/qdrant
创建Qdrant客户端
var qdrantClient *qdrant.Client
var so sync.Once
func NewQdrantClient() *qdrant.Client {
so.Do(func() {
var err error
qdrantClient, err = qdrant.NewClient(&qdrant.Config{
Host: "127.0.0.1",
Port: 6334,
})
if err != nil {
log.Fatalf("NewClient failed, err=%v", err)
}
})
return qdrantClient
}
创建索引器
func NewQdrantIndexer(ctx context.Context, client *qdrant.Client) *qdrant_indexer.Indexer {
CollectName := "test_collection"
var indexer *qdrant_indexer.Indexer
if true, err := client.CollectionExists(ctx, CollectName); err == nil {
// 如果集合已存在,先删除
if err := client.DeleteCollection(ctx, CollectName); err != nil {
log.Fatalf("DeleteCollection failed, err=%v", err)
}
// 创建新的索引器
indexer, err = qdrant_indexer.NewIndexer(ctx, &qdrant_indexer.Config{
Client: client,
Embedding: &NormalizedEmbedder{
OriginalEmbedder: Embeddder,
},
VectorDim: 768, // 向量维度
Distance: qdrant.Distance_Dot, // 使用内积距离
Collection: CollectName,
})
if err != nil {
log.Fatalf("NewIndexer failed, err=%v", err)
}
}
return indexer
}
VectorDim: 768:向量维度,必须与Embedding模型的输出维度一致Distance: qdrant.Distance_Dot:使用内积距离。由于向量已归一化,内积等于余弦相似度归一化Embedder
type NormalizedEmbedder struct {
OriginalEmbedder *ollama.Embedder
}
func (ne *NormalizedEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...embedding.Option) ([][]float64, error) {
// 1. 调用原始Embedding模型获取向量
vectors, err := ne.OriginalEmbedder.EmbedStrings(ctx, texts, opts...)
if err != nil {
return nil, err
}
// 2. 对向量进行归一化
for i, v := range vectors {
vectors[i] = NormalizeVector(v)
}
// 3. 返回模长为1的向量
return vectors, nil
}
存储文档
func StoreEmbeddings(ctx context.Context, docs []*schema.Document, indexer *qdrant_indexer.Indexer) error {
ids, err := indexer.Store(ctx, docs)
if err != nil {
return err
}
fmt.Printf("Store %d documents, ids: %v\n", len(ids), ids)
return nil
}
语义检索
func GetEmbeddings(ctx context.Context, query string, CollectName string, client *qdrant.Client, indexer *qdrant_indexer.Indexer) ([]*schema.Document, error) {
scoreThresh := 0.3
retriever, _ := qdrant_retriever.NewRetriever(ctx, &qdrant_retriever.Config{
Client: client,
Embedding: &NormalizedEmbedder{OriginalEmbedder: Embeddder},
TopK: 20, // 返回最相似的20个文档
ScoreThreshold: &scoreThresh, // 相似度阈值
Collection: CollectName,
})
// 执行检索,并添加过滤条件
neighbors, _ := retriever.Retrieve(ctx, query,
qdrant_retriever.WithFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatch("metadata.source", "sohu"), // 只返回source为sohu的文档
},
}),
)
return neighbors, nil
}
完整示例
测试代码
func TestGetEmbeddings(t *testing.T) {
ctx := context.Background()
// 准备测试文档
docs := []*schema.Document{
{
ID: uuid.NewString(),
Content: "美国9个国家公共假日,元旦:1月1日;马丁路德金日:一月的第三个星期一...",
MetaData: map[string]any{
"source": "sohu",
},
},
{
ID: uuid.NewString(),
Content: "美国平均退休年龄:66.33岁",
MetaData: map[string]any{
"source": "sina",
},
},
{
ID: uuid.NewString(),
Content: "美国平均退休年龄:66.33岁",
MetaData: map[string]any{
"source": "sohu",
},
},
}
// 初始化
NewEmbedder()
client := NewQdrantClient()
indexer := NewQdrantIndexer(ctx, client)
// 存储文档
err := StoreEmbeddings(ctx, docs, indexer)
if err != nil {
t.Fatal(err)
}
// 语义检索
docreturn, err := GetEmbeddings(ctx, "美国多少岁可以退休?", "test_collection", client, indexer)
if err != nil {
t.Fatalf("GetEmbeddings failed: %v", err)
}
// 输出结果
for i, doc := range docreturn {
fmt.Printf("%d %.4f %s\n", i, doc.Score(), doc.Content)
}
}
运行结果
go test -v -run TestGetEmbeddings ./rag/
=== RUN TestGetEmbeddings
Store 3 documents, ids: [49d45bf9-d731-4747-bab3-beed8da245b9 adba8c24-ceb0-4b7a-8f6e-21c6e78c0da9 27aab4dc-7061-469d-89cb-3cefe8189cba]
index_test.go:45: StoreEmbeddings success
index_test.go:50: 返回文档数量: 2
0 0.7797 美国平均退休年龄:66.33岁
1 0.7164 美国9个国家公共假日,元旦:1月1日;马丁路德金日:一月的第三个星期一...
--- PASS: TestGetEmbeddings (1.02s)
PASS
结果分析
排名
相似度
文档内容
说明
1
0.7797
美国平均退休年龄:66.33岁
最相关,直接回答了问题
2
0.7164
美国9个国家公共假日…
次相关,都是美国相关信息
架构图
┌─────────────────────────────────────────────────────────────┐
│ RAG检索流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户查询 │
│ "美国多少岁可以退休?" │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Embedder │ 文本 → 向量 │
│ │ (归一化) │ [0.12, 0.34, -0.56, ...] │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Qdrant │ 向量相似度搜索 + 元数据过滤 │
│ │ Retriever │ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 返回结果 │ 按相似度排序的文档列表 │
│ │ - Doc1 │ 0.7797 美国平均退休年龄 │
│ │ - Doc2 │ 0.7164 美国公共假日 │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
性能优化建议
1. 向量归一化
// 归一化后,内积 = 余弦相似度
Distance: qdrant.Distance_Dot
2. 合理设置TopK
TopK: 20, // 根据实际需求调整,不是越大越好
3. 设置相似度阈值
scoreThresh := 0.3
ScoreThreshold: &scoreThresh,
4. 使用元数据过滤
qdrant_retriever.WithFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatch("metadata.source", "sohu"),
},
})