Aller au contenu
English

Recherche vectorielle dans une collection de documents ouverte

  • Étude de cas 2
  • 240 min
  • Facultative · 1 étude de cas requise pour le certificat
Étude de cas 2 sur 2 · Bases de données et stockage pour les applications d'IARecherche vectorielle dans une collection de documents ouverte

Ce que vous saurez faire

Créez une recherche d’« articles similaires » parmi 15 469 vrais résumés de recherche sous licence ouverte, avec PostgreSQL et pgvector sur votre ordinateur. Mesurez les recherches exactes et approximatives, et empêchez un filtre de catégorie de renvoyer trop peu d’articles. Passez ensuite à un meilleur modèle sans mélanger deux modèles. Enfin, retirez un article pour qu’il n’en reste aucun vecteur.

Étude de casPièce de portfolio

Durée
Environ 4 heures
S’exécute dans
VS Code, Python 3.12+
Compétences utilisées
  • pgvector
  • Versions de plongement
  • Recherche exacte et HNSW
  • Rappel@10
  • Filtres de métadonnées
  • Changement de modèle sûr
  • Suppression et balayages
  • Migrations

Décompressez le projet de départ, ouvrez le dossier dans VS Code et suivez les étapes ci-dessous. Elles commencent par un nouvel environnement virtuel.

Le projet de départ contient la base de données dans Docker, la table des articles et un programme de chargement. Il contient aussi les scripts pour créer les vecteurs, évaluer et mesurer. Vous écrivez les parties qui concernent ce cours : la table vectorielle avec versions, la recherche, les index, le changement de version, la suppression et le balayage. Le projet terminé présente le résultat, pour vous aider en cas de blocage. Le code est sous licence MIT. Vous pouvez donc publier votre version.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 240 minutes

Ce que vous devez savoir

Cette étude de cas utilise tout le cours. Voici les leçons sur lesquelles elle s’appuie le plus :

Ce qu’il vous faut sur votre ordinateur

  • Python 3.12 ou plus récent, VS Code et Docker Desktop (ou PostgreSQL 18 avec pgvector 0.8), comme dans De SQLite à PostgreSQL.
  • Environ 2 Go d’espace disque libre. Les paquets occupent 1,1 Go (surtout PyTorch), les deux modèles 91 Mo et 133 Mo, et la base 190 Mo à la fin.
  • Environ 10 minutes de calcul pour créer les vecteurs, pendant que vous lisez.

Testé sur macOS (Apple M4, 16 Go) avec Python 3.12.13, PostgreSQL 18.6 et pgvector 0.8.7. Les modèles ont fonctionné avec sentence-transformers 6.1.0 et PyTorch 2.14.1 sur le processeur. Nous avons écrit les commandes pour Windows et Linux, mais nous ne les avons pas testées.

Hana Sato dirige le soutien à la recherche dans une bibliothèque universitaire. Les chercheurs posent la même question à son équipe chaque semaine : « J’ai cet article. Que devrais-je lire d’autre? » La bibliothèque veut une recherche d’articles similaires en informatique. Un chercheur colle un titre ou quelques phrases et obtient les articles les plus proches. Il peut choisir une seule catégorie, comme la recherche d’information.

Daniel Mensah, le développeur de la bibliothèque, va la créer. Hana lui donne quatre exigences :

  1. De bons résultats. Meilleurs que la recherche par mots-clés actuelle de la bibliothèque, avec un test pour le prouver, pas une impression.
  2. Une recherche rapide. Une recherche doit prendre quelques millisecondes dans la base de données, pour que la page semble instantanée.
  3. Le modèle peut changer. De meilleurs modèles de plongement apparaissent chaque année. Un changement ne doit jamais mélanger deux modèles et doit être facile à annuler.
  4. Les suppressions sont définitives. Quand un article est retiré, il doit disparaître de la recherche, y compris tous ses vecteurs. Un rechargement des données ne doit jamais le rétablir.

Hana et Daniel sont fictifs. Les articles sont réels.

Vous créez paper-search, un petit projet avec une base PostgreSQL et quelques scripts en ligne de commande :

Partie Ce que vous ajoutez Ce que vous mesurez
1 rien : vous examinez les données tailles, catégories, notices inhabituelles
2 une table vectorielle avec une version de plongement 15 469 vecteurs sur le processeur
3 la fonction de recherche recherche par mots-clés comparée aux vecteurs, sur 500 titres
4 un index HNSW rappel@10 et temps, pour cinq réglages
5 (le filtre est déjà là) résultats trop courts avec et sans balayage itératif
6 un second modèle, activé sans risque les mêmes 500 titres, puis 500 nouveaux
7 la suppression et le balayage une suppression, 300 nouveaux articles, une défaillance créée pour le test

La conception suit le cours. La table des articles est l’original. Chaque vecteur est une copie créée à partir d’un résumé. On peut donc toujours le recréer. Chaque ligne vectorielle indique le modèle qui l’a créée et le texte utilisé.

arXiv est l’archive ouverte où de nombreux chercheurs publient leurs articles. Elle publie une description de chaque article, les métadonnées : titre, résumé, auteurs, catégories et dates. Les conditions d’arXiv placent ces métadonnées sous CC0 1.0 : « Vous pouvez utiliser librement les métadonnées descriptives des publications électroniques d’arXiv selon les conditions de la déclaration de domaine public Creative Commons Universal (CC0 1.0). » CC0 signifie qu’il n’y a aucune condition. Les articles eux-mêmes (les PDF) ont leurs propres licences, et ce projet ne les utilise pas.

Nextia Learning a retenu les notices en informatique dont la dernière modification date de février ou mars 2024. Nous avons utilisé l’interface ouverte d’arXiv, avec une requête toutes les quatre secondes. Nous avons ainsi obtenu 15 469 notices. Un second fichier contient 300 notices d’avril 2024, pour la partie 7. Le dépôt des exercices du cours conserve une copie des deux fichiers, avec leurs sommes de contrôle SHA-256. Vous téléchargez ces copies, pas directement depuis arXiv. La fiche du jeu de données, DATASET.md, donne les étapes exactes. Nous avons aussi examiné des textes du gouvernement américain (domaine public). Mais son site a bloqué notre script avec une vérification antirobot. La licence de Wikipédia exige de partager vos modifications sous la même licence, ce que ce cours évite.

