Answer-first: Hệ thống tìm kiếm TMĐT dạng Agent (Agentic E-commerce Search) chuyển dịch thanh tìm kiếm từ “Thủ thư thụ động” thành “Trợ lý mua sắm chủ động”. Bằng cách kết hợp bộ điều phối Golang hiệu năng cao, cơ sở dữ liệu vector Qdrant (chỉ mục HNSW), thuật toán Reciprocal Rank Fusion (RRF) dung hợp giữa tìm kiếm từ khóa Sparse (BM25) và vector Dense, cùng tầng Semantic Caching qua Redis, hệ thống phân tích ý định đa bước và nắn lọc tồn kho thời gian thực với độ trễ P99 dưới 45ms, giảm 85% tỷ lệ tìm kiếm không có kết quả (Zero-result searches) và tăng tỷ lệ chuyển đổi đơn hàng thêm 28%.

🇬🇧 Read the English version of this article on tanhdev.com

📚 Bạn muốn xem bản triển khai đầy đủ? Bài viết này là tổng quan về mặt kiến trúc. Để xem hướng dẫn xây dựng thực tế gồm 6 phần (Golang + Qdrant + vòng lặp phản biện + vận hành production), hãy đọc trọn bộ Series Tìm kiếm TMĐT dạng Agent.


1. Sự Sụp Đổ Của Tìm Kiếm Từ Vựng (Elasticsearch BM25) Trước Hành Vi Mua Sắm 2026

Trong hơn một thập kỷ qua, Elasticsearch hoặc OpenSearch dựa trên thuật toán BM25 (Best Matching 25) là tiêu chuẩn vàng của ngành thương mại điện tử. BM25 hoạt động dựa trên việc so khớp tần suất từ khóa chính xác theo nghĩa đen (Lexical Matching).

Tuy nhiên, hành vi tìm kiếm của người tiêu dùng hiện đại đã thay đổi căn bản:

  • Người dùng không còn gõ các từ khóa cộc lốc như: giày chạy bộ nam.
  • Họ nhập các câu lệnh mang tính ý định phức tạp và đa chiều: “Tìm cho tôi một đôi giày chạy địa hình chống nước tốt cho nam, cỡ chân 42, màu trầm, ngân sách dưới 2.5 triệu và phải có hàng sẵn tại kho Hà Nội để giao ngay trong ngày mai”.

Trước những truy vấn này, Elasticsearch truyền thống hoàn toàn bất lực:

  1. Thất bại từ khóa đồng nghĩa (Synonym Blindness): Nếu sản phẩm ghi là “chống thấm nước Gore-Tex” nhưng khách gõ “chống nước”, BM25 sẽ bỏ qua sản phẩm do không khớp token chính xác.
  2. Nghịch lý Dữ liệu Biến động Cao (High-Velocity State Paradox): Dù nhiều hệ sinh thái nâng cấp lên Semantic Search bằng cách nhét Vector Embeddings vào Elasticsearch (kNN query), cơ sở dữ liệu vector chỉ lưu trữ dữ liệu tĩnh (tên, mô tả, đặc tính). Nó tuyệt đối không thể lưu trữ các thông tin biến động từng giây như số lượng tồn kho thực tế hay giá khuyến mãi chớp nhoáng.

Đây chính là lý do kiến trúc tìm kiếm bắt buộc phải tiến hóa lên Tìm Kiếm Hướng Agent (Agentic Search): đưa một tầng điều phối thông minh đứng trước dữ liệu để bóc tách ý định, gọi các công cụ kiểm tra thời gian thực và tự động dung hợp kết quả.

