Gần đây tôi nghịch tính năng AI Search của Cloudflare. Màn hình tạo instance có một bước “Review your configuration” liệt kê một loạt knob: Chunk size, Chunk overlap, Hybrid search, Fusion method (Reciprocal Rank Fusion), Keyword match mode, Keyword tokenizer, Query rewriting, Reranking, Score threshold, Similarity cache. Mỗi dòng là một quyết định thiết kế của cả một search engine, gói gọn thành một toggle.

Tôi đang xây một knowledge-management API cho AI agent, và phần lõi của nó cũng là một search engine kiểu này. Nên bài viết này tôi đi ngược lại: lấy từng keyword trên màn hình config làm cửa vào, giải thích nó là gì, rồi map sang cách một backend thật triển khai cơ chế xếp hạng đứng sau.

Bức tranh lớn: search là retrieve rồi rank

Một câu query đi vào, một danh sách kết quả đi ra theo thứ tự. Toàn bộ độ khó nằm ở chữ “thứ tự”. Tách ra, search gồm hai pha:

  1. Retrieve: từ hàng nghìn document, lọc ra một tập ứng viên có khả năng liên quan.
  2. Rank: sắp tập ứng viên đó theo độ liên quan, cắt lấy top N.

Gần như mọi keyword trong màn hình config đều rơi vào một trong hai pha này, cộng thêm vài lớp tối ưu bọc ngoài. Ta đi từ chuẩn bị dữ liệu, tới hai nhánh tìm kiếm, tới cách trộn chúng, rồi tới các lớp tinh chỉnh trên cùng.

Chuẩn bị dữ liệu: chunking và embedding

Trước khi search được, tài liệu phải được cắt nhỏ và mã hoá.

Chunk size và chunk overlap. Một tài liệu dài không thể đem nguyên đi so khớp ngữ nghĩa: vector của cả trang sẽ “trung bình hoá” mọi chủ đề thành một đám mờ. Nên ta cắt tài liệu thành các khúc (chunk). Cloudflare mặc định 1024 token mỗi chunk, chồng lấn (overlap) 10% với khúc trước. Overlap để một câu nằm vắt ngang ranh giới cắt không bị mất ngữ cảnh ở cả hai khúc.

Đây là cách cắt theo kích thước cố định. Có một hướng khác: cắt theo cấu trúc. Trong hệ thống tôi đang làm, dữ liệu phần lớn là markdown, nên tôi cắt theo heading và paragraph thay vì đếm token. Mỗi chunk mang theo “đường dẫn heading” của nó (ví dụ “Setup > Install”). Với tài liệu có cấu trúc rõ, cắt theo heading giữ được mạch ý tốt hơn cắt cứng theo token, đổi lại kích thước chunk không đều. Không có lựa chọn nào đúng tuyệt đối: fixed-size dễ kiểm soát chi phí embed, structure-aware giữ ngữ nghĩa tốt hơn.

Embedding. Mỗi chunk được một model embedding biến thành một vector số (ví dụ 768 chiều). Hai đoạn văn nói cùng một ý sẽ có hai vector gần nhau trong không gian đó, kể cả khi không trùng một từ nào. Đây là nền của semantic search. Cloudflare để bạn chọn embedding model; một backend tự host thì cắm provider (Gemini, OpenAI, Voyage, Jina, hoặc Workers AI của chính Cloudflare) rồi lưu vector vào một vector database. Tôi lưu trong pgvector với index HNSW theo cosine distance.

Hai nhánh tìm: keyword và vector

Đây là tâm điểm của chữ Hybrid search. “Hybrid” nghĩa là chạy song song hai kiểu tìm kiếm bản chất khác nhau, rồi trộn kết quả.

