7 points par GN⁺ 2025-03-06 | 1 commentaires | Partager sur WhatsApp

La meilleure façon d’utiliser des embeddings de texte de manière portable : Parquet et Polars

  • Les embeddings de texte sont des vecteurs générés par des grands modèles de langage, qui représentent numériquement des mots, des phrases et des documents
  • En février 2025, un total de 32 254 embeddings de cartes de « Magic: The Gathering » a été généré
  • Cela permet d’analyser mathématiquement les similarités entre cartes à partir de leurs propriétés de design et de gameplay
  • Il est possible de visualiser les embeddings générés via une réduction de dimension en 2D avec UMAP
  • Le modèle d’embedding utilisé est gte-modernbert-base, et le processus détaillé est documenté dans le dépôt GitHub
  • Le jeu de données d’embeddings est disponible sur Hugging Face

Repenser la nécessité d’une base de données vectorielle

  • En général, on utilise des bases de données vectorielles (faiss, qdrant, Pinecone) pour stocker et rechercher des embeddings
  • Cependant, les bases de données vectorielles demandent une configuration complexe, et les services cloud peuvent coûter cher
  • Pour des jeux de données de petite taille (de l’ordre de quelques dizaines de milliers d’éléments), il est possible d’effectuer des recherches de similarité rapides avec numpy, sans base de données vectorielle
  • En utilisant l’opération de dot product de numpy, on peut calculer simplement la similarité cosinus ; sur 32 254 embeddings, cela prend en moyenne 1,08 ms
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  
  • Utiliser une base de données vectorielle implique souvent une forte dépendance à une bibliothèque ou à un service spécifique
  • Si les embeddings sont générés sur un serveur GPU puis téléchargés en local, il faut un mode de stockage et de transfert efficace

Les pires façons de stocker des embeddings

  • Fichiers CSV
    • Stocker des données en virgule flottante (float32) sous forme de texte multiplie la taille par plus de 6
    • Le tutoriel officiel d’OpenAI recommande lui aussi le CSV uniquement pour de petits jeux de données
    • En enregistrant avec .savetxt() de numpy, la taille du fichier grimpe à 631,5 MB
  • Fichiers pickle
    • Ils permettent un enregistrement et un chargement rapides, mais présentent des risques de sécurité et une compatibilité limitée entre versions
    • La taille du fichier est de 94,49 MB, identique à celle occupée en mémoire, mais la portabilité est faible

Des méthodes acceptables, mais pas optimales

  • Le format .npy de numpy
    • Le paramètre allow_pickle=False permet d’empêcher l’enregistrement au format pickle
    • La taille du fichier et la vitesse sont identiques à celles de pickle, mais il est difficile d’y stocker des métadonnées individuelles
  • Le problème d’un stockage séparé des métadonnées
    • Si l’on stocke les embeddings dans un tableau numpy (.npy), les informations de carte (nom, texte, etc.) sont séparées des embeddings
    • Quand les données changent (ajout/suppression), il devient difficile de faire correspondre correctement métadonnées et embeddings
    • Les bases de données vectorielles stockent les métadonnées avec les vecteurs et offrent des fonctions de filtrage

La meilleure façon de stocker des embeddings : Parquet + polars

Présentation du format de fichier Parquet

  • Apache Parquet est un format de stockage colonnaire qui permet de définir clairement le type de données de chaque colonne
  • Il peut stocker des données sous forme de listes (tableaux float32), ce qui le rend adapté aux embeddings
  • Il offre de meilleures performances d’écriture et de lecture que le CSV, et permet de charger sélectivement seulement une partie des données
  • Il propose la compression, mais comme les données d’embedding contiennent peu de redondance, le gain reste limité

Utiliser des fichiers Parquet en Python

  • Enregistrement et chargement d’un fichier Parquet avec pandas :
    df = pd.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • pandas gère mal les données imbriquées (listes) et les convertit en objets numpy object
    • Lors de la conversion en tableau numpy, une opération supplémentaire (np.vstack()) est nécessaire, ce qui peut dégrader les performances
  • Enregistrement et chargement d’un fichier Parquet avec polars :
    df = pl.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • polars conserve les tableaux float32 tels quels, et un appel à to_numpy() renvoie immédiatement un tableau numpy 2D
    • Le paramètre allow_copy=False permet d’éviter des copies de données inutiles
    embeddings = df["embedding"].to_numpy(allow_copy=False)  
    
  • Il est aussi simple d’ajouter de nouveaux embeddings : il suffit d’ajouter une colonne puis d’enregistrer
    df = df.with_columns(embedding=embeddings)  
    df.write_parquet("mtg-embeddings.parquet")  
    

Recherche de similarité et filtrage avec Parquet + polars

  • Il est possible de filtrer d’abord les données selon certaines conditions, puis d’effectuer la recherche de similarité
  • Exemple : trouver des cartes similaires à une carte donnée (query_embed), mais uniquement parmi celles de type « Sorcery » et dont la couleur inclut « 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]  
    
  • Le temps d’exécution moyen est de 1,48 ms : 37 % plus lent qu’une recherche sur l’ensemble des données, mais cela reste très rapide