Colonne Signification
arxiv_id l’identifiant arXiv, par exemple 2402.05035 (février 2024, article 5035)
title, abstract le titre de l’article et son résumé de 10 à 486 mots
primary_category, categories par exemple cs.IR (recherche d’information); la première catégorie est la catégorie principale
authors les noms des auteurs, tels qu’arXiv les publie
created, updated les dates de la première version et de la plus récente
comments la note des auteurs, comme « 12 pages », ou une indication que l’article a été retiré
  1. Téléchargez le projet de départ dans l’encadré en haut de cette page et décompressez-le dans votre dossier de projets. Vous obtenez le dossier paper-search. Dans VS Code, choisissez File > Open Folder (fichier, ouvrir un dossier) et ouvrez-le. Ouvrez un terminal avec Terminal > New Terminal (terminal, nouveau terminal).

  2. Créez et activez un environnement virtuel, puis installez les versions exactes :

    Fenêtre de terminal
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install -r requirements-lock.txt
    cp .env.example .env

    L’installation prend quelques minutes. Sur Linux sans carte graphique, installez d’abord la version de PyTorch pour processeur, avec la ligne dans requirements.txt.

  3. Démarrez PostgreSQL avec pgvector. La base de données écoute sur le port 5433 pour éviter un conflit avec ticket-api du cours :

    Fenêtre de terminal
    docker compose up -d --wait

    Sans Docker, créez une base papers avec l’utilisateur papers dans votre propre PostgreSQL 18 avec pgvector. Modifiez ensuite DATABASE_URL dans .env.

  4. Récupérez les données et chargez-les :

    Fenêtre de terminal
    python -m scripts.get_data
    python -m scripts.load
    Sortie (fin)
    Checked arxiv-cs-2024-papers.jsonl.gz: SHA-256 fb138597e9be... is correct (8.4 MB)
    Checked arxiv-cs-2024-04-new.jsonl.gz: SHA-256 1b924ae04f27... is correct (0.2 MB)
    applied 001_papers
    Database is up to date (1 migrations).
    Loaded 15,469 papers in 2.5 s.
  5. Lancez les tests : python -m pytest. La dernière ligne est 6 passed (6 tests réussis).

Ce que cela nous apprend. Les données correspondent au fichier que nous avons testé. Un téléchargement modifié ou endommagé a une autre somme de contrôle, et le script s’arrête. La migration 001 a créé la table des articles avec ses règles : un identifiant valide, un titre et un résumé. Elle exige aussi que la catégorie principale soit égale à la première catégorie. Relancez python -m scripts.load. Il affiche The papers table already has data. Nothing was loaded. (la table des articles contient déjà des données; rien n’a été chargé).

Partie 1 : examiner les données nécessaires à la recherche

Section intitulée « Partie 1 : examiner les données nécessaires à la recherche »

Ouvrez psql avec docker compose exec db psql -U papers -d papers. Examinez seulement ce dont dépend la recherche : le nombre de résumés, leur longueur, les catégories et les notices inhabituelles.

SELECT count(*) AS papers, count(DISTINCT abstract) AS abstracts, min(created) AS first, max(created) AS last FROM papers;
SELECT primary_category, count(*) FROM papers GROUP BY 1 ORDER BY 2 DESC LIMIT 6;
SELECT primary_category, count(*) FROM papers WHERE primary_category IN ('cs.IR', 'cs.DB') GROUP BY 1;
papers | abstracts | first | last
--------+-----------+------------+------------
15469 | 15468 | 2008-02-22 | 2024-03-28
(1 row)
primary_category | count
------------------+-------
cs.CV | 3344
cs.LG | 3068
cs.CL | 2126
cs.RO | 1065
cs.AI | 648
cs.CR | 606
(6 rows)
primary_category | count
------------------+-------
cs.DB | 91
cs.IR | 330
(2 rows)

Les résumés comptent au moins 10 mots, 174 à la médiane, 240 au 90e centile et au plus 486. Parmi eux, 1 451 contiennent des formules LaTeX avec $. Deux notices indiquent que l’article a été retiré :

arxiv_id | title | comments
------------+-----------------------------------------------+--------------------------------------------------------------
2402.02616 | The Virtues of Pessimism in Inverse Reinforce | This paper has been withdrawn by the authors pending edits f
2402.05035 | A Survey on Domain Generalization for Medical | This is a withdrawn submission and will be considered invali
2402.14602 | Don't mention it: An approach to assess chall | 17 pages, 8 figures, 8 tables. Revision of a submission to P

Ce que cela nous apprend. Un résumé apparaît deux fois (2311.16203 et 2403.05029, deux articles avec des titres différents). Une recherche peut donc renvoyer des doublons. La plupart des articles datent du début de 2024, car les notices modifiées plus tard ne sont pas dans l’échantillon. Les catégories sont très inégales : un filtre sur cs.CV conserve 22 % des articles, contre 0,6 % pour cs.DB. Cette différence compte dans la partie 5. La troisième ligne contenant « withdrawn » (retiré) est une fausse alerte : c’est un article révisé. Une suppression exige une décision humaine, pas une recherche textuelle.

Chaque vecteur est une liste de 384 nombres qu’un modèle calcule à partir d’un résumé. Le premier modèle est sentence-transformers/all-MiniLM-L6-v2 (Apache-2.0) : petit, rapide et populaire. Écrivez la table vectorielle pour que chaque ligne indique la méthode qui l’a créée, comme dans Stockage des vecteurs. Créez migrations/002_embedding_versions.sql :

migrations/002_embedding_versions.sql
-- Vectors with a version. A version names one recipe: one model, one revision, one prefix.
-- Exactly one version is current, and every search uses it on both sides.
CREATE TABLE embedding_versions (
version text PRIMARY KEY,
model text NOT NULL,
revision text NOT NULL,
dimensions integer NOT NULL CHECK (dimensions = 384),
query_prefix text NOT NULL DEFAULT '',
document_prefix text NOT NULL DEFAULT '',
is_current boolean NOT NULL DEFAULT false
);
CREATE UNIQUE INDEX embedding_versions_one_current ON embedding_versions (is_current) WHERE is_current;
INSERT INTO embedding_versions (version, model, revision, dimensions, is_current)
VALUES ('minilm-v1', 'sentence-transformers/all-MiniLM-L6-v2', '1110a243fdf4706b3f48f1d95db1a4f5529b4d41', 384, true);
CREATE TABLE paper_embeddings (
arxiv_id text NOT NULL REFERENCES papers (arxiv_id) ON DELETE CASCADE,
embedding_version text NOT NULL REFERENCES embedding_versions (version),
source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'),
embedding vector(384) NOT NULL,
PRIMARY KEY (arxiv_id, embedding_version)
);

Trois choix servent de base au reste de l’étude de cas. La révision fixe les fichiers exacts du modèle pour que « MiniLM » ne puisse pas changer sans prévenir. source_sha256 est la somme de contrôle du résumé ayant servi à créer le vecteur. Si le résumé change, le vecteur est périmé. ON DELETE CASCADE fait supprimer par la base les vecteurs d’un article en même temps que l’article.

Appliquez la migration, puis créez les vecteurs. La première exécution télécharge le modèle (91 Mo) dans votre cache Hugging Face :

Fenêtre de terminal
python -m paper_search.migrate
python -m scripts.embed
Sortie (fin)
15,469 papers need a vector of minilm-v1 (sentence-transformers/all-MiniLM-L6-v2).
Embedded 15,469 papers with sentence-transformers/all-MiniLM-L6-v2 in 147 s (105 per second; loading the model took 4.5 s).