Nhánh keyword (lexical). Tìm theo từ khoá, kiểu BM25 hoặc full-text search. Mạnh khi người dùng gõ đúng thuật ngữ, tên riêng, mã lỗi. Yếu khi người dùng diễn đạt khác từ với tài liệu. Vài knob của nhánh này:

  • Keyword tokenizer (Standard with Stemming - Porter). Tokenizer tách câu thành từ; stemming đưa từ về gốc. Thuật toán Porter biến “running”, “ran”, “runs” về cùng gốc “run”, nên gõ “deploy” vẫn khớp được “deployment”. Trong hệ thống tôi dùng Elasticsearch với analyzer gồm lowercase, asciifolding, một bộ synonym, và english_stemmer (tương đương Porter). PostgreSQL tsvector cũng tự stem qua từ điển english.
  • Keyword match mode (AND / OR). AND yêu cầu mọi từ trong query đều xuất hiện trong document: precision cao, recall thấp. OR chỉ cần một từ khớp: recall cao, precision thấp. Cloudflare mặc định AND. Một số backend nghiêng về OR (ví dụ dùng minimum_should_match: 1) để không bỏ sót, rồi để lớp ranking phía sau lo phần sắp xếp. Không có đáp án chung: AND hợp với tra cứu chính xác, OR hợp với khám phá.
  • Fuzziness. Chịu lỗi chính tả: “postgers” vẫn khớp “postgres”. Đây là thứ Cloudflare không phơi ra trên màn hình config nhưng nhiều backend bật ngầm.

Nhánh vector (semantic). Lấy vector của query, tìm các chunk có vector gần nhất theo cosine similarity. Mạnh đúng chỗ keyword yếu: “làm sao đưa app lên production” khớp được tài liệu nói về “deployment”, dù không trùng từ. Yếu ở chỗ ngược lại: tên riêng, mã số, ký hiệu thì vector lại mờ.

Chính vì điểm mạnh của hai nhánh bù trừ cho nhau mà ta chạy cả hai rồi trộn, thay vì chọn một.

Trộn hai nhánh: Reciprocal Rank Fusion

Vấn đề khi trộn: hai nhánh cho điểm theo hai thang khác nhau. BM25 trả về điểm vài chục, cosine trả về số trong khoảng 0 tới 1. Cộng thẳng thì BM25 nuốt chửng cosine. Cần một cách trộn bất biến với thang điểm.

Reciprocal Rank Fusion (RRF) giải đúng việc này: nó bỏ qua điểm số, chỉ nhìn thứ hạng. Với mỗi document, điểm trộn là tổng nghịch đảo thứ hạng của nó ở từng nhánh:

score(d) = 1 / (k + rank_keyword(d)) + 1 / (k + rank_vector(d))

k là hằng số làm mượt, hay dùng 60. Một document đứng cao ở cả hai bảng sẽ ăn điểm từ cả hai số hạng và trồi lên đầu. Vì chỉ dùng rank, RRF không cần chuẩn hoá điểm giữa hai hệ khác đơn vị. Đây là lựa chọn mặc định của Cloudflare, và cũng là cái tôi dùng (cùng công thức, cùng k=60).

Cloudflare còn cho một fusion method khác là Maximum score: lấy điểm cao nhất từ một trong hai nhánh thay vì cộng rank. Đơn giản hơn nhưng dễ bị một nhánh lấn át. RRF thường là điểm khởi đầu an toàn hơn.

Tinh chỉnh thứ hạng: boost và score threshold

Sau khi trộn, ta có một bảng xếp hạng. Hai knob tinh chỉnh phổ biến:

Boost. Cộng điểm theo metadata để nâng những kết quả “đáng tin hơn” theo nghĩa nghiệp vụ, không chỉ theo độ khớp văn bản. Cloudflare cho “Boost by” một field. Một backend có thể boost đa tín hiệu: nâng document có tag trùng từ khoá query, nâng document mới cập nhật (recency), hạ document đã lỗi thời. Boost là chỗ tri thức về domain chen vào công thức ranking.

Score threshold. Cắt phần đuôi điểm thấp. Cloudflare mặc định 0.4: kết quả dưới ngưỡng bị loại. Việc này quan trọng đặc biệt khi kết quả được nhồi vào context của một LLM: thà trả ít mà sạch còn hơn nhồi rác làm loãng. Điểm tinh tế: threshold phải áp lên đúng thang điểm mà thứ tự cuối cùng dùng. Nếu có reranking (xem dưới) thì lọc theo điểm rerank; nếu không thì lọc theo điểm trộn.

Ba lớp trên cùng: query rewriting, reranking, similarity cache

Đây là các lớp tối ưu bọc ngoài pipeline retrieve-rank.

Query rewriting. Trước khi search, một LLM viết lại query của người dùng: sửa lỗi chính tả, mở rộng viết tắt, thêm vài từ đồng nghĩa. “deploymnt cfg k8s” thành “deployment configuration kubernetes”. Mục tiêu là tăng recall ngay từ đầu vào, trước khi retrieve. Đánh đổi: mỗi lần search tốn thêm một lần gọi LLM (độ trễ và chi phí token), nên thường để tuỳ chọn bật/tắt và phải có đường lui về query gốc khi LLM lỗi.