Alternatives pour traiter de gros volumes de données vectorielles

  • L’approche Parquet + dot product est suffisante jusqu’à plusieurs centaines de milliers d’embeddings
  • Pour des jeux de données plus volumineux, l’usage d’une base de données vectorielle peut devenir nécessaire
  • Comme alternative, on peut utiliser sqlite-vec, basé sur SQLite, pour ajouter des capacités de recherche vectorielle et de filtrage

Conclusion

  • Une base de données vectorielle n’est pas forcément indispensable
  • La combinaison Parquet + polars est une alternative puissante pour stocker, rechercher et filtrer efficacement des embeddings
  • En particulier pour les projets de petite taille, utiliser des fichiers Parquet peut être plus rapide et plus rentable
  • Selon le projet, il est important de choisir la solution adaptée entre Parquet et une base de données vectorielle
  • Le code et les données sont disponibles dans le dépôt GitHub

1 commentaires

 
GN⁺ 2025-03-06
Avis Hacker News
  • Le problème de Parquet, c’est qu’il est statique. Il n’est pas adapté aux cas où des écritures et des mises à jour continues sont nécessaires. En revanche, j’ai obtenu de bons résultats en utilisant des fichiers Parquet avec DuckDB et du stockage objet. Les temps de chargement sont rapides

    • Si vous hébergez votre propre modèle d’embedding, vous pouvez envoyer des tableaux compressés numpy float32 sous forme d’octets, puis les redécoder en tableaux numpy
    • Personnellement, je préfère utiliser SQLite avec l’extension usearch. J’utilise des vecteurs binaires, puis je rerank les 100 premiers en float32. Cela prend environ 2 ms pour environ 20 000 éléments, ce qui est plus rapide que LanceDB. Sur des collections plus grandes, Lance peut l’emporter. Mais pour mon cas d’usage, chaque utilisateur a son propre fichier SQLite dédié, donc cela fonctionne bien
    • Pour la portabilité, il y a Litestream
  • Article vraiment génial. J’apprécie votre travail depuis longtemps. Pour les personnes qui se lancent dans une implémentation SQLite, j’ajouterais que DuckDB a commencé à proposer quelques fonctionnalités de similarité vectorielle qui lisent Parquet et couvrent parfaitement ce cas d’usage

  • Je n’aime toujours pas vraiment les dataframes, mais Polars est bien meilleur que pandas

    • Je faisais des calculs de séries temporelles, essentiellement de simples ajustements de prix d’actions
    • J’ai été surpris de voir qu’il était réellement possible de lire et de tester le code
    • L’exécution était si rapide que cela semblait cassé
  • Regardez usearch de Unum. Ça bat tout le reste et c’est très facile à utiliser. Ça fait exactement ce qu’il faut

  • Si vous voulez essayer, vous pouvez faire du lazy loading depuis HF et appliquer des filtres

    • Polars est excellent à utiliser et je le recommande vivement. Il est remarquable pour saturer le CPU sur un nœud unique, et si vous devez distribuer le travail, vous pouvez appliquer POLARS_MAX_THREADS à un Ray Actor afin de l’ajuster selon le niveau de saturation d’un nœud unique
  • Il y a beaucoup d’excellentes trouvailles

    • Je me demande s’il vaut mieux envoyer des données structurées à une API d’embedding, ou des données non structurées. Si on pose la question à ChatGPT, il dit qu’il vaut mieux envoyer des données non structurées
    • Mon cas d’usage concerne jsonresume. J’envoie actuellement la version json complète sous forme de chaîne pour générer les embeddings, mais j’expérimente aussi avec un modèle qui traduit d’abord resume.json en une version texte complète avant de générer les embeddings. Les résultats semblent meilleurs, mais je n’ai pas vu d’avis très concrets sur le sujet
    • Si les données non structurées sont meilleures, c’est parce qu’elles contiennent un sens textuel/sémantique grâce au langage naturel
  • Il y a une astuce élégante dans la documentation de Vespa qui consiste à convertir les vecteurs en binaire, puis à utiliser une représentation hexadécimale

    • Cette astuce peut être utilisée pour réduire la taille du payload. Vespa prend en charge ce format, et c’est particulièrement utile quand le même vecteur est référencé plusieurs fois dans un document. Dans des cas comme ColBERT ou ColPaLi (où il y a plusieurs vecteurs d’embedding), cela peut réduire considérablement la taille des vecteurs stockés sur disque
  • Polars + Parquet, c’est excellent en matière de portabilité et de performances. Ce billet mettait l’accent sur la portabilité Python, mais Polars dispose aussi d’une API Rust facile à utiliser qui permet d’embarquer le moteur à divers endroits

  • Je suis un grand fan de Polars, mais je n’avais pas pensé à l’utiliser pour stocker des embeddings (j’expérimentais avec sqlite-vec). Cela me semble être une idée vraiment intéressante

  • Je recommande aussi lancedb, une autre bibliothèque dotée d’excellentes performances et de fonctionnalités comme l’indexation full-text et le versioning des modifications

    • C’est une base de données vectorielle et c’est plus complexe, mais on peut l’utiliser sans créer d’index, et elle offre aussi un excellent support Arrow zero-copy pour polars et pandas