7 điểm bởi GN⁺ 2025-03-06 | 1 bình luận | Chia sẻ qua WhatsApp

Cách tốt nhất để sử dụng text embedding một cách linh hoạt là Parquet và Polars

  • Text embedding là các vector được tạo ra từ mô hình ngôn ngữ lớn, dùng để biểu diễn từ, câu và tài liệu dưới dạng số liệu
  • Tính đến tháng 2 năm 2025, đã tạo embedding cho tổng cộng 32.254 lá bài "Magic: The Gathering"
  • Nhờ đó có thể phân tích mức độ tương đồng một cách toán học dựa trên thiết kế và thuộc tính cơ chế của các lá bài
  • Có thể trực quan hóa embedding đã tạo bằng cách giảm chiều xuống 2D với UMAP
  • Mô hình embedding được sử dụng là gte-modernbert-base, và quy trình chi tiết được tổng hợp trong kho GitHub
  • Bộ dữ liệu embedding này được cung cấp trên Hugging Face

Xem lại sự cần thiết của vector database

  • Thông thường người ta dùng vector database (faiss, qdrant, Pinecone) để lưu trữ và truy vấn embedding
  • Tuy nhiên, vector database đòi hỏi thiết lập phức tạp, còn dịch vụ đám mây có thể tốn kém
  • Với dữ liệu quy mô nhỏ, ở mức vài chục nghìn bản ghi, vẫn có thể dùng numpy để tìm kiếm tương đồng nhanh mà không cần vector database
  • Dùng phép dot product của numpy có thể tính cosine similarity đơn giản, và với 32.254 embedding thì thời gian trung bình là 1,08ms
def fast_dot_product(query, matrix, k=3):  
    dot_products = query @ matrix.T  
  
    idx = np.argpartition(dot_products, -k)[-k:]  
    idx = idx[np.argsort(dot_products[idx])[::-1]]  
  
    score = dot_products[idx]  
  
    return idx, score  
  • Khi dùng vector database, khả năng bị phụ thuộc vào thư viện hoặc dịch vụ cụ thể là khá lớn
  • Nếu tạo embedding trên máy chủ GPU rồi tải về máy cục bộ, cần có cách lưu trữ và truyền dữ liệu hiệu quả

Cách lưu embedding tệ nhất

  • Tệp CSV
    • Nếu lưu dữ liệu số thực (float32) dưới dạng văn bản thì kích thước có thể tăng hơn 6 lần
    • Ngay cả trong hướng dẫn chính thức của OpenAI cũng chỉ khuyến nghị dùng CSV cho bộ dữ liệu nhỏ
    • Nếu lưu bằng .savetxt() của numpy thì kích thước tệp tăng lên 631.5MB
  • Tệp pickle
    • Có thể lưu và tải nhanh, nhưng tồn tại rủi ro bảo mật và khả năng tương thích phiên bản kém
    • Kích thước tệp là 94.49MB, bằng với kích thước bộ nhớ gốc, nhưng tính di động thấp

Cách lưu không tệ nhưng chưa tối ưu

  • Định dạng .npy của numpy
    • Có thể ngăn lưu dưới dạng pickle bằng thiết lập allow_pickle=False
    • Kích thước tệp và tốc độ giống như cách dùng pickle, nhưng khó lưu kèm metadata riêng cho từng mục
  • Vấn đề của cấu trúc lưu trữ tách rời metadata
    • Nếu lưu bằng mảng numpy (.npy), thông tin lá bài như tên, văn bản... sẽ bị tách khỏi embedding
    • Khi dữ liệu thay đổi, thêm hoặc xóa, sẽ khó khớp metadata với embedding
    • Vector database thì lưu metadata cùng vector và còn cung cấp chức năng lọc

Cách lưu embedding tối ưu: Parquet + polars