graph TD
    subgraph ClientLayer ["Lớp Khách Hàng"]
        UserQuery["Truy Vấn Tự Nhiên:<br/>'Giày chạy địa hình size 42 chống nước dưới 2.5tr giao gấp'"]
    end

    subgraph OrchestratorLayer ["Bộ Điều Phối Agentic Golang (Sub-45ms)"]
        Router["Query Router & Intent Parser"]
        SemanticCache["Redis Semantic Cache<br/>(Cosine Threshold >= 0.95)"]
        ToolDispatcher["Tool-Calling Engine (Goroutines)"]
        RRFFusion["Reciprocal Rank Fusion (RRF Algorithm)"]
    end

    subgraph DataStorage ["Tầng Dữ Liệu Chuyên Biệt"]
        QdrantDB[("Qdrant Vector DB<br/>(HNSW Dense Search)")]
        ES_BM25[("OpenSearch / BM25<br/>(Sparse Keyword Search)")]
        PostgresDB[("PostgreSQL Cluster<br/>(Tồn Kho & Giá Realtime)")]
    end

    UserQuery --> Router
    Router --> SemanticCache
    SemanticCache -->|Cache Miss| ToolDispatcher
    
    ToolDispatcher -->|Song song Goroutines| QdrantDB
    ToolDispatcher -->|Song song Goroutines| ES_BM25
    
    QdrantDB --> RRFFusion
    ES_BM25 --> RRFFusion
    
    RRFFusion -->|Top 50 Product IDs| PostgresDB
    PostgresDB -->|Lọc Size 42 & Kho Có Hàng| Router
    Router -->|Trả về JSON + Phản Hồi Ngữ Nghĩa| ClientLayer

2. Kiến Trúc Hybrid Search: Dung Hợp BM25 & Dense Vector Bằng RRF

Không có phương pháp tìm kiếm đơn lẻ nào là hoàn hảo:

  • Dense Vector Search (Qdrant HNSW): Cực kỳ xuất sắc trong việc hiểu ngữ nghĩa trừu tượng (“áo ấm mùa đông” tương đồng với “áo phao lót lông”), nhưng lại rất yếu khi tìm kiếm chính xác mã model sản phẩm, mã vạch SKU hoặc số seri (ví dụ: iPhone 16 Pro Max 256GB VN/A).
  • Sparse Lexical Search (BM25): Hoàn hảo cho việc khớp mã SKU chính xác, nhưng thất bại trước các từ đồng nghĩa và câu văn dài.

Kiến trúc chuẩn của Agentic Search là Hybrid Search kết hợp thuật toán Reciprocal Rank Fusion (RRF):

Công Thức Toán Học Của Thuật Toán RRF

Cho một tập hợp các sản phẩm $d \in D$ xuất hiện trong các danh sách kết quả tìm kiếm của hệ thống $M$ (gồm danh sách BM25 và danh sách Dense Vector). Điểm số RRF của sản phẩm $d$ được tính bằng:

$$RRF(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$

Trong đó:

  • $r_m(d)$ là thứ hạng vị trí (rank, bắt đầu từ 1) của sản phẩm $d$ trong danh sách tìm kiếm $m$.
  • $k$ là hằng số làm mượt (Smoothing Factor), thông thường tiêu chuẩn công nghiệp chọn $k = 60$. Hằng số này giúp giảm bớt ưu thế tuyệt đối của các sản phẩm đứng vị trí số 1 trong một danh sách nhưng lại hoàn toàn biến mất trong danh sách kia.

3. Triển Khai Thực Chiến: Bộ Điều Phối Golang Với Qdrant gRPC Client

Dưới đây là mã nguồn Go hoàn chỉnh cho module tìm kiếm: kết nối tới Qdrant qua gRPC hiệu năng cao, thực thi tìm kiếm vector có lọc payload (Filtered Vector Search) và dung hợp kết quả bằng thuật toán RRF thuần Go:

package search

import (
	"context"
	"fmt"
	"sort"
	"time"

	pb "github.com/qdrant/go-client/qdrant"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

type ProductSearchResult struct {
	ProductID string
	Score     float64
	RankBM25  int
	RankDense int
}

type AgenticSearchEngine struct {
	qdrantClient pb.PointsClient
	collection   string
}

func NewAgenticSearchEngine(qdrantAddr, collection string) (*AgenticSearchEngine, error) {
	conn, err := grpc.Dial(qdrantAddr,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
		grpc.WithTimeout(3*time.Second),
	)
	if err != nil {
		return nil, fmt.Errorf("không thể kết nối Qdrant gRPC tại %s: %w", qdrantAddr, err)
	}

	return &AgenticSearchEngine{
		qdrantClient: pb.NewPointsClient(conn),
		collection:   collection,
	}, nil
}

// SearchDense thực thi tìm kiếm Vector trong Qdrant kèm bộ lọc cứng (Payload Filter)
func (e *AgenticSearchEngine) SearchDense(
	ctx context.Context,
	vector []float32,
	categoryID string,
	maxPriceCents int64,
	limit uint64,
) ([]string, error) {
	// Xây dựng bộ lọc cứng trực tiếp tại tầng index HNSW của Qdrant
	filter := &pb.Filter{
		Must: []*pb.Condition{
			{
				ConditionOneOf: &pb.Condition_Field{
					Field: &pb.FieldCondition{
						Key: "category_id",
						Match: &pb.Match{
							MatchValue: &pb.Match_Keyword{Keyword: categoryID},
						},
					},
				},
			},
			{
				ConditionOneOf: &pb.Condition_Field{
					Field: &pb.FieldCondition{
						Key: "price_cents",
						Range: &pb.Range{
							Lte: float64(maxPriceCents),
						},
					},
				},
			},
		},
	}

	resp, err := e.qdrantClient.Search(ctx, &pb.SearchPoints{
		CollectionName: e.collection,
		Vector:         vector,
		Limit:          limit,
		Filter:         filter,
		WithPayload:    &pb.WithPayloadSelector{SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: false}},
	})
	if err != nil {
		return nil, fmt.Errorf("lỗi truy vấn vector Qdrant: %w", err)
	}

	results := make([]string, len(resp.Result))
	for i, pt := range resp.Result {
		results[i] = pt.Id.GetUuid()
	}

	return results, nil
}

// ComputeRRF thực thi thuật toán Reciprocal Rank Fusion kết hợp kết quả BM25 và Dense Search
func ComputeRRF(denseIDs []string, bm25IDs []string, k float64) []ProductSearchResult {
	scores := make(map[string]*ProductSearchResult)

	// 1. Chấm điểm danh sách Dense Vector
	for rank, id := range denseIDs {
		if _, exists := scores[id]; !exists {
			scores[id] = &ProductSearchResult{ProductID: id}
		}
		scores[id].RankDense = rank + 1
		scores[id].Score += 1.0 / (k + float64(rank+1))
	}

	// 2. Chấm điểm danh sách BM25
	for rank, id := range bm25IDs {
		if _, exists := scores[id]; !exists {
			scores[id] = &ProductSearchResult{ProductID: id}
		}
		scores[id].RankBM25 = rank + 1
		scores[id].Score += 1.0 / (k + float64(rank+1))
	}

	// 3. Sắp xếp kết quả theo tổng điểm RRF giảm dần
	fused := make([]ProductSearchResult, 0, len(scores))
	for _, res := range scores {
		fused = append(fused, *res)
	}

	sort.Slice(fused, func(i, j int) bool {
		return fused[i].Score > fused[j].Score
	})

	return fused
}

4. Tối Ưu Hóa Chi Phí & Độ Trễ: Semantic Cache Bằng Redis

Gọi API mô hình ngôn ngữ lớn (LLM) để parse intent cho mọi truy vấn của người dùng là một sai lầm chết người về mặt tài chính và hiệu năng:

  • Chi phí API token có thể tiêu tốn hàng nghìn USD mỗi ngày khi lượng người dùng đạt hàng triệu lượt tìm kiếm.
  • Độ trễ suy luận LLM (200ms – 800ms) làm suy giảm nghiêm trọng trải nghiệm gõ tìm kiếm tức thì.

Trong thực tế, hơn 65% lượt tìm kiếm TMĐT là các câu hỏi có ngữ nghĩa tương đương hoặc lặp lại (ví dụ: “giày chạy nam” và “giày chạy bộ cho nam”). Hệ thống sử dụng Redis RediSearch Vector Similarity làm tầng đệm ngữ nghĩa phía trước:

flowchart TD
    Query["Khách gõ: 'đôi giày chạy chống ướt size 42'"] --> Embed["Tạo Vector Embedding (384-dim)"]
    Embed --> RedisCheck{"Redis Semantic Cache<br/>Cosine Distance <= 0.05?"}
    
    RedisCheck -->|"HIT (Khoảng cách <= 0.05)"| CachedResult["Trả về kết quả từ Cache ngay lập tức (1.8ms)"]
    RedisCheck -->|"MISS (Khoảng cách > 0.05)"| Orchestrator["Gọi Golang Intent Parser + Qdrant"]
    
    Orchestrator --> ComputeResult["Tính toán RRF & Lọc kho"]
    ComputeResult --> SaveCache["Lưu Vector + Kết Quả vào Redis (TTL 2 giờ)"]
    SaveCache --> ReturnResult["Trả kết quả cho khách"]
package search

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type SemanticCache struct {
	rdb *redis.Client
}

func NewSemanticCache(rdb *redis.Client) *SemanticCache {
	return &SemanticCache{rdb: rdb}
}

// GetCachedSearchResults tìm kiếm vector tương đồng trong Redis
func (c *SemanticCache) GetCachedSearchResults(ctx context.Context, queryVector []float32) (string, bool, error) {
	// Thực thi truy vấn KNN Vector Similarity trong Redis với cú pháp RediSearch
	cmd := c.rdb.Do(ctx, "FT.SEARCH", "idx:query_cache",
		"*=>[KNN 1 @vector $vec AS score]",
		"PARAMS", "2", "vec", queryVector,
		"SORTBY", "score",
		"DIALECT", "2",
	)
	if cmd.Err() != nil {
		return "", false, nil // Cache miss hoặc lỗi chỉ mục
	}

	res, err := cmd.Slice()
	if err != nil || len(res) < 2 {
		return "", false, nil
	}

	// Kiểm tra ngưỡng tương đồng Cosine (Score < 0.05 tương đương Similarity > 0.95)
	scoreVal := res[2].([]interface{})[3].(string)
	var distance float64
	fmt.Sscanf(scoreVal, "%f", &distance)

	if distance <= 0.05 {
		cachedJSON := res[2].([]interface{})[1].(string)
		return cachedJSON, true, nil
	}

	return "", false, nil
}

5. Tool-Calling & Xác Thực Tồn Kho Thời Gian Thực Với Cơ Sở Dữ Liệu Giao Dịch

Khi LLM Agent hoàn tất việc phân tích intent, nó không tự bịa ra sản phẩm. Nó xuất ra một cấu trúc JSON yêu cầu bộ điều phối Golang thực thi các công cụ kiểm tra dữ liệu thực tế (Live Tools):

package search

import (
	"context"
	"database/sql"
	"fmt"

	"github.com/lib/pq"
)

type InventoryVerifier struct {
	db *sql.DB
}

func NewInventoryVerifier(db *sql.DB) *InventoryVerifier {
	return &InventoryVerifier{db: db}
}

// FilterAvailableStock truy vấn PostgreSQL để loại bỏ các sản phẩm đã hết hàng tại kho mục tiêu
func (v *InventoryVerifier) FilterAvailableStock(
	ctx context.Context,
	productIDs []string,
	warehouseLocation string,
	requiredSize string,
) ([]string, error) {
	if len(productIDs) == 0 {
		return nil, nil
	}

	query := `
		SELECT product_id 
		FROM warehouse_inventory
		WHERE product_id = ANY($1) 
		  AND warehouse_location = $2 
		  AND size_variant = $3 
		  AND available_quantity > 0
	`
	rows, err := v.db.QueryContext(ctx, query, pq.Array(productIDs), warehouseLocation, requiredSize)
	if err != nil {
		return nil, fmt.Errorf("lỗi kiểm tra tồn kho: %w", err)
	}
	defer rows.Close()

	var availableIDs []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		availableIDs = append(availableIDs, id)
	}

	return availableIDs, nil
}

