基于向量的语义相似检索

2026年4月17日 · 635 字 · 3 分钟

如何使用Qdrant向量数据库实现语义相似检索,构建高效的RAG系统

为什么需要向量检索

在传统的搜索引擎中,我们使用关键词匹配来查找文档。但这种方法有很大的局限性:

  • 同义词问题:用户搜索"汽车",但文档中写的是"轿车",无法匹配
  • 语义理解:用户搜索"美国退休年龄",文档中写的是"美国平均退休年龄:66.33岁",关键词完全不同
  • 模糊查询:用户的问题表述与文档内容不完全一致

向量检索通过将文本转换为向量,然后在向量空间中计算相似度,可以解决这些问题。

工作原理

用户查询: "美国多少岁可以退休?"
    ↓
转换为向量: [0.12, 0.34, -0.56, ...]
    ↓
在向量数据库中搜索相似向量
    ↓
返回最相似的文档: "美国平均退休年龄:66.33岁"

Qdrant向量数据库

Qdrant是一个高性能的向量数据库,专门用于向量相似度搜索。它具有以下特点:

  • 高性能:使用HNSW算法实现快速近似最近邻搜索
  • 过滤支持:支持在向量搜索的同时进行元数据过滤
  • 分布式:支持水平扩展
  • 多语言SDK:支持Python、Go、Rust等多种语言

代码实现

依赖安装

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客户端

使用单例模式创建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
}

创建索引器

索引器负责将文档向量化并存入Qdrant:

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

为了使用内积距离计算相似度,需要将向量归一化(模长为1):

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
}

为什么要归一化?

归一化后,向量的模长为1,此时:

  • 内积 = 余弦相似度
  • 内积计算比余弦相似度计算更快(不需要除法)

存储文档

将文档存入向量数据库:

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
}

过滤条件的作用:

在实际应用中,我们经常需要在向量搜索的同时进行元数据过滤。例如:

  • 只搜索特定来源的文档
  • 只搜索特定时间段的文档
  • 只搜索特定用户有权访问的文档

Qdrant支持先过滤再搜索,效率很高。

完整示例

测试代码

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个国家公共假日… 次相关,都是美国相关信息

关键观察:

  1. 语义理解正确:查询"美国多少岁可以退休?",最相似的是"美国平均退休年龄:66.33岁",语义完全匹配
  2. 过滤生效:虽然存储了3篇文档,但只返回了2篇(过滤条件是source=sohu)
  3. 相似度排序:结果按相似度从高到低排序

架构图

┌─────────────────────────────────────────────────────────────┐
│                        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,  // 根据实际需求调整,不是越大越好

TopK过大:

  • 返回结果中可能包含不相关内容
  • 后续LLM处理的token消耗增加

TopK过小:

  • 可能遗漏相关内容

3. 设置相似度阈值

scoreThresh := 0.3
ScoreThreshold: &scoreThresh,

过滤掉相似度过低的文档,提高结果质量。

4. 使用元数据过滤

qdrant_retriever.WithFilter(&qdrant.Filter{
    Must: []*qdrant.Condition{
        qdrant.NewMatch("metadata.source", "sohu"),
    },
})

先过滤再搜索,减少搜索范围,提高效率。