Ce que cela nous apprend. Le processeur d’un portable crée les plongements de cette collection en environ deux minutes et demie. Votre temps sera différent. scripts/embed.py valide les modifications tous les 500 articles. Il crée seulement les plongements des articles sans vecteur de cette version, ou dont le vecteur est périmé. Une exécution interrompue reprend donc là où elle s’est arrêtée. Une seconde exécution affiche Every paper has an up-to-date vector of minilm-v1. (chaque article a un vecteur à jour de minilm-v1). Les vecteurs occupent 31 Mo, soit environ la moitié de la taille de la table des articles.

Partie 3 : une référence de base, le premier modèle et un test équitable

Section intitulée « Partie 3 : une référence de base, le premier modèle et un test équitable »

Avant de faire confiance aux vecteurs, prouvez qu’ils surpassent ce qu’utilise la bibliothèque : la recherche par mots-clés. Il faut un test avec une bonne réponse par question.

Le test. Choisissez 500 articles avec une valeur initiale fixe pour le tirage aléatoire. Utilisez chaque titre comme question. Comptez une réussite quand le résumé de l’article figure parmi les dix premiers résultats. Les vecteurs proviennent uniquement des résumés. Le titre ne fait donc jamais partie du texte recherché. Deux nombres mesurent les résultats. hit@10 est la proportion de questions avec une réussite. MRR@10 est la moyenne de 1 divisé par le rang du bon article : 1 au premier rang, 0,5 au deuxième, 0 s’il manque. Chaque méthode répond aux mêmes 500 titres. Ce test remplace de vraies questions, mais il est équitable et reproductible.

Écrivez maintenant la recherche. Créez paper_search/search.py. Voici la partie centrale :

paper_search/search.py (the core)
def search(conn, query_vector, version, *, k=10, category=None, exclude=None, exact=False,
ef_search=40, iterative="relaxed_order"):
where = [sql.SQL("e.embedding_version = {}").format(sql.Literal(version))]
params = {"q": query_vector, "k": k}
if category is not None:
where.append(sql.SQL("p.primary_category = %(category)s"))
params["category"] = category
if exclude is not None:
where.append(sql.SQL("p.arxiv_id <> %(exclude)s"))
params["exclude"] = exclude
query = sql.SQL(
"SELECT p.arxiv_id, p.title, p.primary_category, e.embedding <=> %(q)s::vector AS distance "
"FROM paper_embeddings e JOIN papers p USING (arxiv_id) "
"WHERE {where} ORDER BY e.embedding <=> %(q)s::vector LIMIT %(k)s"
).format(where=sql.SQL(" AND ").join(where))
with conn.transaction():
# Set all three every time. SET LOCAL lasts to the end of the outer transaction.
conn.execute(sql.SQL("SET LOCAL enable_indexscan = {}").format(sql.SQL("off" if exact else "on")))
conn.execute(sql.SQL("SET LOCAL hnsw.ef_search = {}").format(sql.Literal(int(ef_search))))
conn.execute(sql.SQL("SET LOCAL hnsw.iterative_scan = {}").format(sql.Literal(iterative)))
rows = conn.execute(query, params).fetchall()
return sorted((Hit(*r) for r in rows), key=lambda h: h.distance)

Le code écrit la version dans le SQL comme une valeur littérale. PostgreSQL utilise un index partiel seulement s’il voit la valeur, comme l’explique Stockage des vecteurs. Elle vient de la table embedding_versions, jamais d’un utilisateur. La catégorie et le vecteur restent des paramètres. Le fichier du projet terminé contient aussi les importations, la classe Hit et la chaîne de documentation. Copiez aussi scripts/search.py du projet terminé : il crée le plongement de votre question et appelle search.

La référence de base par mots-clés utilise la recherche plein texte de PostgreSQL, déjà dans la migration 001. Elle comprend une colonne tsvector pour chaque résumé, avec un index GIN. L’évaluation y cherche n’importe quel mot du titre et classe les résultats avec ts_rank_cd. Lancez le test :

Fenêtre de terminal
python -m scripts.evaluate
method hit@1 hit@10 MRR@10 ms/query (500 titles, seed 7)
keyword (full-text search) 0.364 0.734 0.482 59.0
minilm-v1 0.846 0.968 0.890 12.57

Ce que cela nous apprend. La recherche par mots-clés trouve le bon article parmi les dix premiers pour 73 % des titres. MiniLM le trouve pour 97 %, et au premier rang pour 85 %. Un titre et son résumé utilisent souvent des mots différents pour la même idée, et les vecteurs le reconnaissent. Ce test utilise la recherche exacte pour les vecteurs. Il compare donc les modèles, pas les index. La première exigence de Hana est satisfaite. Essayez une question dans vos propres mots : python -m scripts.search "approximate nearest neighbour search with metadata filters" (recherche approximative de voisins les plus proches avec filtres de métadonnées). Elle renvoie des articles sur le hachage sensible à la localité, la recherche vectorielle avec prédicats structurés et la recherche de voisins par graphes.

La recherche exacte ci-dessus calcule la distance aux 15 469 vecteurs et les trie. Un index HNSW est un graphe de voisins. Une recherche le parcourt du plus éloigné au plus proche et conserve une liste de hnsw.ef_search candidats. Elle est beaucoup plus rapide, mais peut manquer un vrai voisin. Stockage des vecteurs explique le graphe. Le rappel@10 est la proportion des dix vrais voisins les plus proches que l’index a renvoyés.

Créez un index partiel pour la version actuelle, sans bloquer les écritures pendant sa construction :

migrations/003_minilm_hnsw.sql
-- migrate: no-transaction
-- An approximate index for the current version only (a partial index), built without blocking writes.
CREATE INDEX CONCURRENTLY paper_embeddings_minilm_v1_hnsw ON paper_embeddings
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64)
WHERE embedding_version = 'minilm-v1';

python -m paper_search.migrate l’a appliqué en 1,4 seconde. L’index occupe 30 Mo, autant que les vecteurs eux-mêmes. Mesurez ensuite. scripts/bench.py choisit 200 articles avec une valeur initiale fixe pour le tirage aléatoire. Il utilise le vecteur stocké de chacun pour chercher les articles les plus similaires. Il exécute la recherche exacte et l’index avec cinq réglages, cinq fois chacun après un échauffement, sur les mêmes requêtes :

Fenêtre de terminal
python -m scripts.bench
minilm-v1: 15,469 vectors, 200 queries, k = 10
search recall@10 short ms/search
exact (no index) 1.000 0 12.074
index, ef_search 20 0.997 0 1.668
index, ef_search 40 0.999 0 1.725
index, ef_search 80 1.000 0 2.003
index, ef_search 160 1.000 0 2.250
index, ef_search 320 1.000 0 3.115
Millisecondes médianes par recherche pour 200 recherches d'articles similaires parmi 15 469 vecteurs MiniLM. L'index est environ 7 fois plus rapide avec ef_search à sa valeur par défaut de 40. Il trouve encore 99,9 % des dix vrais voisins les plus proches.
Description textuelle du graphique