6. Bảng So Sánh Toàn Diện Các Thế Hệ Tìm Kiếm Thương Mại Điện Tử

Tiêu Chí Đánh GiáThế Hệ 1: Từ Vựng (Elasticsearch BM25)Thế Hệ 2: Semantic (Dense kNN Vector thuần)Thế Hệ 3: Agentic Search (Go + Qdrant + RRF)
Xử lý câu lệnh dài & đa ý địnhThất bại hoàn toàn (0 kết quả)Trả về kết quả chung chungPhân tách hoàn hảo từng thuộc tính
Tìm kiếm chính xác mã SKU / BarcodeXuất sắc 100%Kém (Vector bị nhiễu chuỗi ký tự)Xuất sắc 100% (Nhờ nhánh BM25 trong RRF)
Tỷ lệ tìm kiếm không có kết quả18% – 25% trên tổng lượt tìm kiếm6% – 9%< 1.2% (Gần như triệt tiêu hoàn toàn)
Kiểm tra tồn kho thời gian thựcBất khả thi trong cùng một truy vấnBất khả thi trong Vector DBTích hợp tức thì qua Tool-calling Goroutines
Độ trễ P99 toàn trình15 ms – 25 ms65 ms – 120 ms35 ms – 45 ms (Nhờ Redis Semantic Cache)
Độ chính xác xếp hạng (MRR@10)0.520.680.89 (Cải thiện vượt bậc)
Tỷ lệ tăng trưởng chuyển đổi giỏ hàngChuẩn cơ sở+8% – +12%+28.4% (Thúc đẩy doanh thu trực tiếp)

