Les bases de données vectorielles sont-elles toujours nécessaires ?
1. Vue d’ensemble
Chaque génération de modèles de langage de grande taille (LLM) a une context window plus longue. De ce fait, de nombreuses équipes commencent à se poser la question : si vous pouvez charger l’ensemble du corpus dans le prompt, avez-vous toujours besoin de construire une RAG avec une base de données vectorielle ?
Cet article ne répondra pas par un « oui » ou un « non » absolu. Vous verrez où les deux approches diffèrent. L’article contient du code Java illustrant comment estimer un budget de tokens, un vector store minimaliste pour comprendre le mécanisme de récupération, et un sélecteur de stratégie simple. Une fois que vous l’aurez lu, vous pourrez décider par vous-même pour votre système.
2. Deux façons d’intégrer les connaissances dans les LLM
Les LLM ne connaissent pas vos données internes, par exemple les politiques de remboursement, la documentation d’API ou l’historique des commandes des clients. Il y a deux façons principales d’intégrer ces données dans la réponse.
2.1. Retrieval avec base de données vectorielle
Le processus RAG (Retrieval-Augmented Generation) typique comprend quatre étapes :
- Diviser les documents en petits fragments (chunks).
- Utiliser un modèle d’embedding pour transformer chaque chunk en vecteur numérique.
- Stocker les vecteurs dans une base de données vectorielle, avec un index pour trouver les plus proches voisins.
- Lorsque l’utilisateur pose une question, embedder la question, récupérer les top-K chunks les plus proches, puis les insérer dans le prompt.
Le prompt final ne contient que quelques fragments pertinents, pas l’intégralité du corpus.
2.2. Long context : « tout charger dans le prompt »
Cette approche est beaucoup plus simple : concaténer l’intégralité des documents dans le prompt et laisser le modèle trouver lui-même l’information. Vous n’avez pas besoin de chunking, d’embedding, ni d’infrastructure supplémentaire.
Pour un petit corpus, cette approche donne généralement de bons résultats. Le modèle voit l’ensemble du contexte, il peut donc répondre à des questions nécessitant une synthèse de plusieurs parties, ce que la récupération par top-K risque de manquer.
3. Pourquoi cette question est actuellement d’actualité
Il y a plusieurs raisons pour lesquelles ce débat s’intensifie :
- Les context windows augmentent rapidement. Les corpus qui devaient autrefois être divisés peuvent maintenant tenir dans un seul prompt.
- Prompt caching. De nombreux fournisseurs permettent de mettre en cache la partie initiale d’un prompt. Cela réduit considérablement le coût et la latence de l’envoi réitéré du même bloc de documents.
- RAG est difficile à bien faire. Le chunking incorrect, les embeddings mal adaptés à la langue, la omission de fragments importants par top-K sont des erreurs très courantes. Toute équipe qui a débogué un pipeline RAG veut éliminer une couche de complexité.
Le long context a aussi ses propres limitations. La qualité de la récupération d’information dans un très long prompt peut être inégale : l’information au milieu d’un grand bloc de texte est parfois omise. Le coût et la latence augmentent toujours en fonction du nombre de tokens d’entrée. Il y a également des problèmes que le long context ne peut pas résoudre, comme le contrôle d’accès et les données qui changent continuellement. Les sections suivantes explorent chaque point.
4. Commencer par les chiffres : estimer le budget de tokens
Avant de choisir une architecture, vous devez savoir combien de tokens votre corpus représente. L’exemple ci-dessous utilise uniquement Java standard.
import java.util.List;
public class TokenBudget {
// Ước lượng thô: tokenizer thật cho kết quả khác, nhất là với tiếng Việt
private static final double CHARS_PER_TOKEN = 4.0;
public static long estimateTokens(String text) {
return Math.round(text.length() / CHARS_PER_TOKEN);
}
public static long estimateCorpus(List<String> documents) {
return documents.stream().mapToLong(TokenBudget::estimateTokens).sum();
}
public static void main(String[] args) {
List<String> policies = List.of(
"Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi.",
"Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng.",
"Tài khoản Customer bị khoá sau nhiều lần đăng nhập sai."
);
long total = estimateCorpus(policies);
long budget = 8_000; // phần context bạn dành cho tài liệu
System.out.printf("Corpus ~%d tokens, budget %d, fits=%b%n",
total, budget, total <= budget);
}
}
Explication de chaque partie :
CHARS_PER_TOKENest une heuristique très approximative. Le vietnamien avec diacritiques consomme généralement plus de tokens que l’anglais. Quand vous avez besoin de chiffres précis, vous devriez utiliser le tokenizer du modèle exact que vous utilisez.budgetn’est pas la totalité de la context window. Vous devez réserver de la place pour le system prompt, l’historique de conversation et la réponse.- Résultat pour les trois courts fragments :
Corpus ~48 tokens, budget 8000, fits=true
Si fits=true et le corpus change rarement, le long context est un candidat sérieux. Si le corpus est plusieurs fois plus grand que votre budget, vous avez toujours besoin de retrieval, peu importe la longueur de la context window.
5. Un vector store minimaliste pour comprendre le mécanisme
Pour voir ce que fait réellement le retrieval, écrivons un vector store en mémoire. L’embedding ici est du type « hashing bag-of-words », utilisé uniquement à titre illustratif. Un vrai système appellerait un modèle d’embedding.
import java.util.*;
public record Document(String id, String text) {}
public record ScoredDocument(Document document, double score) {}
public class HashingEmbedder {
private final int dimensions;
public HashingEmbedder(int dimensions) { this.dimensions = dimensions; }
public double[] embed(String text) {
double[] vector = new double[dimensions];
for (String token : text.toLowerCase().split("\\P{L}+")) {
if (token.isBlank()) continue;
vector[Math.floorMod(token.hashCode(), dimensions)] += 1.0;
}
double norm = Math.sqrt(Arrays.stream(vector).map(v -> v * v).sum());
if (norm > 0) for (int i = 0; i < dimensions; i++) vector[i] /= norm;
return vector;
}
}
public class InMemoryVectorStore {
private record Entry(Document document, double[] vector) {}
private final HashingEmbedder embedder;
private final List<Entry> entries = new ArrayList<>();
public InMemoryVectorStore(HashingEmbedder embedder) { this.embedder = embedder; }
public void add(Document document) {
entries.add(new Entry(document, embedder.embed(document.text())));
}
public List<ScoredDocument> search(String query, int topK) {
double[] q = embedder.embed(query);
return entries.stream()
.map(e -> new ScoredDocument(e.document(), dot(q, e.vector())))
.sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
.limit(topK)
.toList();
}
private static double dot(double[] a, double[] b) {
double sum = 0;
for (int i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
}
Points clés :
embeddivise les tokens selon les caractères non-alphabétiques (\\P{L}+), préservant ainsi les caractères accentués du vietnamien. Chaque token est associé à une dimension du vecteur.- Le vecteur est normalisé à une longueur de 1. Alors le produit scalaire (
dot) est précisément la similitude cosinus. searchparcourt toutes les entrées. C’est une recherche exhaustive, avec une complexité O(n). Une vraie base de données vectorielle utilise des index approximatifs comme HNSW pour maintenir la vitesse avec un grand nombre de vecteurs.
En exécutant :
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi."));
store.add(new Document("shipping", "Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng."));
store.add(new Document("account", "Tài khoản Customer bị khoá sau nhiều lần đăng nhập sai."));
store.search("khách hàng muốn hoàn tiền", 2)
.forEach(r -> System.out.printf("%s -> %.3f%n", r.document().id(), r.score()));
refund -> 0.6xx
shipping -> 0.1xx
Les chiffres exacts dépendent de la fonction de hachage. L’ordre est stable : refund arrive en premier car il partage de nombreux tokens avec la question. Seul ce fragment est inséré dans le prompt, pas les trois.
Note : Un vrai embedding capture la sémantique (« rendre l’argent » est proche de « remboursement »), mais le hashing bag-of-words ne le fait pas. N’utilisez pas cet exemple en production.
En production, si vous utilisez déjà PostgreSQL, l’extension pgvector est généralement une première étape raisonnable avant de considérer une base de données vectorielle dédiée :
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE policy_chunk (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
content TEXT NOT NULL,
embedding vector(768)
);
CREATE INDEX ON policy_chunk USING hnsw (embedding vector_cosine_ops);
-- :query_embedding do ứng dụng truyền vào
SELECT id, content
FROM policy_chunk
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_embedding
LIMIT 5;
Le nombre de dimensions 768 doit correspondre au modèle d’embedding que vous utilisez. L’opérateur <=> calcule la distance cosinus. La clause WHERE tenant_id = ... nous amène à une raison importante pour laquelle la récupération reste nécessaire, discutée à la section 7.
6. Combiner les deux : choisir une stratégie en fonction des données
En pratique, la réponse est souvent « ça dépend du corpus ». Nous pouvons coder cette décision pour que toute l’équipe s’accorde sur les critères.
public enum ContextStrategy { FULL_CONTEXT, RETRIEVAL }
public record CorpusProfile(long totalTokens,
boolean changesFrequently,
boolean needsPerUserAccess) {}
public class ContextStrategySelector {
private final long documentBudgetTokens;
public ContextStrategySelector(long documentBudgetTokens) {
this.documentBudgetTokens = documentBudgetTokens;
}
public ContextStrategy choose(CorpusProfile profile) {
if (profile.needsPerUserAccess()) return ContextStrategy.RETRIEVAL;
if (profile.totalTokens() > documentBudgetTokens) return ContextStrategy.RETRIEVAL;
if (profile.changesFrequently() && profile.totalTokens() > documentBudgetTokens / 2) {
return ContextStrategy.RETRIEVAL; // cache prompt khó tận dụng khi dữ liệu đổi liên tục
}
return ContextStrategy.FULL_CONTEXT;
}
}
Logique par ordre de priorité :
- Si chaque utilisateur ne doit voir qu’une partie des données, vous devez filtrer avant d’insérer dans le prompt. La récupération avec filtrage fait cela naturellement.
- Si le corpus est plus grand que votre budget de tokens, il n’y a pas d’autre choix que la récupération.
- Si les données changent continuellement et occupent déjà une part importante du budget, le prompt caching est peu efficace, donc la récupération est généralement moins chère.
- Pour les autres cas : utilisez le long context pour la simplicité.
Le seuil / 2 n’est qu’un exemple. Vous devriez mesurer vous-même le coût et la qualité sur vos données puis ajuster.
7. Tester le comportement principal
Dépendance de test, si votre projet utilise le parent Spring Boot, la version est déjà gérée :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RetrievalAndStrategyTest {
@Test
void givenRefundQuestion_whenSearch_thenRefundPolicyRanksFirst() {
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "Chính sách hoàn tiền: khách hàng được hoàn tiền khi đơn hàng bị lỗi."));
store.add(new Document("shipping", "Phí vận chuyển được tính theo khối lượng và khoảng cách giao hàng."));
var results = store.search("khách hàng muốn hoàn tiền", 1);
assertThat(results).hasSize(1);
assertThat(results.getFirst().document().id()).isEqualTo("refund");
}
@Test
void givenSmallStaticCorpus_whenChoose_thenFullContext() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(10_000, false, false);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.FULL_CONTEXT);
}
@Test
void givenPerUserAccess_whenChoose_thenRetrievalEvenIfSmall() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(1_000, false, true);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.RETRIEVAL);
}
}
Le troisième test est le plus important sur le plan métier. Même si le corpus est petit, les exigences de contrôle d’accès vous obligent à utiliser la récupération. C’est une contrainte que le long context ne peut pas résoudre.
8. Quand vous avez encore besoin d’une base de données vectorielle, et quand vous n’en avez pas
Vous avez toujours besoin de retrieval (base de données vectorielle ou index vectoriel dans une BD existante) quand :
- Le corpus est plusieurs fois plus grand que la context window, par exemple l’ensemble des tickets d’assistance ou l’ensemble du wiki interne.
- Il y a un contrôle d’accès basé sur l’utilisateur ou le tenant. Vous ne devez pas permettre au modèle de « voir » les données que l’utilisateur n’est pas autorisé à consulter, même seulement dans le prompt.
- Les données se mettent à jour continuellement et doivent être reflétées presque immédiatement.
- Le volume de requêtes est élevé. Envoyer un énorme prompt à chaque requête augmente le coût et la latence proportionnellement aux tokens.
- Vous avez besoin de citations claires, c’est-à-dire savoir sur quel chunk repose la réponse.
Vous pouvez abandonner la base de données vectorielle quand :
- Le corpus est petit, stable et rentre dans le budget de tokens, par exemple l’ensemble des politiques d’un produit ou la documentation d’un module.
- Les questions nécessitent généralement une synthèse de l’ensemble du corpus, comme « résumez les différences entre les deux contrats ». La récupération par top-K est inefficace pour cela.
- Vous prototypez et voulez valider la valeur avant d’investir dans l’infrastructure.
Vous n’avez pas non plus besoin de vector search pour les requêtes exactes comme les numéros de commande, les SKU ou les noms de fonction. La recherche full-text ou les requêtes SQL sont généralement meilleures. De nombreux systèmes utilisent une recherche hybride, combinant les mots-clés et les vecteurs.
9. Erreurs courantes
- Confondre la context window avec le budget de documents. Vous oubliez de réserver de la place pour le system prompt, l’historique et la sortie, ce qui entraîne des demandes tronquées ou des erreurs en production.
- Charger tout dans le prompt sans évaluer la qualité. Plus le prompt est long, plus le modèle risque d’omettre les détails au milieu. Créez un ensemble de questions de test avec des réponses de référence et mesurez sur vos données.
- Construire une base de données vectorielle dédiée trop tôt. Avec plusieurs dizaines de milliers de chunks, un index vectoriel dans PostgreSQL est généralement suffisant, et vous vous évitez d’avoir un système supplémentaire à exploiter.
- Filtrer les permissions après la récupération. Récupérer top-K puis filtrer par permissions peut éliminer tous les résultats valides. Intégrez les conditions d’accès dans la requête elle-même, comme la clause
WHERE tenant_idà la section 5. - Changer de modèle d’embedding sans ré-indexer. Les vecteurs produits par deux modèles différents ne sont pas comparables.
Note : Le long context et la récupération ne s’excluent pas mutuellement. Un modèle courant est une récupération « généreuse », récupérer plus de chunks qu’avant, puis utiliser le long context pour que le modèle fasse lui-même la sélection. Cela réduit le risque de manquer des éléments tout en gardant les coûts sous contrôle.
10. Conclusion
Les context windows plus longs modifient l’équilibre, mais n’éliminent pas le besoin de récupération. Pour un petit corpus stable, « tout charger dans le prompt » est souvent simple et suffisamment bon. Pour les données volumineuses, instables, avec contrôle d’accès ou haut débit, la récupération reste nécessaire. Cependant, ce n’est pas forcément une base de données vectorielle distincte : un index vectoriel dans une BD existante est souvent suffisant.
L’approche pratique consiste à mesurer d’abord, puis à décider. Estimez le nombre de tokens de votre corpus, déterminez les exigences de contrôle d’accès et la fréquence de mise à jour, puis évaluez la qualité sur un vrai ensemble de questions. Lorsque ces critères sont codifiés comme dans ContextStrategySelector, toute l’équipe a une décision claire et testable.