Temps médian par recherche sur 200 requêtes : la recherche exacte a pris 12,1 ms. L’index HNSW a pris 1,7 ms avec ef_search à 20 (rappel de 0,997) et 1,7 ms à 40 (rappel de 0,999). À 80, 160 et 320, il a pris 2,0, 2,3 et 3,1 ms, toujours avec un rappel de 1,000.

Ce que cela nous apprend. La valeur par défaut, ef_search = 40, est le réglage à utiliser : 1,7 ms au lieu de 12,1 ms, et 1 998 des 2 000 vrais voisins. Une liste de candidats plus longue prend plus de temps et apporte très peu pour cette collection. Les vrais résumés arrivent rarement à égalité. Le rappel est donc proche de 1, contrairement aux billets fictifs du cours. Ces temps sont les médianes de 5 passages sur un Apple M4 occupé et partagé. Entre deux exécutions, la recherche exacte est passée de 8,5 à 12,1 ms. Vos nombres seront différents, mais leur ordre devrait rester le même : l’index est plusieurs fois plus rapide au même rappel.

Erreur courante

L'index est aussi lent que la recherche exacte, et son rappel est parfait à 1,000

Symptôme : lors de notre première exécution, chaque ligne d’index affichait un rappel de 1,000 et environ 10 à 12 ms. C’était le même temps que la recherche exacte. Un résultat trop beau est un résultat à vérifier.

Cause : la première version de search définissait SET LOCAL enable_indexscan = off seulement pour les recherches exactes. bench.py avait déjà exécuté une requête. Sa connexion était donc dans une transaction, et conn.transaction() créait un point de sauvegarde. Un SET LOCAL dans un point de sauvegarde dure jusqu’à la fin de la transaction externe. Après la première recherche exacte, toutes les recherches « par index » suivantes étaient donc exactes aussi.

Diagnostic : exécutez SHOW enable_indexscan; après une recherche dans la même session, ou examinez la requête avec EXPLAIN. La ligne d’index, Index Scan using paper_embeddings_minilm_v1_hnsw (balayage d’index utilisant paper_embeddings_minilm_v1_hnsw), est absente.

Correction : définissez les trois réglages à chaque recherche, comme dans le code ci-dessus. Le test test_an_exact_search_does_not_turn_the_index_off_for_the_next_one du projet terminé préserve cette correction.

Partie 5 : un filtre de catégorie qui retourne trop peu d’articles

Section intitulée « Partie 5 : un filtre de catégorie qui retourne trop peu d’articles »

Une chercheuse demande des articles liés, uniquement en recherche d’information : --category cs.IR. Seuls 330 des 15 469 articles (2 %) correspondent. Lancez le banc d’essai avec le filtre :

Fenêtre de terminal
python -m scripts.bench --category cs.IR
minilm-v1: 15,469 vectors, 200 queries, k = 10, filter primary_category = 'cs.IR' (330 papers)
search recall@10 short ms/search
exact (no index) 1.000 0 2.280
index, iterative scan off 0.033 198 2.161
index, strict_order 0.939 0 12.043
index, relaxed_order 0.980 0 11.424
Rappel@10 de 200 recherches avec le filtre primary_category = 'cs.IR' (330 des 15 469 articles) : recherche exacte, index sans balayage itératif, strict_order et relaxed_order. Sans balayage, 198 des 200 recherches ont retourné moins de 10 articles.
Description textuelle du graphique

Rappel@10 avec le filtre : recherche exacte, 1,00 en 2,3 ms. Index sans balayage itératif : 0,03 en 2,2 ms, avec trop peu de résultats pour 198 des 200 recherches. Avec strict_order : 0,94 en 12,0 ms. Avec relaxed_order : 0,98 en 11,4 ms. Seule la recherche sans balayage a retourné trop peu de résultats.

Ce que cela nous apprend. Sans balayage itératif, l’index fournit ses 40 candidats les plus proches. Le filtre garde ensuite ceux de cs.IR. Parmi les voisins de la plupart des articles, presque aucun n’en fait partie. Sur 200 recherches, 198 ont retourné trop peu de résultats. C’est le problème que Stockage des vecteurs montrait avec les billets. Le balayage itératif le corrige : relaxed_order (ordre relâché) (0,98) retourne des listes complètes, et search() les trie à nouveau. Vous le voyez directement dans psql, pour les articles semblables à l’article retiré 2402.05035 :

SET hnsw.iterative_scan = off; -- the second time: relaxed_order
SELECT count(*) AS found FROM (
SELECT p.arxiv_id FROM paper_embeddings e JOIN papers p USING (arxiv_id)
WHERE e.embedding_version = 'minilm-v1' AND p.primary_category = 'cs.IR' AND p.arxiv_id <> '2402.05035'
ORDER BY e.embedding <=> (SELECT embedding FROM paper_embeddings
WHERE arxiv_id = '2402.05035' AND embedding_version = 'minilm-v1')
LIMIT 10) AS s;
psql: count the cs.IR papers that the search returns (shortened)
SET hnsw.iterative_scan = off; ... found: 0
SET hnsw.iterative_scan = relaxed_order; ... found: 10

Mais regardez la dernière colonne : la recherche exacte avec le filtre a pris 2,3 ms. C’est cinq fois plus rapide que le balayage itératif. EXPLAIN montre pourquoi. Pour cs.IR, le planificateur parcourt l’index HNSW et filtre ensuite. Pour cs.DB (91 articles), il choisit lui-même un autre plan :

EXPLAIN (COSTS OFF), the main lines of the two plans
cs.IR: -> Index Scan using paper_embeddings_minilm_v1_hnsw on paper_embeddings e
-> Index Scan using papers_pkey on papers p
Filter: (primary_category = 'cs.IR'::text)
cs.DB: -> Sort
Sort Key: ((e.embedding <=> (InitPlan 1).col1))
-> Nested Loop
-> Bitmap Heap Scan on papers p
Recheck Cond: (primary_category = 'cs.DB'::text)

Avec cs.DB, PostgreSQL lit les 91 articles avec l’index B-tree de la catégorie, puis trie leurs distances. C’est une recherche exacte sur un petit ensemble. Chaque ligne cs.DB du banc d’essai avait un rappel de 1,000, sans résultat incomplet. C’est l’avantage de garder les vecteurs et les métadonnées dans une seule base de données : le planificateur peut choisir. Daniel garde relaxed_order comme protection, pour qu’un filtre ne retourne jamais trop peu d’articles. Pour les catégories rares, il note une voie plus rapide à explorer : imposer la recherche exacte sous une taille mesurée.

Partie 6 : passer à un meilleur modèle en toute sécurité

Section intitulée « Partie 6 : passer à un meilleur modèle en toute sécurité »