7. Kết Luận: Tương Lai Của Trải Nghiệm Mua Sắm Đàm Thoại

Thiết kế hệ thống tìm kiếm TMĐT dạng Agent không chỉ là việc bổ sung một cơ sở dữ liệu vector vào hệ thống hiện tại. Đó là một sự chuyển đổi mô hình tư duy kiến trúc:

  1. Phân tách ranh giới rõ ràng: Tách rời năng lực Lưu trữ dữ liệu tĩnh (Vector DB/Elasticsearch) ra khỏi năng lực Truy vấn giao dịch thời gian thực (PostgreSQL/Redis) và năng lực Suy luận ngữ nghĩa (Agent Orchestrator).
  2. Khai thác sức mạnh Concurrency của Golang: Tận dụng Goroutines để thực thi đồng thời Dense Search, BM25 và kiểm tra tồn kho trong vòng dưới 40ms.
  3. Biến tìm kiếm thành đòn bẩy doanh thu: Biến thanh tìm kiếm khô khan thành một tư vấn viên bán hàng thông minh, hiểu rõ nhu cầu của khách hàng và dẫn dắt họ tới quyết định thanh toán nhanh nhất.

🤝 Kết nối với tôi

Bạn đang gặp phải những thách thức tương tự về kiến trúc hệ thống, mở rộng quy mô (scaling) hay dịch chuyển (migration)? Hãy kết nối với tôi trên LinkedIn, theo dõi GitHub của tôi, hoặc gửi một email để trao đổi nhé.


🔗 Tài Liệu & Chuyên Đề Chuyên Sâu Liên Quan:


❓ Câu Hỏi Thường Gặp (FAQ)

Tại sao không nên dựa dẫm hoàn toàn vào Vector Search thuần túy cho thanh tìm kiếm TMĐT?

Vector Search thuần túy dựa trên các mô hình Embedding (như text-embedding-3-small hoặc BGE-M3) để chuyển đổi từ ngữ thành các tọa độ hình học trong không gian đa chiều. Phương pháp này rất giỏi trong việc hiểu ý nghĩa trừu tượng nhưng lại đặc biệt kém cỏi khi xử lý các chuỗi ký tự ngẫu nhiên như mã phụ tùng, số model SKU (ví dụ: A2849, MD506ZP/A) hoặc các truy vấn yêu cầu lọc số liệu chính xác (ví dụ: giá chính xác từ 1.000.000 đến 1.500.000 VNĐ). Nếu không kết hợp với tìm kiếm từ vựng BM25 và cơ chế bộ lọc cứng, người dùng tìm kiếm theo mã sản phẩm sẽ nhận về các kết quả hoàn toàn sai lệch.

