PyMuPDF et pgvector : ce que le découpage décide
Dans une chaîne de recherche augmentée, l’attention va au modèle. La qualité, elle, se joue deux étapes plus tôt : dans la manière dont un PDF est coupé, et dans ce que l’extraction a déjà perdu.
La démonstration se monte en une après-midi et la déception arrive la semaine suivante : le système répond à côté, et l’on soupçonne le modèle. Dans les cas que j’ai vus, la faute était presque toujours en amont. Un modèle ne peut pas répondre à partir d’un passage qu’on ne lui a pas donné, et il ne peut pas comprendre un passage coupé au milieu d’un tableau.
Un PDF n’est pas un document structuré : c’est une description de ce qu’il faut peindre et où. L’extraction restitue des caractères et des positions, et tout ce qui était structure (un tableau, une note de bas de page, un en-tête répété) devient du texte au fil de l’eau. Le premier travail, avant tout découpage, est de regarder ce que l’extraction a produit sur les documents les plus laids du corpus, pas sur le plus propre.
import pymupdf
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
def read(path: str) -> list[Document]:
document = pymupdf.open(path)
pages = []
for number, page in enumerate(document, start=1):
# "blocks" rather than "text": this returns a reconstructed reading order
# instead of a stream where columns interleave.
blocks = page.get_text("blocks")
content = "
".join(b[4].strip() for b in sorted(blocks, key=lambda b: (b[1], b[0])))
# The page number is not decorative: it is what will later let the source
# be shown, and an answer without a source cannot be checked.
pages.append(Document(page_content=content, metadata={"source": path, "page": number}))
return pages
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
# Order matters: cut at paragraphs first, and only as a last resort in the
# middle of a word.
separators=["
", "
", ". ", " ", ""],
)Deux morceaux consécutifs qui ne se recouvrent pas coupent une phrase en deux, et aucun des deux ne porte alors la réponse. Un recouvrement de dix à quinze pour cent règle ce cas au prix d’un index un peu plus gros. Le vrai arbitrage est ailleurs : un morceau long donne du contexte au modèle et un vecteur flou, un morceau court donne un vecteur précis et un contexte insuffisant. Il n’y a pas de valeur juste, il y a une valeur à mesurer sur une trentaine de questions dont on connaît la réponse.
Le réflexe est d’ajouter une base vectorielle à l’architecture. Pour quelques dizaines de milliers de morceaux, une extension sur la base relationnelle déjà en service suffit, et elle apporte ce qu’un service séparé ne donne pas : les vecteurs et les métadonnées dans la même transaction, la même sauvegarde, les mêmes droits. C’est un service de moins à administrer, et c’est le genre d’économie qui se voit à la reprise plutôt qu’à la livraison.
from langchain_postgres import PGVector
store = PGVector(
embeddings=embedding_model,
collection_name="documents",
connection="postgresql+psycopg://…",
use_jsonb=True,
)
store.add_documents(splitter.split_documents(pages))
# The metadata filter in the same query as the distance: this is what avoids
# pulling back a thousand chunks in order to keep five.
results = store.similarity_search(
"what are the termination conditions",
k=5,
filter={"source": {"$in": permitted_documents}},
)Je ne dis rien du réordonnancement des résultats, ni des recherches hybrides mêlant mots-clés et vecteurs. Les deux améliorent la précision, et les deux se justifient une fois seulement qu’on mesure : les ajouter avant d’avoir un jeu de questions de référence, c’est empiler des réglages sans savoir lequel a servi. Et je ne dis rien des droits d’accès document par document, qui sont la vraie difficulté d’un tel système en entreprise : le filtre ci-dessus suppose une liste déjà calculée, et calculer cette liste est un projet à part entière.