VECTOR_SEARCH, filtres SQL et index
1. La requête
VECTOR_SEARCH(table_de_base, 'colonne', table_requête, top_k, distance_type) compare chaque vecteur de la requête à ceux de la table et renvoie les top_k plus proches.
def build_query(categorie=None, annee_min=None, top_k=4) -> tuple[str, dict]:
where, params = [], {}
if categorie:
if categorie not in CATEGORIES: # ("rh", "finance", "securite")
raise ValueError(f"catégorie inconnue : {categorie!r} (attendu : {', '.join(CATEGORIES)})")
where.append("categorie = @categorie")
params["categorie"] = categorie
if annee_min:
where.append("annee >= @annee_min")
params["annee_min"] = int(annee_min)
base = f"(SELECT * FROM `{TABLE}`" + (f" WHERE {' AND '.join(where)})" if where else ")")
sql = f"""
SELECT base.doc, base.titre, base.annee, base.texte, distance
FROM VECTOR_SEARCH(
{base}, 'embedding',
(SELECT @query_embedding AS embedding),
top_k => {int(top_k)}, distance_type => 'COSINE')
ORDER BY distance"""
return sql, paramsLe résultat expose deux structures, query et base (la ligne trouvée), et la colonne distance. Avec COSINE, 0 = même direction, plus c'est petit plus c'est proche.
flowchart LR P["@query_embedding (768 nombres)"] --> VS["VECTOR_SEARCH"] W["Sous-requête de base : WHERE categorie = @categorie AND annee >= @annee_min"] --> VS VS --> R["top_k lignes : base.*, distance"]
2. Les paramètres, pas la concaténation
qp = [bigquery.ArrayQueryParameter("query_embedding", "FLOAT64", embed([requete], "RETRIEVAL_QUERY")[0])]
qp += [bigquery.ScalarQueryParameter(k, "INT64" if isinstance(v, int) else "STRING", v) for k, v in params.items()]
job = bigquery.Client(project=PROJECT, location=LOCATION).query(sql, job_config=bigquery.QueryJobConfig(
query_parameters=qp, maximum_bytes_billed=MAX_BYTES)) # 100 Mo : au-delà, BigQuery refuseLes valeurs ('rh', 2026, le vecteur) voyagent à côté du SQL : même si le modèle invente rh' OR 1=1 --, ce texte ne peut pas devenir du SQL. Et ici il n'arrive même pas jusqu'à BigQuery : la liste fermée CATEGORIES le refuse avant.
3. Pré-filtre ou post-filtre ?
Où est le WHERE | Effet |
|---|---|
| dans la sous-requête de base (notre cas) | on cherche les top_k parmi les lignes filtrées : on obtient toujours top_k résultats s'il y en a assez |
après VECTOR_SEARCH | on cherche les top_k dans toute la table, puis on filtre : on peut en obtenir moins, voire zéro |
4. Faut-il un index vectoriel ?
Sans index, VECTOR_SEARCH fait une recherche exacte (brute force) : il compare la requête à toutes les lignes. Pour 19 morceaux, c'est instantané et parfaitement exact.
| Situation | Recommandation |
|---|---|
| table < 10 Mo | pas d'index : la doc indique qu'il n'est pas rempli et que la recherche repasse en force brute |
| des centaines de milliers de lignes, requêtes fréquentes | CREATE VECTOR INDEX … OPTIONS(index_type = 'IVF') |
| lots de centaines de vecteurs de requête | TreeAH, optimisé pour les requêtes par lots |
| filtres fréquents avec index | déclarer les colonnes filtrées dans STORING, sinon le filtre devient un post-filtre |
CREATE VECTOR INDEX chunks_idx ON lab_rag_bq.chunks(embedding)
STORING (categorie, annee)
OPTIONS (index_type = 'IVF', distance_type = 'COSINE');Un index rend la recherche approchée (le rappel peut être inférieur à 100 %) et son stockage est facturé tant qu'il existe. À 19 lignes, il n'apporterait que des inconvénients.
Avec un pré-filtre categorie = 'finance' et top_k = 4, combien de lignes reçoit-on si la catégorie compte 4 morceaux ou plus ?
Le filtre est appliqué dans la table de base : VECTOR_SEARCH ne voit que les lignes finance et en renvoie les 4 plus proches. Résultat réel : 4 extraits de la Procédure notes de frais.
Quand créer un index vectoriel sur la table du lab ?
Sous 10 Mo, BigQuery revient à la force brute. Un index se justifie à grand volume ; il rend la recherche approchée et son stockage est facturé.
Exercice : un filtre « hors ancienne version »
Ajoutez à build_query un paramètre exclure_doc (identifiant d'un document à exclure, par exemple politique-teletravail-2024) sans ouvrir de faille d'injection.
Voir la solution
if exclure_doc:
if exclure_doc not in METADATA:
raise ValueError(f"document inconnu : {exclure_doc!r}")
where.append("doc != @exclure_doc")
params["exclure_doc"] = exclure_docDeux protections : la valeur doit exister dans la liste connue (METADATA), et elle passe en paramètre @exclure_doc (le code existant transforme toute valeur texte en ScalarQueryParameter de type STRING). Ajoutez un test qui vérifie "doc != @exclure_doc" in sql.
Résultat obtenu
Exécutions réelles du 2026-10-15 :
$ python bq_rag.py search "nombre de jours de télétravail par semaine"
[BigQuery] 124107 octets lus, filtre=aucun
0.227 Politique de télétravail (v3) (2026) : - Pendant les 4 premières semaines d'un nouvel arrivant, …
0.237 Politique de télétravail (v2, ancienne) (2024) : - Le lundi et le jeudi sont des jours de présence obligatoire.
…
$ python bq_rag.py search "nombre de jours de télétravail par semaine" rh 2026
[BigQuery] 124227 octets lus, filtre={'categorie': 'rh', 'annee_min': 2026}
0.227 Politique de télétravail (v3) (2026) : - Pendant les 4 premières semaines d'un nouvel arrivant, …
0.254 Politique de télétravail (v3) (2026) : Les alternants et stagiaires peuvent télétravailler 1 jour …
$ bq query --dry_run 'SELECT doc, texte FROM `<projet>.lab_rag_bq.chunks` WHERE categorie = "rh"'
Query successfully validated. […] running this query will process 6772 bytes of data.Sans filtre, la v2 de 2024 arrive en 2e position ; avec le filtre, elle disparaît.
Je sais faire
- Écrire un
VECTOR_SEARCHavec distance cosinus ettop_k - Passer filtres et vecteur en paramètres de requête
- Distinguer pré-filtre et post-filtre
- Décider quand créer un index vectoriel et lequel