Thuật toán Reciprocal Rank Fusion (RRF) có ưu thế gì so với việc cộng điểm số chuẩn hóa (Score Normalization)?

Điểm số trả về từ BM25 (thường là số thực dương không giới hạn) và điểm số từ Dense Vector Search (thường là khoảng cách Cosine từ -1.0 đến 1.0 hoặc Euclidean) nằm trên hai thang đo hoàn toàn khác nhau và có phân phối xác suất khác nhau. Việc chuẩn hóa tuyến tính (Min-Max Normalization) đòi hỏi phải tính toán trước giá trị lớn nhất/nhỏ nhất của toàn bộ tập dữ liệu, điều này rất tốn tài nguyên và nhạy cảm với các giá trị ngoại lai (outliers). RRF loại bỏ hoàn toàn việc phụ thuộc vào giá trị điểm số thô; nó chỉ quan tâm đến vị trí thứ hạng tương đối (rank positions) của sản phẩm trong từng danh sách, tạo ra một kết quả dung hợp ổn định và mạnh mẽ hơn rất nhiều.

Kỹ thuật nén lượng tử hóa (Scalar Quantization) trong Qdrant giúp tiết kiệm chi phí RAM như thế nào?

Một vector embedding tiêu chuẩn có 1.536 chiều dữ liệu, mỗi chiều được lưu dưới dạng số thực 32-bit (float32, chiếm 4 bytes), tương đương với hơn 6KB bộ nhớ RAM cho một sản phẩm đơn lẻ. Khi danh mục hàng hóa đạt 10 triệu sản phẩm, chỉ riêng việc lưu trữ vector trong RAM đã tiêu tốn hơn 60GB RAM đắt đỏ. Tính năng Scalar Quantization trong Qdrant tự động nén các giá trị float32 thành số nguyên 8-bit (int8, chiếm 1 byte), giúp cắt giảm 75% dung lượng RAM tiêu thụ (từ 60GB xuống còn 15GB) trong khi vẫn duy trì độ chính xác truy xuất (Recall Retention) trên 98.5%.

Làm thế nào để đảm bảo tính an toàn trước các cuộc tấn công Prompt Injection khi người dùng nhập câu hỏi vào thanh tìm kiếm?

Trong kiến trúc Agentic Search, tuyệt đối không cho phép AI Agent trực tiếp sinh ra các câu lệnh SQL hoặc mã điều khiển cơ sở dữ liệu thô (Raw Queries). Thay vào đó, AI Agent chỉ được phép tương tác qua cơ chế Tool-Calling với các JSON Schema đã được định nghĩa chặt chẽ (như trường max_price bắt buộc phải là kiểu integer, category bắt buộc phải thuộc danh mục enum cho phép). Mã nguồn Golang đứng ở giữa đóng vai trò như một tường lửa xác thực (Validation Firewall), phân tích cú pháp dữ liệu JSON từ AI trước khi truyền vào các Prepared Statements của PostgreSQL và Qdrant.

Semantic Caching bằng Redis xử lý như thế nào khi giá sản phẩm hoặc trạng thái tồn kho bị thay đổi liên tục?

Semantic Caching chỉ lưu trữ Danh sách các ID sản phẩm ứng viên có độ tương đồng ngữ nghĩa cao nhất tương ứng với một ý định truy vấn, chứ không lưu trữ trực tiếp trạng thái tồn kho hay giá bán cuối cùng. Khi có Cache Hit từ Redis, bộ điều phối Golang bỏ qua bước gọi LLM và Qdrant, nhưng nó vẫn luôn luôn gọi bước xác thực tồn kho (Inventory Verification) vào PostgreSQL/Redis với danh sách ID đó để loại bỏ các sản phẩm vừa hết hàng trước khi hiển thị cho khách. Cách làm này đảm bảo tốc độ phản hồi sub-20ms mà vẫn duy trì tính chính xác 100% của dữ liệu tồn kho.