Reranking. Sau khi lấy top N (ví dụ 30 ứng viên), một model cross-encoder đọc từng cặp (query, document) cùng lúc rồi chấm lại điểm liên quan. Khác với embedding (mã hoá query và document riêng rẽ rồi mới so), cross-encoder nhìn cả hai cùng một lượt nên bắt được sắc thái mà cosine bỏ lỡ. Đắt hơn nhiều, nên chỉ chạy trên top N chứ không trên toàn bộ corpus. Trong hệ thống tôi làm, lớp này dùng đúng cross-encoder của Cloudflare Workers AI (@cf/baai/bge-reranker-base), bật tuỳ chọn, và nếu provider lỗi hay quá hạn mức thì lặng lẽ rơi về thứ tự RRF chứ không làm hỏng cả request.

Similarity cache. Cache câu trả lời, nhưng theo độ tương đồng ngữ nghĩa của query chứ không theo chuỗi ký tự. Một query mới “đủ giống” một query đã cache thì trả luôn kết quả cũ thay vì chạy lại toàn bộ pipeline. “Đủ giống” là một ngưỡng cosine (Cloudflare gọi là cache strictness: strong, moderate, loose). Đây là tối ưu đáng giá nhất về chi phí và độ trễ khi người dùng hay hỏi lại cùng một ý.

Một điểm Cloudflare để mặc định dựa vào TTL (cache sống 48 giờ): cache cũ tự hết hạn. Khi tự build, tôi thấy cần thêm một thứ nữa là invalidation theo ghi dữ liệu: mỗi lần có document mới được tạo hay sửa, tôi tăng một bộ đếm “thế hệ” của tenant đó, và mọi entry cache thuộc thế hệ cũ bị bỏ qua. TTL một mình thì có cửa sổ trả kết quả cũ ngay sau khi vừa thêm dữ liệu mới; invalidation theo ghi đóng cửa sổ đó lại.

Bảng đối chiếu

Gom lại, từng keyword trên màn hình config map sang một khái niệm và một quyết định thiết kế:

Config keywordLà gìĐánh đổi chính
Chunk size / overlapCắt tài liệu để embedTo thì mất chi tiết, nhỏ thì mất ngữ cảnh
Hybrid searchChạy song song keyword + vectorTốn gấp đôi truy vấn, đổi lấy recall tốt hơn
RRF vs Max scoreCách trộn hai bảng xếp hạngRRF cân bằng, Max score đơn giản nhưng dễ lệch
Keyword match AND / ORMọi từ phải khớp, hay chỉ cần mộtPrecision đổi lấy recall
Tokenizer + stemmingĐưa từ về gốc khi so khớpKhớp rộng hơn, nhưng có lúc khớp nhầm
Query rewritingLLM mở rộng query trước khi tìmRecall cao hơn, đổi lấy độ trễ + token
RerankingCross-encoder chấm lại top NChính xác hơn, đắt hơn nhiều
Score thresholdCắt đuôi điểm thấpSạch hơn, rủi ro cắt nhầm kết quả tốt
Similarity cacheTrả lại kết quả cho query giốngNhanh và rẻ, rủi ro trả đồ cũ

Đúc kết

Điều tôi rút ra khi đối chiếu màn hình config với phần lõi tôi tự viết: gần như mọi knob đều là một đánh đổi precision với recall, hoặc chất lượng với chi phí. Không có cấu hình tối ưu phổ quát. RRF với k=60, score threshold quanh 0.4, reranking bật trên top 30, cache strictness “strong” là các điểm khởi đầu hợp lý mà cả Cloudflare lẫn tôi cùng chọn, nhưng con số đúng tuỳ vào dữ liệu và kiểu truy vấn của riêng bạn.

Cái hay của một màn hình config như của Cloudflare AI Search là nó phơi bày các quyết định đó thành lựa chọn tường minh thay vì chôn trong code. Đọc nó giống như đọc mục lục của một search engine: mỗi dòng là một chương về một bài toán đã được nghiên cứu kỹ. Tự build lại từng lớp khiến tôi hiểu vì sao từng knob tồn tại, và đó là lý do tôi thấy việc bóc tách config kiểu này đáng giá hơn là chỉ bấm toggle.