Tại sao tôi chuyển từ ChromaDB sang Elasticsearch và những điều tôi nhớ về ChromaDB
Hệ thống AI của tôi gặp khó khăn trong việc truy xuất dữ liệu vì cần kết hợp tìm kiếm theo ngữ nghĩa và tìm kiếm từ khóa chính xác. Việc chuyển đổi sang Elasticsearch đã cho phép triển khai Hybrid Search hiệu quả, giải quyết vấn đề mà ChromaDB hay pgvector đơn thuần không làm được. Bài viết chia sẻ chi tiết về quá trình cài đặt, lợi ích của thuật toán RRF và những đánh đổi về hiệu năng.

Hệ thống AI của tôi đã trích xuất kiến thức từ các email trong nhiều tuần. Hàng nghìn sự thật, thực thể và mô hình đều nằm trong PostgreSQL. Vấn đề là làm sao để tìm thấy chúng. "Bộ não" AI đang sử dụng các bộ lọc SQL được viết cứng như WHERE category = 'infrastructure' để kéo ngữ cảnh trước khi ra quyết định. Nếu một sự thật về chi phí lưu trữ được phân loại dưới "billing" (thanh toán), bộ não sẽ không bao giờ thấy nó khi suy luận về cơ sở hạ tầng.
Tôi cần tìm kiếm theo ý nghĩa, không phải theo nhãn. Nhưng tôi cũng cần tìm kiếm theo sự khớp chính xác. Một số hóa đơn như "TDS-2026-003" không có ý nghĩa ngữ nghĩa. Bạn không thể tìm thấy nó bằng tìm kiếm vector. Tôi cần cả hai cách tiếp cận hoạt động cùng nhau trong một truy vấn.
Đó là lý do dẫn tôi đến Elasticsearch, và đó là chủ đề của bài viết này.
Vấn đề của việc chỉ dùng Vector Search
ChromaDB đã có trong file Docker Compose của tôi từ những ngày đầu. Kế hoạch là một đường ống RAG hoàn chỉnh: nhúng tài liệu, lưu trữ vector, truy xuất các đoạn liên quan. Trong thực tế, ChromaDB hầu như không hoạt động trong khi cơ sở kiến thức phát triển nhanh hơn dự kiến. Đến khi tôi quay lại vấn đề tìm kiếm, các yêu cầu đã thay đổi.
Cơ sở kiến thức không chỉ là ngôn ngữ tự nhiên. Nó chứa mã tham chiếu hóa đơn, tên miền, ngày tháng cụ thể và số hợp đồng. Tất cả những thứ này không có ý nghĩa ngữ nghĩa. Khi bạn tìm kiếm vector cho "TDS-2026-003", mô hình nhúng sẽ mã hóa nó thành một không gian 768 chiều và tìm kiếm các vector lân cận. Nhưng một mã tham chiếu tùy ý không có vị trí có ý nghĩa trong không gian đó. Vector đó mơ hồ liên quan đến "hóa đơn" nhưng sẽ không truy xuất được một cách đáng tin cậy tài liệu duy nhất chứa chuỗi chính xác đó.
Đây không phải là vấn đề của ChromaDB. Đó là bản chất của tìm kiếm vector thuần túy. Bất kỳ hệ thống nào chỉ thực hiện so khớp tương đồng sẽ gặp khó khăn với việc tra cứu chính xác.
Yêu cầu buộc phải đưa ra quyết định: bộ não cần tìm thấy "TDS-2026-003" (khớp từ khóa chính xác) VÀ "chi phí lưu trữ" (tương đồng ngữ nghĩa) thông qua cùng một hệ thống tìm kiếm. Lý tưởng nhất là trong cùng một truy vấn.
Tại sao không dùng pgvector
Tôi đã chạy PostgreSQL với hơn 95 bảng. Thêm pgvector có nghĩa là không cần thêm container, không cần thêm dịch vụ để bảo trì. Đó là sự lựa chọn rõ ràng đầu tiên.
Vấn đề là kết hợp kết quả. pgvector cung cấp tương đồng vector. tsvector tích hợp sẵn của PostgreSQL cung cấp tìm kiếm toàn văn bản. Nhưng việc hợp nhất hai tập kết quả thành một danh sách được xếp hạng duy nhất, trong đó tài liệu có điểm số tốt ở cả khớp từ khóa và tương đồng ngữ nghĩa sẽ xếp cao hơn tài liệu chỉ khớp một, đòi hỏi phải xây dựng logic điểm số của riêng bạn. Về cơ bản, bạn đang xây dựng một công cụ tìm kiếm bên trong cơ sở dữ liệu của mình.
Elasticsearch có một thuật toán tích hợp sẵn cho việc này gọi là Reciprocal Rank Fusion (RRF). Nó lấy thứ hạng từ BM25 (khớp từ khóa) và thứ hạng từ kNN (tương đồng vector) và kết hợp chúng về mặt toán học. Một tài liệu xuất hiện gần đầu cả hai danh sách sẽ có điểm số kết hợp cao hơn tài liệu chỉ xuất hiện ở một danh sách. Không cần logic điểm số tùy chỉnh, không cần hợp nhất kết quả thủ công.
Đối với các trường hợp sử dụng đơn giản hơn, nơi bạn chỉ cần tương đồng vector hoặc chỉ cần tìm kiếm từ khóa nhưng không cần cả hai kết hợp với thứ hạng nguyên tắc, pgvector là lựa chọn tốt hơn. Ít cơ sở hạ tầng hơn, ít phức tạp hơn và nó sống trong một cơ sở dữ liệu mà bạn đã chạy.
Quá trình di chuyển
Việc cài đặt Elasticsearch diễn ra khá đơn giản. Một nút đơn, không có cụm, tắt bảo mật (sau Tailscale, không có quyền truy cập công khai):
elasticsearch:
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms4g -Xmx4g
- xpack.ml.enabled=false
Việc tắt các tính năng ML đã tiết kiệm được khoảng 500MB heap. Tôi không sử dụng ML tích hợp của Elasticsearch vì tất cả suy diễn đều chạy qua Ollama.
Thiết kế chỉ mục theo một mô hình mà tôi đã thiết lập trong PostgreSQL: tách biệt các miền dữ liệu. Sự thật, thực thể, mô hình, email, giao dịch và hợp đồng mỗi thứ có một chỉ mục riêng. Mỗi miền có các trường và nhu cầu truy vấn khác nhau. Nhét mọi thứ vào một chỉ mục khổng lồ sẽ đồng nghĩa với việc các trường thưa thớt ở khắp mọi nơi và gây nhầm lẫn về tính liên quan của điểm số.
Mọi chỉ mục đều tuân theo cùng một mẫu trường kết hợp. Văn bản có thể tìm kiếm chính được lưu trữ hai lần: một lần dưới dạng trường text cho khớp từ khóa BM25 và một lần dưới dạng trường dense_vector cho tương đồng kNN:
# Ví dụ: chỉ mục facts
"fact": {"type": "text", "analyzer": "standard"},
"fact_vector": {"type": "dense_vector", "dims": 768,
"index": True, "similarity": "cosine"},
Mô hình nhúng là nomic-embed-text chạy trên Mac mini thông qua Ollama. 768 chiều, hỗ trợ đa ngôn ngữ khá tốt, điều quan trọng đối với một doanh nghiệp nhận email bằng tiếng Hà Lan, tiếng Anh và tiếng Ả Rập. Mỗi đoạn văn bản được cắt ngắn thành 500 ký tự trước khi nhúng, giúp giữ cho thông lượng ổn định và nắm bắt được hầu hết các tín hiệu ngữ nghĩa.
Một điều tôi học được trong quá trình lập chỉ mục: làm phong phú văn bản trước khi nhúng tạo ra sự khác biệt lớn. Chỉ có "Hetzner" tạo ra một vector chung chung. "Hetzner (công ty) cloud hosting hetzner.com" tạo ra một vector hữu ích hơn nhiều, nắm bắt thực thể đó thực sự là gì. Cách tiếp cận tương tự cũng áp dụng cho email (tiêu đề + đoạn thân) và giao dịch (bên đối tác + mô tả).
Hybrid Search trông như thế nào
Hàm cốt lõi kết hợp BM25 và kNN trong một truy vấn Elasticsearch duy nhất:
async def hybrid_search(index, query, k=10, filters=None):
text_field, vector_field = INDEX_FIELDS[index]
query_vector = await embed_text(query)
es_query = {
"size": k,
"query": {
"bool": {
"should": [
{"match": {text_field: query}}
],
}
},
"knn": {
"field": vector_field,
"query_vector": query_vector,
"k": k,
"num_candidates": max(k * 5, 50),
},
"rank": {"rrf": {"window_size": 50, "rank_constant": 20}},
"_source": {"excludes": [vector_field]},
}
# ... thực thi và trả về kết quả
rank.rrf làm phần việc nặng nhọc. Đối với mỗi tài liệu, nó tính điểm số dựa trên vị trí xếp hạng của nó trong cả kết quả từ khóa và kết quả vector. Một tài liệu có xếp hạng cao trong cả hai danh sách sẽ có điểm số kết hợp cao hơn tài liệu chỉ xuất hiện ở một danh sách. rank_constant là 20 kiểm soát trọng số dành cho các kết quả xếp hạng cao so với phần còn lại.
Trong thực tế, điều này có nghĩa là:
- Tìm kiếm "TDS-2026-003": BM25 tìm thấy tài liệu chính xác. Kết quả kNN là những tiếng ồn mơ hồ liên quan đến hóa đơn. RRF đặt chính xác sự khớp vào vị trí số 1.
- Tìm kiếm "chi phí lưu trữ": BM25 có thể tìm thấy các tài liệu có những từ字 đó. kNN tìm thấy "phí thuê máy chủ", "thanh toán VPS hàng tháng", "hóa đơn cơ sở hạ tầng đám mây". Về mặt khái niệm giống hệt nhau, không có từ khóa chung. RRF kết hợp cả hai, mang lại cho bạn kết quả rộng hơn và hữu ích hơn so với bất kỳ cách tiếp cận nào riêng lẻ.
- Tìm kiếm "hóa đơn Hetzner": BM25 bắt được tên chính xác "Hetzner". kNN bắt được các khái niệm liên quan đến hóa đơn lưu trữ. Các tài liệu cụ thể về hóa đơn Hetzner xuất hiện trong cả hai tập kết quả và xếp hạng cao nhất.
Giữ cho mọi thứ đồng bộ
Kiến thức mới liên tục được trích xuất từ các email. Những sự thật mới này cần kết thúc trong Elasticsearch. Thay vì đồng bộ hóa trực tuyến (inline), điều này sẽ thêm độ trễ cho quy trình trích xuất, tôi đã thêm một cột boolean es_indexed vào mỗi bảng với một trigger:
CREATE OR REPLACE FUNCTION fn_mark_es_unindexed()
RETURNS TRIGGER AS $$
BEGIN
NEW.es_indexed := FALSE;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Kích hoạt khi INSERT hoặc UPDATE nội dung, không phải thay đổi metadata
CREATE TRIGGER trg_facts_es_sync
BEFORE INSERT OR UPDATE OF fact, category, confidence
ON memory_facts
FOR EACH ROW EXECUTE FUNCTION fn_mark_es_unindexed();
Một quy trình làm việc (workflow) thăm dò (polls) mỗi vài phút một lần đối với các hàng có es_indexed = FALSE, nhúng chúng, lập chỉ mục chúng vào Elasticsearch và chuyển đổi cờ. Các chỉ mục một phần trên cột boolean làm cho truy vấn "tìm các hàng chưa được lập chỉ mục" trở nên nhanh vì chỉ mục chỉ chứa các hàng thực sự cần đồng bộ.
Nó không phải là thời gian thực, nhưng đối với trường hợp sử dụng của tôi, độ trễ vài phút giữa trích xuất và khả năng tìm kiếm là chấp nhận được.
Những đánh đổi
Tìm kiếm thực sự hoạt động bây giờ. Bộ não có thể tìm thấy ngữ cảnh liên quan bất kể các sự thật được phân loại như thế nào. Khớp chính xác và khớp ngữ nghĩa trong một truy vấn. Lọc có cấu trúc theo danh mục, điểm số tin cậy hoặc phạm vi ngày tháng kết hợp với tìm kiếm văn bản tự do.
Giá phải trả: 4,5GB RAM trên một máy đã chạy 20 container. ChromaDB dùng 53MB. Đó là gấp khoảng 85 lần bộ nhớ. Elasticsearch là một ứng dụng JVM và điều đó thể hiện rõ ràng. Việc phân bổ heap một mình là 4GB và bạn thực sự không thể đi thấp hơn cho việc sử dụng sản xuất.
DSL truy vấn cũng dài dòng hơn đáng kể so với API Python của ChromaDB. ChromaDB là collection.query(query_texts=["hosting"], n_results=10). Tương đương Elasticsearch là JSON lồng nhau mà bạn đã thấy ở trên. Tôi đã bọc nó trong các hàm trợ giúp để phần còn lại của cơ sở mã không phải xử lý nó, nhưng đường cong học tập là có thật.
Tôi có làm lại không? Có, vì tìm kiếm kết hợp đã giải quyết một vấn đề thực tế mà tôi không thể giải quyết bằng cách nào khác. Nhưng tôi sẽ không giới thiệu Elasticsearch cho mọi dự án cần tìm kiếm. Nếu bạn chỉ cần tương đồng vector, ChromaDB hoặc pgvector đơn giản và nhẹ hơn. Elasticsearch xứng đáng với 4GB của nó khi bạn cần khớp từ khóa và tìm kiếm ngữ nghĩa hoạt động cùng nhau trong cùng một truy vấn.
Đây là Phần 3 của series "Một Nhà phát triển, 22 Container". Series này bao gồm việc xây dựng hệ thống quản lý văn phòng AI trên phần cứng tiêu dùng, những lựa chọn, những đánh đổi và những thứ đã bị phá vỡ dọc đường.
Tìm thấy tôi trên GitHub.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