Un petit modèle plus récent, BAAI/bge-small-en-v1.5 (MIT), produit aussi 384 nombres. Sa fiche indique qu’il est meilleur pour la recherche. bge demande aussi une instruction avant une requête courte : Represent this sentence for searching relevant passages: (représentez cette phrase pour rechercher des passages pertinents). Cette instruction fait partie de la recette. Elle va donc dans la ligne de version. La troisième exigence de Hana détermine l’ordre du travail :

Six étapes du changement de version. 1 Ajouter la version : migration 004, bge-small-v1, non courante. 2 Remplir les données existantes : embed.py produit 15 469 vecteurs bge par lots, en 316 secondes. 3 Comparer sur les mêmes 500 titres : hit@10 passe de 0,968 à 0,984. 4 Indexer : la migration 005 construit l'index HNSW pour bge sans bloquer les accès, en 1,3 seconde. 5 Changer : set_current vérifie, puis exécute un seul UPDATE. 6 Garder, puis retirer : MiniLM reste pour un retour arrière et sera supprimé plus tard. Pendant les étapes 1 à 4, les recherches utilisent MiniLM; dès l'étape 5, elles utilisent bge. Deux notes rouges : un changement avant l'étape 4 est refusé avec No valid HNSW index covers bge-small-v1 (aucun index HNSW valide ne couvre bge-small-v1); comparer des vecteurs de requête bge aux vecteurs MiniLM ne produit aucune erreur et donne un hit@10 de 0,180.
L'ordre sûr : la nouvelle version est complète, mesurée et indexée avant qu'un seul UPDATE la rende courante. Les recherches n'attendent jamais et ne mélangent jamais deux modèles.
Description textuelle du diagramme

Étape 1 : la migration 004 ajoute la version bge-small-v1, non courante. Étape 2 : le script de plongement produit 15 469 vecteurs bge par lots, en 316 secondes. Étape 3 : l’évaluation compare les modèles sur les mêmes 500 titres : hit@10 passe de 0,968 à 0,984. Étape 4 : la migration 005 construit l’index HNSW pour bge sans bloquer les accès, en 1,3 seconde. Étape 5 : set_current vérifie que la version est prête, puis exécute un seul UPDATE. Étape 6 : MiniLM reste pour un retour arrière et sera supprimé plus tard. Pendant les étapes 1 à 4, les recherches utilisent MiniLM; dès l’étape 5, elles utilisent bge. Un changement avant l’étape 4 est refusé. Une recherche comparant un vecteur de requête bge aux vecteurs MiniLM ne produit aucune erreur et donne un hit@10 de 0,180.

  1. Ajoutez la version, sans la rendre courante :

    migrations/004_bge_small_version.sql
    -- A second version: BAAI/bge-small-en-v1.5 (MIT), also 384 numbers. It is not current yet.
    -- bge asks for an instruction in front of a short query that looks for passages.
    INSERT INTO embedding_versions (version, model, revision, dimensions, query_prefix, is_current)
    VALUES ('bge-small-v1', 'BAAI/bge-small-en-v1.5', '5c38ec7c405ec4b44b94cc5a9bb96e735b38267a', 384,
    'Represent this sentence for searching relevant passages: ', false);

    Exécutez python -m paper_search.migrate.

  2. Remplissez les données existantes : python -m scripts.embed --version bge-small-v1. Le script télécharge le modèle (133 Mo) et produit les vecteurs par lots validés, tandis que les recherches continuent d’utiliser MiniLM.

    Embedded 15,469 papers with BAAI/bge-small-en-v1.5 in 316 s (49 per second; loading the model took 2.8 s).
  3. Comparez. D’abord, l’erreur que la colonne de version empêche : comparer des vecteurs de questions bge aux vecteurs MiniLM. Les deux ont 384 nombres, donc rien ne l’empêche :

    Fenêtre de terminal
    python -m scripts.evaluate --mix bge-small-v1:minilm-v1
    method hit@1 hit@10 MRR@10 ms/query (500 titles, seed 7)
    bge-small-v1 queries against minilm-v1 vectors 0.060 0.180 0.093 11.27

    Ensuite, la comparaison équitable, chaque méthode sur les mêmes titres : python -m scripts.evaluate.

    method hit@1 hit@10 MRR@10 ms/query (500 titles, seed 7)
    keyword (full-text search) 0.364 0.734 0.482 56.13
    minilm-v1 0.846 0.968 0.890 10.77
    bge-small-v1 0.906 0.984 0.936 12.0
  4. Copiez scripts/set_current.py du projet terminé et tentez de changer de version maintenant : python -m scripts.set_current bge-small-v1.

    Not switched. bge-small-v1 is not ready:
    - No valid HNSW index covers bge-small-v1: every search would read every vector. Build it first, with a migration (CREATE INDEX CONCURRENTLY).

    Écrivez migrations/005_bge_small_hnsw.sql, identique à 003, avec bge_small_v1 dans le nom et 'bge-small-v1' dans le WHERE. Appliquez la migration (1,3 s).

  5. Changez de version : python -m scripts.set_current bge-small-v1.

    The current version is now bge-small-v1 (index paper_embeddings_bge_small_v1_hnsw). minilm-v1 and its vectors are kept: to go back, run this command with minilm-v1.
hit@10 sur les mêmes 500 titres : proportion des titres dont l'article figure parmi les dix premiers résultats. La dernière barre représente l'erreur que la colonne de version empêche.
Description textuelle du graphique

hit@10 sur 500 titres : recherche plein texte par mots-clés, 0,73; MiniLM, 0,97; bge, 0,98. Vecteurs de questions bge comparés aux vecteurs MiniLM : 0,18.

Ce que cela nous apprend. La recherche mixte n’a produit aucune erreur et n’a trouvé le bon article que dans 18 % des cas. Chaque modèle avec ses propres vecteurs atteint plutôt 97 % et 98 %. Seules la colonne de version et la règle d’une seule version courante empêchent ce mélange. bge gagne sur toutes les mesures : 0,906 contre 0,846 en première place, et un MRR@10 de 0,936 contre 0,890. Le gain est modeste, et le modèle est deux fois plus lent à exécuter (49 contre 105 résumés par seconde). Il faut aussi un deuxième index de 30 Mo. Hana accepte ce coût pour obtenir de meilleurs premiers résultats. set_current vérifie deux choses avant son unique UPDATE : chaque article possède un vecteur bge à jour, et un index HNSW valide couvre la version. Sans cet index, le changement aurait rendu toutes les recherches exactes.

Le test final, une seule fois. Les 500 titres de la graine 7 ont aidé à choisir le modèle. Ils donnent donc une image un peu favorable. Un nouvel ensemble, avec la graine 8, a vérifié une seule fois le modèle choisi : python -m scripts.evaluate --only bge-small-v1 --seed 8 --misses 3.

