Semantic search inside BigQuery: embeddings, VECTOR_SEARCH and when to add an index
BigQuery can turn text into embeddings by calling an Agent Platform model and then find the closest rows with plain SQL. Wire up a connection with least-privilege IAM, search a tiny table, weigh index vs brute force, then tear it all down.
BigQuery vector search and embeddings, in plain words
An embedding is a long list of numbers that captures what a piece of text (or an image) means, so two passages about the same idea land close together even when they share no words. BigQuery can create embeddings for a whole column in one SQL statement: you register an embedding model from Gemini Enterprise Agent Platform (formerly Vertex AI), such as text-embedding-005, as a remote model, then call AI.GENERATE_EMBEDDING. Newer helpers include AI.EMBED for one value at a time and autonomous embedding generation, which keeps an embedding column in sync as the source text changes.
To search, VECTOR_SEARCH takes a table of stored embeddings plus one or more query embeddings and returns the top_k nearest rows with their distance. Without an index it does brute force: it compares the query with every row, which is exact but scans the whole column. A vector index (IVF, or TreeAH built on Google's ScaNN algorithm) switches to approximate nearest-neighbor search, trading a little recall for speed on big tables. BigQuery only populates an index once the table is at least 10 MB; below that, searches quietly stay brute force.
The professional decision is where vectors should live. BigQuery is the natural home when the text already sits in your warehouse and searches are analytical or batch, like de-duplicating tickets or finding related products for a nightly job. A dedicated engine such as Agent Platform's Vector Search runs on always-on serving machines for low-latency lookups behind an app, and an operational database with vector support suits search that must change with every transaction. Recent news: on October 21, 2026, BigQuery made AI.CHUNK_DOCUMENT generally available for splitting documents into chunks before embedding, and on October 5 gemini-embedding-2 became generally available for its embedding functions.
Reach for it when
- The documents you want to search (tickets, product descriptions, call notes) already live in BigQuery tables and you would rather not copy them into a separate vector database.
- The searches run in batches or analyses, such as matching every new support ticket to its five most similar past tickets each night, where a few seconds per query is fine.
- You are building a retrieval-augmented generation (RAG) pipeline and want chunking, embedding, search and the model's answer in one governed place, using SQL your data team already knows.
Skip it when
- A user-facing app needs answers in tens of milliseconds at high request rates. A dedicated serving engine such as Agent Platform Vector Search is designed for that.
- Rows change constantly inside transactions and searches must see each write immediately; a database that stores vectors next to the operational data fits better.
- You only need keyword or exact matching (part numbers, error codes). Ordinary
WHEREfilters or BigQuery's text search are cheaper and more predictable than embeddings.
The AI angle
This is where BigQuery meets Gemini Enterprise Agent Platform: the embedding model runs on Agent Platform and BigQuery calls it through a remote model, either with your own credentials or through a Cloud resource connection whose service account holds the Agent Platform User role (roles/aiplatform.user; the role ID kept its old aiplatform name). The same pattern feeds RAG: VECTOR_SEARCH finds the passages and AI.GENERATE_TEXT with a Gemini remote model writes an answer from them, all in one query.
Hands-on lab: Build a tiny semantic FAQ search in BigQuery with an explicit connection
Create a dataset and a ten-row FAQ table, connect BigQuery to an Agent Platform embedding model through a Cloud resource connection with one narrowly scoped IAM grant, embed the rows, run brute-force VECTOR_SEARCH for a question that shares no keywords with its answer, then remove every resource and the IAM binding.
Before you start
- A Google Cloud sandbox project you own with billing enabled (or free-trial credit). Don't use a work project.
- Owner on the project, or BigQuery Admin plus Project IAM Admin (
roles/resourcemanager.projectIamAdmin) and permission to enable APIs. - Your project ID, to replace
YOUR_PROJECT_IDbelow.
Open Cloud Shell, set variables, enable APIs and create a dataset
Choose Activate Cloud Shell in the console. The dataset and connection both live in the
USmulti-region, because a remote model must sit in the same location as its connection. If Cloud Shell reconnects later, run the variable lines again.gcloud config set project YOUR_PROJECT_ID PROJECT_ID=YOUR_PROJECT_ID DS=vs_lab CONN=vs_lab_conn gcloud services enable bigquery.googleapis.com bigqueryconnection.googleapis.com aiplatform.googleapis.com bq mk --dataset --location=US $DSCreate a Cloud resource connection and grant its service account one role
The connection gets its own Google-managed service account. Run
bq show, copy theserviceAccountIdvalue from the output intoSA, then grant that account only Agent Platform User, so BigQuery can call the embedding model and nothing else. IAM changes can take a minute or two to apply.bq mk --connection --location=US --project_id=$PROJECT_ID \ --connection_type=CLOUD_RESOURCE $CONN bq show --connection $PROJECT_ID.us.$CONN SA=PASTE_THE_serviceAccountId_HERE gcloud projects add-iam-policy-binding $PROJECT_ID \ --member="serviceAccount:$SA" --role="roles/aiplatform.user" --condition=NoneLoad a ten-row FAQ table and register the embedding model
The SQL goes into small files and is passed to
bq query, which avoids quoting trouble in the shell. TheCREATE MODELstatement stores no model inside BigQuery; it just records which Agent Platform endpoint (text-embedding-005) to call through your connection. The backticks are escaped because this heredoc expands$PROJECT_ID.cat > faq.sql <<'SQL' CREATE OR REPLACE TABLE vs_lab.faq AS SELECT * FROM UNNEST([ STRUCT(1 AS id, "How do I reset my password if I forgot it?" AS content), (2, "Where can I download last month's invoice?"), (3, "I was billed two times for the same order."), (4, "How long does standard shipping take?"), (5, "Can I change the delivery address after ordering?"), (6, "My parcel arrived damaged."), (7, "How do I turn on two-step verification?"), (8, "Do you ship to Canada and Mexico?"), (9, "How do I cancel my subscription?"), (10, "The app crashes when I open the camera.") ]); SQL bq query --use_legacy_sql=false "$(cat faq.sql)" cat > model.sql <<SQL CREATE OR REPLACE MODEL vs_lab.embed_model REMOTE WITH CONNECTION \`$PROJECT_ID.us.$CONN\` OPTIONS (ENDPOINT = 'text-embedding-005'); SQL bq query --use_legacy_sql=false "$(cat model.sql)"Embed every row with AI.GENERATE_EMBEDDING
The function reads a column that must be called
contentand returns it with anembeddingarray and astatusstring (empty when the row succeeded). The second query checks that all ten rows came back with a vector and shows how many numbers each one has. If you see a permission error, the IAM grant from step 2 is still propagating; wait a minute and run it again.cat > embed.sql <<'SQL' CREATE OR REPLACE TABLE vs_lab.faq_emb AS SELECT * FROM AI.GENERATE_EMBEDDING( MODEL vs_lab.embed_model, (SELECT id, content FROM vs_lab.faq)); SQL bq query --use_legacy_sql=false "$(cat embed.sql)" bq query --use_legacy_sql=false \ 'SELECT COUNT(*) AS rows_ok, MAX(ARRAY_LENGTH(embedding)) AS dims FROM vs_lab.faq_emb WHERE LENGTH(status) = 0'Search by meaning with brute-force VECTOR_SEARCH
The question shares almost no words with FAQ 3, yet it should rank first.
use_brute_forceforces an exact scan; on a ten-row table BigQuery would do that anyway, because an index is only populated once the table reaches 10 MB. Smallerdistancemeans closer. Try your own wording by editingsearch.sql.cat > search.sql <<'SQL' SELECT query.query, base.id, base.content, distance FROM VECTOR_SEARCH( TABLE vs_lab.faq_emb, 'embedding', (SELECT embedding, content AS query FROM AI.GENERATE_EMBEDDING( MODEL vs_lab.embed_model, (SELECT 'my card got charged twice' AS content))), top_k => 3, distance_type => 'COSINE', options => '{"use_brute_force":true}'); SQL bq query --use_legacy_sql=false "$(cat search.sql)"
Check that it worked
Step 4 shows rows_ok = 10 and a non-zero dims. Step 5 returns three rows with id 3 ("billed two times") at the top and the lowest distance. An error such as Not found: Connection usually means the connection and dataset are in different locations, and a permission error mentioning aiplatform means the step 2 grant hasn't applied yet or went to the wrong account.
Clean up
Remove things in reverse order: the dataset (with its tables and model), then the IAM binding, then the connection, then the local SQL files. Remove the binding before the connection, while you still know which service account it belongs to. Finally, check that nothing is left: the dataset and connection lists should not show the lab names and the IAM query should print nothing. Disabling the APIs is optional and only sensible in a sandbox where nothing else uses them.
bq rm -r -f -d $PROJECT_ID:$DS
gcloud projects remove-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$SA" --role="roles/aiplatform.user" --condition=None
bq rm --connection --location=us $CONN
rm -f faq.sql model.sql embed.sql search.sql
# confirm nothing is left
bq ls
bq ls --connection --project_id=$PROJECT_ID --location=US
gcloud projects get-iam-policy $PROJECT_ID \
--flatten="bindings[].members" --filter="bindings.members:$SA" --format="value(bindings.role)"
# optional, sandbox only:
# gcloud services disable bigqueryconnection.googleapis.com
# optional, practice-only project:
# gcloud projects delete YOUR_PROJECT_IDGotchas
- The remote model, its connection and the dataset must share one location. A dataset in
UScannot use a connection created inus-central1, and the embedding endpoint must be offered in that location too. - Embeddings from different models (or different versions of one model) can't be compared. Embed the stored rows and the query with the same model, and re-embed everything when you switch to something like
gemini-embedding-2. - Vector indexes only kick in on tables of 10 MB or more and aren't supported in the Standard edition, so a demo-sized table is always brute force. Check
INFORMATION_SCHEMA.VECTOR_INDEXESfor status and coverage before trusting latency numbers. - With on-demand pricing, every search is billed on bytes scanned from the base table, the index and the query. Store only the columns you need in the index, and filter on partitioned or stored columns so pre-filters prune instead of scanning the whole table.
- Using the
DEFAULTconnection is convenient, but BigQuery grants roles to its service account automatically, and anyone allowed to use that connection inherits them. An explicit connection with one documented grant is easier to audit.