Trong Phần 2: Data Ingestion & E-commerce Chunking - Đưa Dữ Liệu Sản Phẩm Vào Môi Trường AI, chúng ta đã thiết lập một pipeline đồng bộ dữ liệu sạch sẽ từ PostgreSQL sang Qdrant qua Kafka CDC. Nhưng hành trình xây dựng một hệ thống tìm kiếm chuẩn e-commerce chỉ mới bắt đầu.

Khi người dùng nhập: “laptop Asus ROG Zephyrus G14 giá dưới 30 triệu còn hàng”

  • Nếu sử dụng Dense Vector Search thuần túy: Hệ thống có thể trả về các laptop Asus ROG Zephyrus khác nhưng giá 45 triệu, hoặc thậm chí máy cũ đã hết hàng, vì mô hình Embedding chỉ hiểu được độ tương đồng ngữ nghĩa chung chung chứ không xử lý được các phép so sánh số học cứng (Hard Filters như price < 30,000,000in_stock = true).
  • Nếu sử dụng Lexical Search (BM25) thuần túy: Hệ thống sẽ thất bại khi người dùng tìm kiếm theo ý định như “máy tính chơi game mỏng nhẹ hiệu năng cao”, vì các từ khóa này không xuất hiện trực tiếp trong văn bản mô tả sản phẩm.

Giải pháp tối ưu cho e-commerce là Hybrid Search — kết hợp Dense Search (hiểu ngữ nghĩa), Sparse Search/BM25 (khớp từ khóa chính xác, mã SKU) và Filterable HNSW (lọc thuộc tính cứng hiệu năng cao).

Bài viết này sẽ hướng dẫn bạn cấu hình và triển khai kiến trúc Hybrid Search nâng cao sử dụng Qdrant Go Client và cơ chế Reciprocal Rank Fusion (RRF).


1. Cấu Hình Collection: Dense & Sparse Vectors Song Song

Kể từ Qdrant v1.7.0, chúng ta có thể cấu hình một Collection chứa đồng thời cả Dense Vectors và Sparse Vectors. Sparse Vectors không yêu cầu số chiều (dimension) cố định, vì chúng lưu trữ dưới dạng mảng các chỉ mục từ khóa (indices) và trọng số thống kê (values).

Dưới đây là cách khởi tạo Collection hỗ trợ Hybrid Search sử dụng Qdrant Go SDK:

package search

import (
	"context"
	"fmt"

	"github.com/qdrant/go-client/qdrant"
)

type QdrantSearchClient struct {
	client         *qdrant.Client
	collectionName string
}

func NewQdrantSearchClient(client *qdrant.Client, collectionName string) *QdrantSearchClient {
	return &QdrantSearchClient{
		client:         client,
		collectionName: collectionName,
	}
}

// CreateHybridCollection khởi tạo collection với cả dense và named sparse vectors
func (q *QdrantSearchClient) CreateHybridCollection(ctx context.Context) error {
	err := q.client.CreateCollection(ctx, &qdrant.CreateCollection{
		CollectionName: q.collectionName,
		// 1. Cấu hình Dense Vector cho tìm kiếm ngữ nghĩa (ví dụ: model 384 chiều)
		VectorsConfig: qdrant.NewVectorsConfigMap(map[string]*qdrant.VectorParams{
			"dense-embeddings": {
				Size:     384,
				Distance: qdrant.Distance_Cosine,
			},
		}),
		// 2. Cấu hình Named Sparse Vector cho BM25 / SPLADE
		SparseVectorsConfig: qdrant.NewSparseVectorsConfigMap(map[string]*qdrant.SparseVectorParams{
			"sparse-bm25": {
				Index: &qdrant.SparseIndexParams{
					OnDisk: qdrant.PtrBool(true), // Tiết kiệm RAM bằng cách đưa chỉ mục sparse xuống đĩa
				},
			},
		}),
	})
	if err != nil {
		return fmt.Errorf("failed to create collection: %w", err)
	}
	return nil
}

Lưu ý: Sparse Vectors bắt buộc phải sử dụng độ đo tương đồng là Dot Product (đây là giá trị mặc định của Qdrant cho sparse configuration).


2. Tìm Hiểu Universal Query API & Reciprocal Rank Fusion (RRF)

Để kết hợp kết quả từ hai thuật toán tìm kiếm khác nhau (Dense và Sparse), chúng ta sử dụng Reciprocal Rank Fusion (RRF). RRF hoạt động dựa trên thứ hạng (rank) của các điểm dữ liệu trong từng danh sách kết quả, thay vì cộng trực tiếp điểm số (score) thô (vốn có thang đo khác nhau hoàn toàn).