method hit@1 hit@10 MRR@10 ms/query (500 titles, seed 8)
bge-small-v1 0.896 0.966 0.922 12.29
missed 2310.20707: "What's In My Big Data?"
first result 2402.11001: 'idwMapper: An interactive and data-driven web mapping framework for vi'
missed 2312.01629: 'CLAMP: Contrastive LAnguage Model Prompt-tuning'
first result 2211.04118: 'ConsPrompt: Exploiting Contrastive Samples for Fewshot Prompt Learning'
missed 2401.08572: 'The illusion of artificial inclusion'
first result 2403.18588: 'From Virtual Reality to the Emerging Discipline of Perception Engineer'

Ce que cela nous apprend. Sur les nouveaux titres, bge trouve 96,6 % des articles parmi les dix premiers résultats. C’est proche des 98,4 % de l’ensemble de sélection. Les résultats manqués révèlent de vraies limites de la tâche. Un titre humoristique (« Qu’y a-t-il dans mes mégadonnées? ») en dit peu sur son résumé. Un titre avec un acronyme (CLAMP) mène à un autre article sur le réglage des requêtes. Les utilisateurs avec de vraies questions rencontreront les mêmes limites. Le banc d’essai sur bge a donné le même portrait que MiniLM : recherche exacte, 12,0 ms; index, 1,5 ms avec un rappel de 0,998.

Partie 7 : une suppression qui atteint chaque vecteur

Section intitulée « Partie 7 : une suppression qui atteint chaque vecteur »

2402.05035 est un article retiré. Hana décide de le supprimer. Cherchez d’abord son titre avec python -m scripts.search "A Survey on Domain Generalization for Medical Image Analysis" -k 3 :

0.095 2402.05035 cs.CV A Survey on Domain Generalization for Medical Image Analysis
0.104 2403.15647 cs.CV RetiGen: A Framework for Generalized Retinal Diagnosis Using Multi-View Fundus I
0.176 2401.08865 cs.CV The Effect of Intrinsic Dataset Properties on Generalization: Unraveling Learnin

Créez la liste des suppressions. Elle garde seulement l’identifiant et la raison, sans titre ni résumé :

migrations/006_removals.sql
CREATE TABLE removals (
arxiv_id text PRIMARY KEY,
removed_at timestamptz NOT NULL DEFAULT now(),
reason text NOT NULL CHECK (reason <> '')
);

Une fois cette table créée, le script de chargement du projet de départ ignore tous les identifiants qu’elle contient. Écrivez maintenant scripts/remove_paper.py (le projet terminé contient le fichier complet). Son cœur est une seule transaction :

scripts/remove_paper.py (the core)
with connect() as conn, conn.transaction():
vectors = conn.execute("SELECT count(*) FROM paper_embeddings WHERE arxiv_id = %s", (args.arxiv_id,)).fetchone()[0]
deleted = conn.execute("DELETE FROM papers WHERE arxiv_id = %s RETURNING title", (args.arxiv_id,)).fetchone()
if deleted is None:
print(f"No paper {args.arxiv_id}. Nothing changed.", file=sys.stderr)
return 1
left = conn.execute("SELECT count(*) FROM paper_embeddings WHERE arxiv_id = %s", (args.arxiv_id,)).fetchone()[0]
conn.execute("INSERT INTO removals (arxiv_id, reason) VALUES (%s, %s)", (args.arxiv_id, args.reason))
print(f"Removed {args.arxiv_id} ({deleted[0][:60]}): 1 paper, {vectors} vectors ({left} left).")
Fenêtre de terminal
python -m scripts.remove_paper 2402.05035 --reason "withdrawn by the authors (arXiv comment)"
Removed 2402.05035 (A Survey on Domain Generalization for Medical Image Analysis): 1 paper, 2 vectors (0 left).
remove_paper 2402.05035 exécute une seule transaction : il enregistre l'identifiant et la raison dans removals, puis supprime l'article de papers. ON DELETE CASCADE supprime ses vecteurs de toutes les versions, minilm-v1 et bge-small-v1 : 2 vecteurs, 0 restant. Les entrées de l'index HNSW sont encore présentes, mais aucune recherche ne les voit : 2 lignes mortes, que VACUUM supprime. load.py ignore les identifiants dans removals, donc un rechargement ne ramène jamais l'article. La vérification comporte cinq contrôles; après 300 nouveaux articles et une défaillance provoquée, elle a signalé 1 vecteur orphelin, 1 supprimé mais encore recherchable, 1 périmé, 300 manquants et 15 468 d'autres versions. L'option de correction et le script de plongement ramènent les quatre premiers comptes à 0.
Une seule transaction supprime l'article et, grâce à la clé étrangère, tous ses vecteurs. La liste des suppressions empêche son rechargement. La vérification cherche ce qu'un bogue ou une restauration pourrait encore laisser.
Description textuelle du diagramme

Le script remove_paper exécute une seule transaction : il enregistre l’identifiant 2402.05035 et la raison dans removals, puis supprime l’article. ON DELETE CASCADE supprime ses vecteurs des deux versions : 2 vecteurs, 0 restant. Les entrées de l’index HNSW restent jusqu’à VACUUM, sous forme de 2 lignes mortes, mais aucune recherche ne les voit. Le script de chargement ignore les identifiants dans removals, donc un rechargement ne ramène jamais l’article. La vérification comporte cinq contrôles. Après 300 nouveaux articles et une défaillance provoquée, elle a signalé 1 vecteur orphelin, 1 supprimé mais encore recherchable, 1 périmé et 300 manquants. Il y avait 15 468 vecteurs d’autres versions. L’option de correction et le script de plongement ramènent les quatre premiers comptes à 0.

La même recherche ne retourne plus l’article; RetiGen est maintenant premier. L’article avait deux vecteurs, un par version, et la clé étrangère a supprimé les deux. Même l’ancien vecteur MiniLM a disparu, donc un retour arrière ne peut pas le remettre en service. Et l’index? Les entrées HNSW ne sont pas supprimées immédiatement. pg_stat_user_tables montrait 2 lignes mortes dans paper_embeddings. Aucune recherche ne peut les voir, car PostgreSQL vérifie la visibilité de chaque ligne. VACUUM paper_embeddings; (ou le nettoyage automatique, un peu plus tard) les a supprimées : 0 ligne morte. Une deuxième suppression affiche No paper 2402.05035. Nothing changed. (aucun article 2402.05035; rien n’a changé).

La vérification. Une suppression qui fonctionne aujourd’hui ne prouve pas que rien ne restera après un bogue, une restauration ou un nouveau chargement. Copiez scripts/scan.py du projet terminé. Il compte cinq choses, chacune avec une requête :

scripts/scan.py (the checks, in words)
CHECKS = {
"orphan vectors": "... a vector whose paper does not exist",
"removed but searchable": "... a vector of a paper in removals",
"stale vectors": "... current version, source_sha256 <> sha256 of the abstract now",
"missing vectors": "... a paper with no vector of the current version",
"other versions": "... vectors of versions that are not current",
}