Giới thiệu định dạng tệp Parquet

  • Apache Parquet là định dạng lưu trữ dữ liệu dạng cột, cho phép chỉ định rõ kiểu dữ liệu của từng cột
  • Có thể lưu dữ liệu dạng danh sách, tức mảng float32, nên phù hợp để lưu embedding
  • Cung cấp hiệu năng lưu và tải nhanh hơn CSV, đồng thời có thể chỉ tải chọn lọc một phần dữ liệu
  • Có hỗ trợ nén, nhưng dữ liệu embedding có độ trùng lặp thấp nên hiệu quả nén không cao

Sử dụng tệp Parquet trong Python

  • Lưu và tải tệp Parquet bằng pandas:
    df = pd.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • pandas không xử lý hiệu quả dữ liệu lồng nhau như danh sách, và sẽ chuyển thành object của numpy
    • Khi chuyển sang mảng numpy sẽ cần thêm thao tác (np.vstack()), có thể làm giảm hiệu năng
  • Lưu và tải tệp Parquet bằng polars:
    df = pl.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • polars giữ nguyên mảng float32, và khi gọi to_numpy() có thể trả về ngay mảng numpy 2D
    • Có thể tránh sao chép dữ liệu không cần thiết bằng thiết lập allow_copy=False
    embeddings = df["embedding"].to_numpy(allow_copy=False)  
    
  • Khi thêm embedding mới cũng có thể đơn giản thêm cột rồi lưu lại
    df = df.with_columns(embedding=embeddings)  
    df.write_parquet("mtg-embeddings.parquet")  
    

Tìm kiếm tương đồng và lọc với Parquet + polars

  • Có thể lọc trước dữ liệu thỏa điều kiện nhất định rồi mới thực hiện tìm kiếm tương đồng
  • Ví dụ: tìm các lá bài tương tự với một lá bài cụ thể (query_embed), nhưng chỉ trong các lá có kiểu 'Sorcery' và có màu 'Black'
    df_filter = df.filter(  
        pl.col("type").str.contains("Sorcery"),  
        pl.col("manaCost").str.contains("B"),  
    )  
    
    embeddings_filter = df_filter["embedding"].to_numpy(allow_copy=False)  
    idx, _ = fast_dot_product(query_embed, embeddings_filter, k=4)  
    related_cards = df_filter[idx]  
    
  • Thời gian chạy trung bình là 1,48ms, chậm hơn 37% so với tìm trên toàn bộ dữ liệu nhưng vẫn đủ nhanh

Phương án thay thế cho xử lý dữ liệu vector quy mô lớn

  • Cách dùng Parquet và dot product có thể xử lý tốt tới vài trăm nghìn embedding
  • Nếu xử lý bộ dữ liệu lớn hơn, có thể sẽ cần dùng vector database
  • Một lựa chọn khác là dùng sqlite-vec dựa trên SQLite để bổ sung khả năng tìm kiếm vector và lọc

Kết luận

  • Vector database không phải lúc nào cũng là bắt buộc
  • Tổ hợp Parquet + polars là một phương án thay thế mạnh mẽ để lưu trữ, tìm kiếm và lọc embedding hiệu quả
  • Đặc biệt với các dự án quy mô nhỏ, tận dụng tệp Parquet sẽ nhanh hơn và hiệu quả chi phí hơn
  • Tùy theo dự án, điều quan trọng là chọn giải pháp phù hợp giữa Parquet và vector database
  • Có thể xem mã nguồn và dữ liệu trong kho GitHub