Công thức tính điểm RRF cho một tài liệu $d$: $$RRF_Score(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$ Trong đó:

  • $M$ là tập hợp các phương thức tìm kiếm (Dense và Sparse).
  • $r_m(d)$ là thứ hạng của tài liệu $d$ trong phương thức tìm kiếm $m$.
  • $k$ là hằng số làm mịn (thường mặc định là $60$), giúp giảm tầm ảnh hưởng của các tài liệu có thứ hạng quá thấp.

Gọi Hybrid Search sử dụng cơ chế Prefetch trong Go

Qdrant cung cấp Universal Query API cho phép chúng ta thực hiện truy vấn đa tầng thông qua tham số Prefetch. Chúng ta sẽ gửi hai truy vấn con song song lên server, sau đó yêu cầu server tự động merge kết quả bằng thuật toán RRF.

Dưới đây là cách triển khai hàm tìm kiếm Hybrid sử dụng Qdrant Go Client:

// SearchParams chứa thông tin truy vấn Hybrid từ người dùng
type SearchParams struct {
	DenseQuery  []float32
	SparseQuery *qdrant.SparseVector
	StoreID     int32
	Limit       uint64
}

func (q *QdrantSearchClient) HybridSearch(ctx context.Context, params SearchParams) ([]*qdrant.ScoredPoint, error) {
	// 1. Định nghĩa truy vấn con Dense (Semantic)
	densePrefetch := &qdrant.Prefetch{
		Query: qdrant.NewQueryDense(params.DenseQuery),
		Using: qdrant.PtrString("dense-embeddings"),
		Limit: qdrant.PtrUint64(100), // Lấy top 100 ứng viên từ semantic search
	}

	// 2. Định nghĩa truy vấn con Sparse (Lexical)
	sparsePrefetch := &qdrant.Prefetch{
		Query: qdrant.NewQuerySparse(params.SparseQuery.Indices, params.SparseQuery.Values),
		Using: qdrant.PtrString("sparse-bm25"),
		Limit: qdrant.PtrUint64(100), // Lấy top 100 ứng viên từ từ khóa chính xác
	}

	// 3. Thực thi QueryPoints kết hợp RRF và Hard Filters
	res, err := q.client.Query(ctx, &qdrant.QueryPoints{
		CollectionName: q.collectionName,
		// Sử dụng Prefetch để chạy song song 2 truy vấn con
		Prefetch: []*qdrant.Prefetch{densePrefetch, sparsePrefetch},
		// Merge kết quả bằng thuật toán Reciprocal Rank Fusion (RRF)
		Query: qdrant.NewQueryRRF(), 
		// Lọc cứng store_id để đảm bảo multi-tenancy an toàn
		Filter: &qdrant.Filter{
			Must: []*qdrant.Condition{
				qdrant.NewFieldFilterMinInteger("store_id", int64(params.StoreID)),
			},
		},
		Limit: qdrant.PtrUint64(params.Limit),
	})
	if err != nil {
		return nil, fmt.Errorf("hybrid query failed: %w", err)
	}

	return res, nil
}

Mẹo nâng cao: Qdrant v1.14+ hỗ trợ cấu hình trọng số cho từng prefetch (RRF Weights). Ví dụ: nếu bạn muốn ưu tiên kết quả tìm kiếm ngữ nghĩa hơn từ khóa chính xác, bạn có thể truyền thêm mảng Weights vào cấu hình fusion của API.


3. Tối Ưu Hóa Hard Filters Với Filterable HNSW & Payload Indexing

Mặc dù Qdrant có tốc độ tìm kiếm vector cực kỳ nhanh, hiệu năng của nó sẽ sụt giảm nghiêm trọng nếu bạn thực hiện lọc Metadata (Payload Filters) mà không cấu hình hợp lý.

Cơ Chế Hoạt Động Của Filterable HNSW

Thông thường, nếu bạn lọc dữ liệu sau khi tìm kiếm vector (Post-filtering), bạn có thể bị mất mát kết quả (Ví dụ: Lấy top 10 sản phẩm tương đồng nhất, sau đó lọc ra những máy dưới 20 triệu. Nếu cả 10 máy đều trên 20 triệu, bạn nhận về danh sách rỗng).

Nếu bạn lọc dữ liệu trước khi tìm kiếm vector (Pre-filtering) thông qua quét tuần tự: Tốc độ sẽ rất chậm đối với tập dữ liệu lớn.

Qdrant giải quyết bài toán này bằng Filterable HNSW. Trong quá trình build chỉ mục đồ thị HNSW, Qdrant xây dựng các liên kết bổ sung (subgraphs) dựa trên các giá trị Payload. Khi thực hiện lọc, thuật toán duyệt đồ thị sẽ chỉ đi qua các node thỏa mãn điều kiện lọc, duy trì độ chính xác (Recall) gần như tuyệt đối và tốc độ truy vấn ở mức mili-giây.

graph TD
    A[Đồ thị HNSW toàn bộ sản phẩm] -- Áp dụng bộ lọc cứng Store_ID = 5 --> B[HNSW Subgraph<br/>Chỉ liên kết các máy thuộc Store 5]
    B -- Duyệt Vector --> C[Kết quả chính xác]

Triển khai tạo Payload Index trong Golang

Để Filterable HNSW hoạt động hiệu quả nhất, bạn bắt buộc phải tạo Field Index (Payload Index) cho các thuộc tính thường xuyên xuất hiện trong mệnh đề Filter (như danh mục sản phẩm, trạng thái tồn kho, và khoảng giá). Nếu không có Payload Index, Qdrant sẽ phải thực hiện quét tuần tự (Full-Scan) để tìm kiếm các điểm dữ liệu thỏa mãn bộ lọc trước khi đi vào đồ thị.

Dưới đây là cách viết code khởi tạo Payload Index bằng Go Client:

// CreatePayloadIndexes cấu hình các chỉ mục tìm kiếm cứng cho e-commerce
func (q *QdrantSearchClient) CreatePayloadIndexes(ctx context.Context) error {
	// 1. Tạo index dạng Keyword cho store_id (dùng để lọc phân mảnh multi-tenant)
	_, err := q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
		CollectionName: q.collectionName,
		FieldName:      "store_id",
		FieldType:      qdrant.PayloadSchemaType_Keyword.Enum(),
		Wait:           qdrant.PtrBool(true),
	})
	if err != nil {
		return fmt.Errorf("failed to create store_id index: %w", err)
	}

	// 2. Tạo index dạng Keyword cho category_id
	_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
		CollectionName: q.collectionName,
		FieldName:      "category_id",
		FieldType:      qdrant.PayloadSchemaType_Keyword.Enum(),
		Wait:           qdrant.PtrBool(true),
	})
	if err != nil {
		return fmt.Errorf("failed to create category_id index: %w", err)
	}

	// 3. Tạo index dạng Float/Integer cho price (để phục vụ bộ lọc so sánh lớn hơn/nhỏ hơn)
	_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
		CollectionName: q.collectionName,
		FieldName:      "price",
		FieldType:      qdrant.PayloadSchemaType_Float.Enum(),
		Wait:           qdrant.PtrBool(true),
	})
	if err != nil {
		return fmt.Errorf("failed to create price index: %w", err)
	}

	// 4. Tạo index dạng Bool cho in_stock (lọc các sản phẩm còn hàng)
	_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
		CollectionName: q.collectionName,
		FieldName:      "in_stock",
		FieldType:      qdrant.PayloadSchemaType_Bool.Enum(),
		Wait:           qdrant.PtrBool(true),
	})
	if err != nil {
		return fmt.Errorf("failed to create in_stock index: %w", err)
	}

	return nil
}