Chargez maintenant les 300 articles d’avril et provoquez des erreurs. Cas provoqués : un résumé modifié pour 2402.02616, comme lors d’une correction, et un vecteur orphelin pour l’article supprimé. Ce vecteur est inséré avec SET session_replication_role = replica. Ce réglage désactive les contrôles de clés étrangères, comme une restauration des données seulement avec --disable-triggers.

Fenêtre de terminal
python -m scripts.load --updates data/arxiv-cs-2024-04-new.jsonl.gz
python -m scripts.scan
Updates from arxiv-cs-2024-04-new.jsonl.gz: 300 new papers, 0 changed abstracts, 0 other changes, 0 removed (skipped).
orphan vectors: 1
removed but searchable: 1
stale vectors: 1
missing vectors: 300
other versions: 15,468

python -m scripts.scan --fix a supprimé le vecteur orphelin (fixed orphan vectors: 1 deleted, soit un vecteur orphelin corrigé par suppression). C’était aussi le vecteur « supprimé mais encore recherchable ». Les vecteurs périmés et manquants nécessitent le modèle : python -m scripts.embed a produit 301 vecteurs bge en 7 s. La vérification suivante affichait 0 pour les quatre premières lignes. Les 15 468 vecteurs MiniLM restent dans « autres versions » jusqu’à ce que Hana retire cette version. Enfin, copiez tests/test_vectors.py et tests/test_removal.py du projet terminé, puis exécutez python -m pytest : 19 passed (19 tests réussis).

Ce que cela nous apprend. La clé étrangère rend la suppression normale complète, en une étape, pour chaque version. La vérification détecte ce que la clé étrangère ne peut pas détecter. Elle trouve un vecteur produit à partir d’un ancien texte, un article sans vecteur et des lignes insérées sans contrôles. Les 300 nouveaux articles montrent le cas le plus courant. Un chargement sans exécution du modèle de plongement laisse des articles qu’aucune recherche ne peut trouver, sans message d’erreur.

Une chercheuse saisit une nouvelle question, avec un filtre sur la recherche d’information :

Fenêtre de terminal
python -m scripts.search "approximate nearest neighbour search with metadata filters" --category cs.IR
Version bge-small-v1 (BAAI/bge-small-en-v1.5); embed 32 ms, search 11.9 ms
0.188 2402.04713 cs.IR Theoretical and Empirical Analysis of Adaptive Entry Point Selection for Graph-b
0.203 2403.04871 cs.IR ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Struc
0.209 2402.11855 cs.IR TriSampler: A Better Negative Sampling Principle for Dense Retrieval
0.213 2302.14534 cs.IR Spacerini: Plug-and-play Search Engines with Pyserini and Hugging Face
0.214 2305.04359 cs.IR ParlayANN: Scalable and Deterministic Parallel Graph-Based Approximate Nearest N

Ce que cela nous apprend. Tout le parcours fonctionne. Le modèle et l’instruction de la version courante transforment la question en vecteur de plongement. L’index HNSW de cette version trouve des candidats, et le balayage itératif maintient des résultats complets malgré le filtre. ACORN, le deuxième résultat, porte exactement sur ce problème : la recherche vectorielle avec des filtres sur des données structurées. Le plongement de la question a pris 32 ms, plus que la recherche dans la base de données. Le temps de recherche comprend le balayage itératif, mesuré à environ 11 ms sur ces données dans la partie 5.

  • Les vecteurs battent les mots-clés pour cette tâche. Sur les mêmes 500 titres, hit@10 était de 0,73 pour la recherche plein texte, 0,97 pour MiniLM et 0,98 pour bge. Sur 500 nouveaux titres, bge a atteint 0,966. La tâche est une approximation : un titre est une question courte et claire. Les vraies questions sont moins nettes.
  • L’index est la partie facile. Avec 15 469 vecteurs, HNSW a rendu les recherches 7 fois plus rapides, avec un rappel de 0,999 et ses réglages par défaut. Les surprises venaient des filtres, des réglages et des versions, pas de l’index.
  • Les filtres exigent une décision par valeur. Une catégorie de 2 % a donné trop peu de résultats dans 198 des 200 recherches sans balayage itératif. Le plan exact du planificateur convenait mieux à une catégorie de 0,6 %. Mesurez les valeurs rares de chaque filtre.
  • Un changement de version est une migration de données. Remplissez les données existantes, comparez, indexez, puis exécutez un seul UPDATE, en gardant l’ancienne version pour un retour arrière. La colonne de 384 nombres fixe la taille : un modèle de 768 nombres exige une nouvelle colonne ou table et sa propre migration.
  • Limites. Seulement des résumés, seulement en anglais, un échantillon de deux mois en informatique d’une seule archive, deux petits modèles. Les temps viennent d’un seul portable occupé. Le test de pertinence utilise des titres, pas les questions de vrais utilisateurs.
  • Des métadonnées, pas des articles. La licence CC0 d’arXiv couvre les titres, résumés, auteurs et catégories. Elle ne couvre pas les PDF, qui ont leurs propres licences; certaines interdisent la redistribution. Faites un lien vers l’article sur arXiv; ne le copiez pas. Ne présentez pas le projet comme approuvé par arXiv.
  • De vraies personnes. Les noms d’auteurs sont réels et publics, mais cette collection sert à trouver des articles, pas à profiler des personnes. Ne l’utilisez pas pour classer, noter ou regrouper les auteurs.
  • Suppressions. Un article retiré doit disparaître de la recherche partout : vecteurs de chaque version, index après nettoyage, et copies dans les caches, exportations ou sauvegardes. La liste des suppressions doit accompagner les données, sinon une restauration peut ramener l’article. L’exercice de sauvegarde de Restauration et conservation l’a montré.
  • Semblable ne veut pas dire bon. Un vecteur proche signifie « sur le même sujet », pas « correct » ou « important ». Présentez les résultats comme des suggestions et gardez la recherche par mots-clés pour les titres et les noms exacts.
  • Biais. Les deux modèles ont surtout été entraînés sur des textes Web en anglais. Ils trouvent moins bien les articles aux formulations inhabituelles, dans d’autres langues ou avec beaucoup de formules.

Ce projet montre ce qu’une équipe attend d’une recherche vectorielle en production. Il possède une référence de base mesurée et un index ajusté selon le rappel et le temps. Ses filtres n’échouent pas silencieusement, son changement de modèle est sûr et sa suppression atteint chaque copie.

Ce qu’il faut mettre dans le dépôt :

paper-search/
README.md what it does, the results table, how to run and check it
DATASET.md source, licence (CC0 metadata), sample, limits
LICENSE MIT, for the code
compose.yaml PostgreSQL 18 with pgvector 0.8.7
requirements-lock.txt the exact versions
migrations/ 001 to 006: papers, versions, indexes, removals
paper_search/ search.py, versions.py, embedding.py, migrate.py
scripts/ get_data, load, embed, evaluate, bench, search, set_current, remove_paper, scan
tests/ 19 tests, with made-up vectors: no model needed