1 bình luận

 
GN⁺ 2025-03-06
Ý kiến trên Hacker News
  • Vấn đề của Parquet là nó có tính tĩnh. Nó không phù hợp nếu cần ghi và cập nhật liên tục. Tuy nhiên, tôi đã có kết quả tốt khi dùng các tệp Parquet với DuckDB và object storage. Thời gian tải rất nhanh

    • Nếu tự host mô hình embedding, bạn có thể truyền mảng nén numpy float32 dưới dạng byte rồi giải mã lại thành mảng numpy
    • Cá nhân tôi thích dùng SQLite với phần mở rộng usearch hơn. Tôi dùng vector nhị phân trước rồi xếp hạng lại top 100 bằng float32. Mất khoảng 2ms cho khoảng 20.000 mục, nhanh hơn LanceDB. Với các bộ sưu tập lớn hơn thì Lance có thể thắng. Nhưng với trường hợp sử dụng của tôi, mỗi người dùng có một tệp SQLite riêng nên nó hoạt động rất tốt
    • Về tính di động thì có Litestream
  • Bài viết thật sự rất hay. Tôi đã thích công việc của bạn từ lâu. Với những ai đang muốn đi sâu vào triển khai bằng SQLite, tôi muốn bổ sung rằng DuckDB đã bắt đầu có một số tính năng tương tự vector để đọc Parquet và xử lý hoàn hảo trường hợp sử dụng này

  • Tôi vẫn không thích dataframe, nhưng Polars tốt hơn pandas rất nhiều

    • Tôi đã làm các phép tính chuỗi thời gian, về cơ bản là một số điều chỉnh giá cổ phiếu đơn giản
    • Thật ngạc nhiên là mã thực sự có thể đọc được và kiểm thử được
    • Tốc độ chạy nhanh đến mức trông như bị lỗi
  • Hãy xem usearch của Unum. Nó đánh bại hầu như mọi thứ và rất dễ dùng. Nó làm đúng chính xác những gì cần thiết

  • Nếu muốn thử, bạn có thể lazy load từ HF và áp dụng bộ lọc

    • Polars rất tuyệt để sử dụng và tôi cực kỳ khuyến nghị. Nó xuất sắc trong việc làm bão hòa CPU trên một node đơn, và nếu cần phân tán công việc thì bạn có thể áp dụng POLARS_MAX_THREADS cho Ray Actor để điều chỉnh theo mức độ bão hòa của từng node
  • Có rất nhiều phát hiện hay

    • Tôi tự hỏi liệu việc đưa dữ liệu có cấu trúc vào API embedding có tốt hơn hay đưa dữ liệu phi cấu trúc vào thì tốt hơn. Nếu hỏi ChatGPT thì nó nói gửi dữ liệu phi cấu trúc sẽ tốt hơn
    • Trường hợp sử dụng của tôi là cho jsonresume. Tôi đang gửi toàn bộ phiên bản json dưới dạng chuỗi để tạo embedding, nhưng cũng đang thử dùng một mô hình dịch resume.json sang phiên bản văn bản đầy đủ trước rồi mới tạo embedding. Kết quả có vẻ tốt hơn, nhưng tôi chưa thấy ý kiến cụ thể nào về việc này
    • Lý do dữ liệu phi cấu trúc có thể tốt hơn là vì ngôn ngữ tự nhiên giúp chứa đựng ý nghĩa văn bản/ngữ nghĩa
  • Trong tài liệu của Vespa có một mẹo khá gọn là chuyển vector sang nhị phân rồi dùng biểu diễn hệ thập lục phân

    • Mẹo này có thể dùng để giảm kích thước payload. Vespa hỗ trợ định dạng này và nó đặc biệt hữu ích khi cùng một vector được tham chiếu nhiều lần trong tài liệu. Trong các trường hợp như ColBERT hoặc ColPaLi (khi có nhiều vector embedding), nó có thể giảm đáng kể kích thước vector lưu trên đĩa
  • Polars + Parquet rất tuyệt về tính di động và hiệu năng. Bài đăng này tập trung vào tính di động trong Python, nhưng Polars có API Rust dễ dùng cho phép nhúng engine ở nhiều nơi

  • Tôi là fan cuồng của Polars, nhưng chưa từng nghĩ đến chuyện dùng nó để lưu embedding (tôi đang thử nghiệm với sqlite-vec). Có vẻ đây là một ý tưởng thật sự thú vị

  • Tôi cũng khuyên dùng lancedb như một thư viện khác có hiệu năng và tính năng nổi bật, chẳng hạn như lập chỉ mục toàn văn và quản lý phiên bản cho thay đổi

    • Đây là một cơ sở dữ liệu vector và phức tạp hơn, nhưng có thể dùng mà không cần tạo chỉ mục, đồng thời cũng có hỗ trợ Arrow zero-copy rất tốt cho polars và pandas