Tóm Tắt & Bài Học Rút Ra từ Phần 3

  1. Đừng chọn một trong hai, hãy chọn cả hai: E-commerce đòi hỏi sự kết hợp giữa hiểu ngữ nghĩa (Dense vector) và đối khớp từ khóa chính xác / mã SKU (Sparse vector).
  2. RRF là tiêu chuẩn vàng: Sử dụng Reciprocal Rank Fusion để kết hợp hai không gian điểm số khác nhau mà không sợ sai lệch tỷ lệ.
  3. Tận dụng Prefetch: Sử dụng API Prefetch của Qdrant giúp thực hiện Hybrid Search song song trên server chỉ với 1 lượt khứ hồi mạng (Single Network Roundtrip).
  4. Bắt buộc tạo Field Index: Đồ thị Filterable HNSW của Qdrant chỉ phát huy hiệu năng tối đa khi các trường thuộc tính lọc cứng được đánh chỉ mục Payload đầy đủ (Keyword, Float, Bool).

Hệ thống Vector Database và Hybrid Search của chúng ta giờ đây đã có khả năng tìm kiếm cực kỳ chính xác. Nhưng làm thế nào để biến kết quả tìm kiếm thô này thành một trải nghiệm chat thông minh và tương tác trực tiếp? Làm sao để LLM hiểu được lúc nào cần gọi API tìm kiếm, lúc nào cần kiểm tra khuyến mãi thời gian thực?

Trong Phần 4: Active RAG & Strict Tool Calling: Kết Nối LLM Với Real-time Inventory API, chúng ta sẽ tiến hành lập trình điều phối AI Agent bằng framework Eino (CloudWeGo) để kết nối LLM trực tiếp với các Go microservices thông qua cơ chế Function Calling an toàn kiểu dữ liệu.