Gardez data/ hors de Git; get_data.py télécharge et vérifie les fichiers. Exécutez python -m pytest depuis un nouveau clone avant de publier, et indiquez dans le README que les tests réussissent.

README.md (outline)
# paper-search
## What it is
Related-papers search over 15,469 arXiv computer-science abstracts (CC0 metadata),
in PostgreSQL with pgvector, on a laptop CPU.
## Results
- Same 500 titles, hit@10: keyword 0.734, MiniLM 0.968, bge-small 0.984 (0.966 on 500 new titles).
- Exact search 12.1 ms; HNSW (ef_search 40) 1.7 ms at recall@10 0.999.
- Filter on a 2% category: 198 of 200 searches short without the iterative scan, 0 with it.
## Design choices
- Papers are the original; vectors carry their version and the SHA-256 of their text.
- One current version; a switch is checked (coverage, index) and is one UPDATE.
- Removals: one transaction, ON DELETE CASCADE, a removals list; a scan for leftovers.
## Run it / Check it
## Data and limits

Comment le décrire en entrevue, en deux phrases : « J’ai créé une recherche d’articles liés sur 15 000 vrais résumés arXiv dans PostgreSQL avec pgvector; sur un test fixe, elle trouve le bon article parmi les dix premiers résultats dans 98 % des cas, contre 73 % pour la recherche plein texte. J’ai ajusté HNSW par comparaison avec la recherche exacte, corrigé un filtre incomplet 198 fois sur 200, changé de modèle avec une transition vérifiée et fait en sorte que les suppressions atteignent chaque vecteur. »

Pour aller plus loin :

  • Retirez MiniLM. Une migration supprime son index et ses 15 468 vecteurs. Mesurez la base de données avant et après (190 Mo à la fin de cette étude de cas).
  • La voie exacte pour les filtres rares. Dans search, comptez d’abord les articles de la catégorie, puis utilisez la recherche exacte sous un seuil mesuré.
  • Recherche hybride. Combinez les classements plein texte et vectoriel (fusion des rangs réciproques), puis mesurez le résultat avec les mêmes 500 titres.
  • Deux fois moins de stockage. Le type halfvec de pgvector stocke des nombres de 16 bits. Mesurez la taille, le rappel et le temps.

Vérifiez votre compréhension

Le banc d'essai a montré un rappel de 1,000 et le même temps pour la recherche exacte et tous les réglages d'index. Nommez deux causes possibles et la façon de vérifier chacune.

Afficher la réponse

Premièrement, l’index n’est pas utilisé. Un réglage comme enable_indexscan = off (désactiver le parcours d’index) peut encore s’appliquer. Par exemple, un SET LOCAL (réglage local) dans un point de sauvegarde reste actif jusqu’à la fin de la transaction externe. Le planificateur peut aussi préférer un autre plan. Vérifiez avec SHOW enable_indexscan; (afficher le réglage du parcours d’index) et avec EXPLAIN (expliquer le plan) sur la même requête. La ligne Index Scan using ..._hnsw (parcours d’index utilisant …_hnsw) doit être présente. Deuxièmement, la collection est si petite ou si simple que l’index trouve vraiment tous les voisins. Le temps est alors inférieur à celui de la recherche exacte, et non identique. Un temps identique avec un rappel parfait indique la première cause.

Vérifiez votre compréhension

Pourquoi le filtre cs.DB n'a-t-il donné aucun résultat incomplet, alors que cs.IR a donné 198 recherches incomplètes sur 200? Que fait la recherche de Daniel dans les deux cas?

Afficher la réponse

Pour cs.DB (91 articles), le planificateur a estimé peu de lignes et choisi l’index en arbre B sur primary_category. Il a lu les 91 articles et trié leurs distances : une recherche exacte. Pour cs.IR (330 articles), il a choisi l’index HNSW, qui fournit ses 40 candidats les plus proches avant le filtre. Près de la plupart des articles, peu de candidats appartiennent à cs.IR. Les listes étaient donc incomplètes. La recherche de Daniel active le parcours itératif (relaxed_order, ordre assoupli). L’index poursuit alors la recherche jusqu’à ce que k lignes passent le filtre, puis le code trie les lignes à nouveau. Quel que soit le plan choisi par PostgreSQL, le résultat est complet.

Vérifiez votre compréhension

Un collègue restaure uniquement la sauvegarde de paper_embeddings de la semaine dernière, après le retrait de 2402.05035. Quel problème survient, et comment le projet le détecte-t-il?

Afficher la réponse

La sauvegarde contient encore les deux vecteurs de 2402.05035. Une restauration des données seulement, avec les déclencheurs désactivés, ignore la clé étrangère. Les vecteurs reviennent donc sans leur article. La recherche fait une jointure avec papers et ne les affiche donc pas. Mais tout code qui lit seulement les vecteurs les afficherait, et ils occupent des places de candidats dans l’index. scripts/scan.py les signale comme vecteurs orphelins et comme éléments retirés, mais encore accessibles par recherche. L’option --fix (corriger) les supprime. La liste des retraits permet au balayage et au chargeur de savoir que l’article doit rester absent.

Récapitulatif

  • Les articles sont les originaux. Chaque vecteur enregistre sa version de plongement et le SHA-256 de son texte, ce qui rend visibles les mélanges et les données périmées.
  • Sur 500 titres, les vecteurs surpassaient la recherche plein texte (hit@10 de 0,97 et 0,98 contre 0,73). Un test fixe, identique pour chaque méthode, a rendu le choix équitable. Un deuxième jeu de données l’a vérifié une fois (0,966).
  • HNSW, avec ef_search à sa valeur par défaut de 40, était environ 7 fois plus rapide que la recherche exacte, avec un rappel de 0,999. Un banc d’essai qui semble parfait exige une vérification de ses propres réglages.
  • Un filtre appliqué après l’index a produit des listes incomplètes (198 sur 200). Le parcours itératif a corrigé le problème. Pour une catégorie très rare, le planificateur a choisi lui-même un plan exact.
  • Pour changer de modèle sans risque, suivez cet ordre : ajouter, remplir les données existantes, comparer, indexer, puis exécuter un seul UPDATE (mettre à jour) vérifié. Gardez l’ancienne version pour revenir en arrière.
  • Un retrait se fait dans une seule transaction avec ON DELETE CASCADE (suppression en cascade) et une liste des retraits. Un balayage trouve les vecteurs orphelins, périmés et manquants après des défaillances.

Vous avez construit une recherche vectorielle qu’une bibliothèque pourrait utiliser en toute confiance. Le travail final vous demande de concevoir le stockage de billets de soutien, de documents et d’exécutions d’IA. Il exige un index mesuré et un plan de sauvegarde, de restauration et de conservation.

Leçon suivante : Travail final