Project name: [fill in] Team members: [fill in] Target tier: [80 / 87 / 100] Capstone (if tier 100): the Analyst's Agent (a governed text-to-SQL agent over your own vocabulary)
Build the production data stack a 2026 data team ships before lunch: Spark ELT, vector embeddings in BigQuery, a partitioned warehouse, a calibrated model. By Nov 20 you have a portfolio piece that covers the full pipeline from ingest to insight.
This file covers all rubric sections. Each section has its rubric, its expected output, and a "what good looks like" note. Read the section header before you start.
| Section | Title | Points | Tier |
|---|---|---|---|
| Phase 0 | Spark + Wikipedia + keyword join (proposal, Oct 30) | 10 | all |
| Phase 1 | Embed slice in BQ + semantic join | 10 | all |
| 1 | 6 non-simple queries + 3 viz (2+ use Wiki layer) | 20 | all |
| 2 | BQML model + eval | 15 | all |
| 3 | Scale math + query plans | 10 | all |
| 4 | Writeup + AI takeaways + demo | 15 | all |
| Tier 80 total | 80 | ||
| 5 | Warehouse layout (≥ 5 GB fact layer) | 7 | 87+ |
| Tier 87 total | 87 | ||
| 6 | Production retrieval at scale (1 GB embed + vector index + hybrid query) | 6 | 93+ |
| Tier 93 total | 93 | ||
| 7 | Capstone: the Analyst's Agent (governed text-to-SQL) | 7 | 100 |
| Tier 100 total | 100 |
Plus +5 bonus for storytelling quality at Tier 93+.
Tier IS scope. Each tier adds one section to the previous tier; the points add up to the ceiling.
Run these once. Everything below uses what's set up here.
# Authenticate to Google Cloud and configure your project.
from google.colab import auth
auth.authenticate_user()
import os
PROJECT_ID = "your-gcp-project-id" # FILL IN
DATASET_ID = "cs145_p2" # FILL IN; must exist in PROJECT_ID
LOCATION = "US"
os.environ['GOOGLE_CLOUD_PROJECT'] = PROJECT_ID
from google.cloud import bigquery
bq = bigquery.Client(project=PROJECT_ID, location=LOCATION)
print(f"Using project={PROJECT_ID}, dataset={DATASET_ID}, location={LOCATION}")
Call estimate_query_cost(sql) on any query before you run it. Watch the dollar number, especially on anything that scans more than 1 GB.
def estimate_query_cost(sql: str) -> dict:
"""Dry-run a query; return bytes scanned + USD estimate."""
job_config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
job = bq.query(sql, job_config=job_config)
bytes_processed = job.total_bytes_processed
# On-demand pricing: $6.25 / TB scanned (us multi-region).
cost_usd = bytes_processed / 1e12 * 6.25
return {
"bytes": bytes_processed,
"gb": round(bytes_processed / 1e9, 3),
"cost_usd": round(cost_usd, 4),
}
# Quick sanity check (free; no charge for dry-run).
example_sql = f"SELECT COUNT(*) FROM `{PROJECT_ID}.{DATASET_ID}.INFORMATION_SCHEMA.TABLES`"
print(estimate_query_cost(example_sql))
Four worked recipes for filtering Wikipedia to a project-relevant domain. Pick the closest one and edit the keyword set. Document your filter precision later (Step 0.4).
# Recipe A: FHIR / healthcare
FHIR_KEYWORDS = {
"diabetes", "hypertension", "asthma", "copd", "myocardial",
"stroke", "ischemic", "anemia", "hyperlipidemia", "obesity",
"depression", "anxiety", "psoriasis", "arthritis", "pneumonia",
"metformin", "insulin", "statin", "lisinopril", "amlodipine",
"icd-10", "snomed", "rxnorm", "loinc", "fhir",
"cardiology", "oncology", "neurology", "pulmonology", "endocrinology",
}
# Recipe B: NYC taxi / urban
NYC_KEYWORDS = {
"manhattan", "brooklyn", "queens", "the bronx", "staten island",
"harlem", "soho", "tribeca", "chelsea", "midtown",
"jfk airport", "laguardia airport", "new york city subway",
"metropolitan transportation authority", "yellow cab",
"new york city neighborhood", "new york city street",
}
# Recipe C: Finance / public companies
FINANCE_KEYWORDS = {
"new york stock exchange", "nasdaq", "s&p 500", "ipo",
"dividend", "earnings", "merger", "acquisition", "hedge fund",
"fortune 500", "sec filing", "10-k", "balance sheet",
"p/e ratio", "market capitalization",
}
# Recipe D: Music
MUSIC_KEYWORDS = {
"billboard hot 100", "grammy award", "rolling stone",
"album", "single", "discography", "music genre", "record label",
"spotify", "apple music", "musician", "band",
}
# Pick one (or define your own keyword set):
MY_KEYWORDS = FHIR_KEYWORDS # CHANGE THIS
print(f"Using filter with {len(MY_KEYWORDS)} keywords")
What you ship in Phase 0 (banked on-time, forfeit if late):
Spark filter pulled Wikipedia down to ~1 GB of in-domain articles.
Slice loaded into BigQuery next to your existing tables.
One keyword join query between your dataset and the wiki slice.
Filter precision sample (~30 articles checked by hand).
Parity check against bigquery-public-data.samples.wikipedia.
One viz from your join.
Proposal writeup at the bottom of this section.
!pip install -q pyspark==3.5.0 datasets pyarrow
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("cs145-p2-wiki-elt")
.config("spark.driver.memory", "8g")
.config("spark.sql.execution.arrow.pyspark.enabled", "true")
.getOrCreate()
)
print("Spark version:", spark.version)
The full English Wikipedia is ~22 GB compressed parquet across many files. We download the parquet files directly from HuggingFace Hub, then point Spark at the directory. Do not call load_dataset(...) and spark.createDataFrame(ds): that materializes the entire 22 GB through pandas and will crash Colab. Spark reads parquet natively in parallel.
# Download parquet files from HuggingFace Hub (no pandas roundtrip).
!pip install -q huggingface_hub
from huggingface_hub import snapshot_download
LOCAL_WIKI_DIR = snapshot_download(
repo_id="wikimedia/wikipedia",
allow_patterns=["20231101.en/*.parquet"],
repo_type="dataset",
)
print("Wikipedia parquet downloaded to:", LOCAL_WIKI_DIR)
# Spark reads the parquet directory in parallel.
from pyspark.sql.functions import udf, col, lower
from pyspark.sql.types import BooleanType, StringType
import re
wiki = spark.read.parquet(f"{LOCAL_WIKI_DIR}/20231101.en")
print("Raw articles in dump:", wiki.count())
# Filter: keep articles whose title or first 2k chars mention a domain keyword.
# (UDFs are slower than SQL `like`; for large keyword sets, consider rewriting
# this as a regex-based filter on lower(title) and lower(substr(text, 1, 2000)).)
def in_domain(title, text):
if not title or not text: return False
t = title.lower()
body = text[:2000].lower()
return any(k in t or k in body for k in MY_KEYWORDS)
in_domain_udf = udf(in_domain, BooleanType())
def first_paragraph(text):
if not text: return ""
parts = re.split(r"\n\n", text, maxsplit=2)
return (parts[0] if parts else "")[:2000]
first_para_udf = udf(first_paragraph, StringType())
slim = (
wiki.filter(in_domain_udf(col("title"), col("text")))
.select(
col("id").alias("article_id"),
col("title"),
col("url"),
first_para_udf(col("text")).alias("first_paragraph"),
)
)
# Checkpoint to Colab disk as a single parquet file (coalesce(1)) so the
# downstream BQ load is one upload, not N. For 1 GB this is fine; if you
# scale up at tier 87+, drop coalesce(1) and load each part separately.
CHECKPOINT_DIR = "/content/wiki_domain.parquet"
slim.coalesce(1).write.mode("overwrite").parquet(CHECKPOINT_DIR)
n_articles = spark.read.parquet(CHECKPOINT_DIR).count()
print(f"Kept {n_articles:,} in-domain articles")
BQ load jobs are free. We don't need GCS as an intermediate; the Python client loads parquet straight from local disk.
TABLE_WIKI_DOMAIN = f"{PROJECT_ID}.{DATASET_ID}.wiki_domain"
# Spark wrote a single part file because we used coalesce(1).
from pathlib import Path
parts = sorted(Path(CHECKPOINT_DIR).glob("part-*.parquet"))
assert len(parts) == 1, f"expected one part file (coalesce(1)); got {len(parts)}"
job_config = bigquery.LoadJobConfig(
source_format=bigquery.SourceFormat.PARQUET,
write_disposition="WRITE_TRUNCATE",
)
with open(parts[0], "rb") as f:
job = bq.load_table_from_file(f, TABLE_WIKI_DOMAIN, job_config=job_config)
job.result()
print(f"Loaded to {TABLE_WIKI_DOMAIN}")
print(bq.get_table(TABLE_WIKI_DOMAIN))
Sample ~30 articles from your slice. Eyeball each. How many are actually in your domain? Report the hit rate.
sql = f"""
SELECT article_id, title, SUBSTR(first_paragraph, 1, 200) AS preview
FROM `{TABLE_WIKI_DOMAIN}`
ORDER BY RAND()
LIMIT 30
"""
sample = bq.query(sql).to_dataframe()
sample
Hit rate writeup: I sampled 30 articles. N of them are clearly in-domain (good). M are off-topic but plausibly related (acceptable). K are noise (bad). My filter precision is N / 30 = ?. [FILL IN AND COMMENT ON WHAT YOU'D CHANGE.]
Write one query joining your fact table with wiki_domain. Join key depends on your domain: condition names, neighborhood names, ticker symbols, artist names, etc.
sql = f"""
-- FILL IN: replace this with a real join between your fact table and wiki_domain.
-- Example pattern (FHIR):
-- SELECT c.condition_name, COUNT(w.article_id) AS wiki_coverage
-- FROM `{PROJECT_ID}.{DATASET_ID}.fhir_conditions` c
-- LEFT JOIN `{TABLE_WIKI_DOMAIN}` w
-- ON LOWER(w.title) LIKE CONCAT('%', LOWER(c.condition_name), '%')
-- GROUP BY c.condition_name
-- ORDER BY wiki_coverage DESC
SELECT COUNT(*) AS row_count FROM `{TABLE_WIKI_DOMAIN}`
"""
print("Cost preview:", estimate_query_cost(sql))
df_join = bq.query(sql).to_dataframe()
df_join.head(20)
Run the same keyword test against bigquery-public-data.samples.wikipedia (a public ~314M row sample of Wikipedia revisions with a title column). Title-count ratios should match between your slice and the public sample once you align case and keyword set. If they disagree wildly, your filter is wrong.
# Compare title-keyword hit counts: my slice vs public sample.
parity_sql = f"""
WITH keyword_hits AS (
SELECT 'mine' AS source, COUNT(*) AS hits
FROM `{TABLE_WIKI_DOMAIN}`
WHERE LOWER(title) LIKE '%diabetes%' -- replace with one of YOUR keywords
UNION ALL
SELECT 'public' AS source, COUNT(*) AS hits
FROM `bigquery-public-data.samples.wikipedia`
WHERE LOWER(title) LIKE '%diabetes%'
)
SELECT * FROM keyword_hits
"""
print("Cost preview:", estimate_query_cost(parity_sql))
df_parity = bq.query(parity_sql).to_dataframe()
df_parity
Parity finding: my slice has X matches for "diabetes"; the public dataset has Y. Ratio is X/Y. [ONE SENTENCE ON WHETHER THIS MATCHES EXPECTATIONS.]
import matplotlib.pyplot as plt
# FILL IN with your real join result.
df_join.plot(kind="bar", x=df_join.columns[0], y=df_join.columns[1], figsize=(10, 5))
plt.title("Phase 0: your dataset × Wikipedia keyword join")
plt.tight_layout()
plt.show()
One markdown cell. Submit to Gradescope by Oct 30. Worth 10 pts banked on-time.
Dataset and starting point. I am extending [my Project 1 notebook / partner's Project 1 notebook / fresh dataset]. It covers [domain].
Filter design. I used Recipe [A/B/C/D] with [N] keywords. Filter precision on a 30-article sample is [N/30].
Phase 0 finding. One paragraph on what the keyword join surfaced. What surprised you?
Target tier. [80 / 87 / 100]
Capstone (if tier 100). You build the Analyst's Agent: a governed text-to-SQL agent over your own vocabulary of table functions. [Note the kinds of business questions you expect it to answer.]
Partner divvy (if applicable). [Names + section ownership]
Compute embeddings on your filtered slice in BigQuery, build a vector index (if your slice is big enough), and run one semantic join query.
Cost budget for this phase: $1 to $3. Embed first paragraphs only; cap at ~100 MB of text total.
Set up a Vertex AI connection (one-time) and register the embedding model. If you don't have a connection yet, run the bq CLI command in the cell below first.
# One-time: create a Vertex AI connection. Skip if already done.
# Run this in a separate terminal or via shell:
# !bq mk --connection --display_name="vertex_ai_connection" \
# --connection_type=CLOUD_RESOURCE --project_id=$PROJECT_ID --location=US vertex_ai_connection
create_model_sql = f"""
CREATE OR REPLACE MODEL `{PROJECT_ID}.{DATASET_ID}.embed_model`
REMOTE WITH CONNECTION `{PROJECT_ID}.us.vertex_ai_connection`
OPTIONS(endpoint = 'text-embedding-005')
"""
bq.query(create_model_sql).result()
print("embed_model created")
Honest cost math:
30K articles × ~500 chars (first paragraph) ≈ $0.40
100K articles × ~500 chars ≈ $1.25
100K articles × full text (~3k chars) ≈ $7.50
Default: first paragraphs only.
embed_sql = f"""
CREATE OR REPLACE TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded` AS
SELECT
article_id, title, url, first_paragraph,
ml_generate_embedding_result AS embedding,
ml_generate_embedding_statistics
FROM ML.GENERATE_EMBEDDING(
MODEL `{PROJECT_ID}.{DATASET_ID}.embed_model`,
(SELECT article_id, title, url, first_paragraph,
first_paragraph AS content
FROM `{TABLE_WIKI_DOMAIN}`)
)
WHERE ml_generate_embedding_status = '' -- successful only
"""
print("Cost preview:", estimate_query_cost(embed_sql))
print("Running embedding job. This takes a few minutes for ~50K rows.")
bq.query(embed_sql).result()
print("Done. Embeddings landed in wiki_domain_embedded.")
BQ vector indexes need ≥ 5 GB of vector data to build. For a Phase 1 slice (~300 MB), skip the index; brute-force VECTOR_SEARCH works fine at this scale. Revisit at tier 87+ when your fact layer crosses 5 GB.
# Optional, only if your embedded table is ≥ 5 GB:
# CREATE OR REPLACE VECTOR INDEX wiki_idx
# ON `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded`(embedding)
# OPTIONS(distance_type='COSINE', index_type='IVF')
Embed a query text (or a column from your fact table), then VECTOR_SEARCH against your slice. The pattern below joins to your dataset; adapt the inner SELECT to your domain.
semantic_sql = f"""
-- VECTOR_SEARCH returns columns: query.*, base.*, distance.
-- The query CTE supplies one or more (query_text, embedding) rows; each is
-- ranked against the base table.
WITH query_embeddings AS (
SELECT
query_text,
ml_generate_embedding_result AS embedding
FROM ML.GENERATE_EMBEDDING(
MODEL `{PROJECT_ID}.{DATASET_ID}.embed_model`,
(
-- FILL IN: replace with the query strings you actually want to test.
SELECT 'diabetes type 2' AS content, 'diabetes type 2' AS query_text
UNION ALL
SELECT 'metformin dosing', 'metformin dosing'
)
)
)
SELECT
query.query_text,
base.title,
base.url,
distance
FROM VECTOR_SEARCH(
TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded`,
'embedding',
TABLE query_embeddings,
top_k => 5,
distance_type => 'COSINE'
)
ORDER BY query.query_text, distance ASC
"""
print("Cost preview:", estimate_query_cost(semantic_sql))
df_sem = bq.query(semantic_sql).to_dataframe()
df_sem
Phase 1 finding: Two or three sentences on what the semantic join surfaced that the keyword join in Phase 0 missed (or vice versa).
Six queries total. At least 2 must use the Wiki layer (keyword join, semantic join, or both). Three of the six should have visualizations.
"Non-simple" = combines 2+ of: JOINs, CTEs, window functions, subqueries, complex CASE, VECTOR_SEARCH.
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 1: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q1 = bq.query(sql).to_dataframe()
df_q1.head(20)
# Visualization for Query 1 (delete this cell if no viz).
import matplotlib.pyplot as plt
# FILL IN: chart df_q1.
Analysis: what does the result and visualization tell you?
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 2: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q2 = bq.query(sql).to_dataframe()
df_q2.head(20)
# Visualization for Query 2 (delete this cell if no viz).
import matplotlib.pyplot as plt
# FILL IN: chart df_q2.
Analysis: what does the result and visualization tell you?
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 3: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q3 = bq.query(sql).to_dataframe()
df_q3.head(20)
# Visualization for Query 3 (delete this cell if no viz).
import matplotlib.pyplot as plt
# FILL IN: chart df_q3.
Analysis: what does the result and visualization tell you?
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 4: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q4 = bq.query(sql).to_dataframe()
df_q4.head(20)
Analysis: what does the result and visualization tell you?
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 5: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q5 = bq.query(sql).to_dataframe()
df_q5.head(20)
Analysis: what does the result and visualization tell you?
What you want to investigate. What techniques does this query use?
sql = f"""
-- Query 6: FILL IN
SELECT 1
"""
print("Cost preview:", estimate_query_cost(sql))
df_q6 = bq.query(sql).to_dataframe()
df_q6.head(20)
Analysis: what does the result and visualization tell you?
Train one model with BigQuery ML. Linear or logistic regression at minimum. Boosted tree or matrix factorization fine. You may use embeddings as features (a smart move).
What we're looking for:
Prediction target is product-relevant and not trivially derivable.
Train/test split is clean (no leakage).
Metrics match the problem (R² for regression, precision/recall for imbalanced classification).
One paragraph interpreting the result.
What are you predicting? Why is it useful? Which BQML model type fits?
training_sql = f"""
-- FILL IN: build the training table. Include features, target, split column.
SELECT 1 AS feature1, 0 AS target, IF(RAND() < 0.8, 'TRAIN', 'TEST') AS split
"""
print("Cost preview:", estimate_query_cost(training_sql))
train_sql = f"""
CREATE OR REPLACE MODEL `{PROJECT_ID}.{DATASET_ID}.my_model`
OPTIONS(
model_type = 'linear_reg', -- or 'logistic_reg', 'boosted_tree_*'
input_label_cols = ['target'],
data_split_col = 'split',
data_split_method = 'CUSTOM'
) AS
SELECT * FROM ({training_sql})
WHERE split = 'TRAIN'
"""
# bq.query(train_sql).result()
eval_sql = f"""
SELECT * FROM ML.EVALUATE(
MODEL `{PROJECT_ID}.{DATASET_ID}.my_model`,
(SELECT * FROM ({training_sql}) WHERE split = 'TEST')
)
"""
# bq.query(eval_sql).to_dataframe()
Run ML.PREDICT on a few held-out rows. One paragraph: what do the predictions actually mean for your domain? What would a product manager do with them?
Pick two of your complex queries. For each:
Run it and pull the query plan from BigQuery Query History → Execution details.
Report stage timings, bytes processed, rows shuffled.
Compute I/O costs at 10x and 100x scale using the equation sheet.
| Operator | Formula |
|---|---|
| BigSort | 2 × P(R) × (1 + ⌈log_{B-1}(P(R)/B)⌉) |
| BNLJ | P(R) + P(R) × P(S) / B |
| SMJ | 3 × (P(R) + P(S)) |
| HPJ | 3 × (P(R) + P(S)) |
# Paste one of your complex queries here.
sql_q1_plan = "SELECT 1"
print("Dry-run cost:", estimate_query_cost(sql_q1_plan))
# bq.query(sql_q1_plan).result()
Query plan screenshot: paste from BigQuery Query History → Execution details.
Stage breakdown: largest stage, time spent, rows in/out.
I/O cost at 10× scale: formula application, result.
I/O cost at 100× scale: formula application, result. Suggest one optimization.
# Paste one of your complex queries here.
sql_q2_plan = "SELECT 1"
print("Dry-run cost:", estimate_query_cost(sql_q2_plan))
# bq.query(sql_q2_plan).result()
Query plan screenshot: paste from BigQuery Query History → Execution details.
Stage breakdown: largest stage, time spent, rows in/out.
I/O cost at 10× scale: formula application, result.
I/O cost at 100× scale: formula application, result. Suggest one optimization.
Short and informal. Half a page total, not a report.
Key learnings from the project, limitations of your analysis, and one or two ideas for future work. 3-4 sentences each.
2 to 3 short anecdotes. A few sentences each. At least one should be a surprise (AI got something wrong and you caught it, or AI nailed something unexpectedly).
Example shapes that tend to work:
"AI suggested a Spark schema that silently dropped null titles. I caught it when my filter hit rate plummeted between dev and prod."
"AI's first cut at my VECTOR_SEARCH query used the wrong embedding model for the query side, returning irrelevant matches."
"AI suggested logistic regression for a continuous target. I caught it when ML.EVALUATE returned accuracy instead of R²."
Pick one piece of your pipeline (a complex query, your embedding step, your model). In 2-3 sentences, explain what it does to a reader who doesn't know SQL or ML.
Paste the link here. Record a 5-minute screen capture: 2 min of your pipeline running end-to-end, 3 min walking through your code.
If you are aiming for Tier 80, you are done after this section. Tier 87 students continue to Section 5. Tier 93 students continue to Section 6. Tier 100 students continue through Section 7.
Requires fact layer ≥ 5 GB. If your current wiki_domain slice is 1 GB, scale your Spark ELT up before attempting this section. Partitioning a 1 GB table teaches almost nothing.
What you ship:
Fact layer ≥ 5 GB (extended Spark ELT, or added a second source).
Partition key + cluster keys on the fact table.
Materialized view for an ML feature.
Before/after benchmark on one query (bytes scanned, $/query).
Re-run your Spark ELT with a broader filter (or a second domain corpus, or the full Wikipedia language subset). Land the result back in BigQuery as wiki_domain_large.
# FILL IN: rerun your Spark filter with a broader keyword set or no filter
# at all. Target ≥ 5 GB on disk after BQ load.
Partition on a date / time column most of your queries filter on. Cluster on 1-4 categorical columns most of your queries group or filter on.
Document your choice: which key is partition, which are cluster, and why.
layout_sql = f"""
CREATE OR REPLACE TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_domain_partitioned`
PARTITION BY DATE_TRUNC(load_date, MONTH) -- FILL IN: choose your column
CLUSTER BY category, language -- FILL IN: 1-4 columns
AS
SELECT * FROM `{PROJECT_ID}.{DATASET_ID}.wiki_domain_large`
"""
# bq.query(layout_sql).result()
mv_sql = f"""
CREATE OR REPLACE MATERIALIZED VIEW `{PROJECT_ID}.{DATASET_ID}.feature_v` AS
SELECT
-- FILL IN: precomputed aggregate that your ML model uses.
article_id,
COUNT(*) AS link_count
FROM `{PROJECT_ID}.{DATASET_ID}.wiki_domain_partitioned`
GROUP BY article_id
"""
# bq.query(mv_sql).result()
Pick one query. Run it against the unpartitioned table and the partitioned+clustered table. Report bytes scanned and $/query for both.
before_sql = "-- FILL IN: query against wiki_domain_large (no partition/cluster)"
after_sql = "-- FILL IN: same query against wiki_domain_partitioned"
print("Before:", estimate_query_cost(before_sql))
print("After: ", estimate_query_cost(after_sql))
Benchmark writeup: bytes before / after, $/query before / after, ratio. One sentence on why the partition key worked (or didn't).
Requires Section 5 (warehouse layout) complete. Section 6 takes Phase 1's brute-force VECTOR_SEARCH and turns it into a real production retrieval layer with an index and hybrid scoring, at a scale where it actually pays off.
What you ship:
Scale up the embed pass to ~1 GB of text (vs. Phase 1's ~100 MB cap). Expect $8 to $15 of BQ credit.
Build a real BQ vector index on the scaled embedded table.
Run one hybrid query (keyword + vector) using reciprocal-rank fusion or weighted-score fusion.
Compare latency and bytes scanned between indexed and brute-force VECTOR_SEARCH on the same query.
Re-run ML.GENERATE_EMBEDDING on a broader slice. Two ways to hit ~1 GB:
Broader filter, first-paragraphs only (cheaper per article, ~1-2 M articles).
Narrow filter, full text (~300 K articles).
Either path costs roughly the same ($8 to $15). Pick what fits your domain. Land the result in a new table so you can A/B against the Phase 1 slice if you want.
scaled_embed_sql = f"""
CREATE OR REPLACE TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded_1gb` AS
SELECT
article_id, title, url, first_paragraph,
ml_generate_embedding_result AS embedding
FROM ML.GENERATE_EMBEDDING(
MODEL `{PROJECT_ID}.{DATASET_ID}.embed_model`,
(SELECT article_id, title, url, first_paragraph,
first_paragraph AS content
FROM `{PROJECT_ID}.{DATASET_ID}.wiki_domain_large`) -- FILL IN: broader slice
)
WHERE ml_generate_embedding_status = ''
"""
print("Cost preview:", estimate_query_cost(scaled_embed_sql))
print("This will charge $8 to $15. Confirm before running.")
# bq.query(scaled_embed_sql).result()
With ~1 GB of vector data (~5 GB embedded matrix at 768d FLOAT64), the BQ vector index will build. Pick IVF (cheap, good for cosine) or Tree-AH (better for L2). Wait for the index to be READY before querying.
vec_index_sql = f"""
CREATE OR REPLACE VECTOR INDEX wiki_idx_1gb
ON `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded_1gb`(embedding)
OPTIONS(distance_type = 'COSINE', index_type = 'IVF')
"""
# bq.query(vec_index_sql).result()
# Poll until the index is READY:
status_sql = f"""
SELECT index_name, coverage_percentage, last_refresh_time
FROM `{PROJECT_ID}.{DATASET_ID}.INFORMATION_SCHEMA.VECTOR_INDEXES`
WHERE table_name = 'wiki_domain_embedded_1gb'
"""
# bq.query(status_sql).to_dataframe()
Combine BM25-style keyword matching with vector similarity in a single SQL statement. Reciprocal-rank fusion (RRF) is the simplest defensible fuser. Document your weighting or fusion choice.
hybrid_sql = f"""
-- Hybrid retrieval: keyword candidates + vector candidates, fused by reciprocal rank.
WITH query_embedding AS (
SELECT ml_generate_embedding_result AS embedding
FROM ML.GENERATE_EMBEDDING(
MODEL `{PROJECT_ID}.{DATASET_ID}.embed_model`,
(SELECT 'diabetes type 2' AS content))
),
vec AS (
SELECT base.article_id, base.title, distance,
ROW_NUMBER() OVER (ORDER BY distance) AS vec_rank
FROM VECTOR_SEARCH(
TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_domain_embedded_1gb`, 'embedding',
TABLE query_embedding,
top_k => 50, distance_type => 'COSINE')
),
kw AS (
SELECT article_id, title,
ROW_NUMBER() OVER (ORDER BY LENGTH(title)) AS kw_rank
FROM `{PROJECT_ID}.{DATASET_ID}.wiki_domain_large`
WHERE LOWER(title) LIKE '%diabetes%' -- FILL IN: align with query
LIMIT 50
)
SELECT
COALESCE(vec.article_id, kw.article_id) AS article_id,
COALESCE(vec.title, kw.title) AS title,
1.0/(60 + COALESCE(vec.vec_rank, 1000)) +
1.0/(60 + COALESCE(kw.kw_rank, 1000)) AS rrf_score
FROM vec FULL OUTER JOIN kw USING (article_id, title)
ORDER BY rrf_score DESC
LIMIT 10
"""
print("Cost preview:", estimate_query_cost(hybrid_sql))
df_hybrid = bq.query(hybrid_sql).to_dataframe()
df_hybrid
Run your hybrid query twice: once with the index (default), once forcing brute-force (options => '{"use_brute_force": true}' on VECTOR_SEARCH). Compare wall time and bytes scanned. Report both numbers. If brute force wins at 1 GB, document it; the crossover point is itself a finding.
# Brute-force version: same query, but force VECTOR_SEARCH to bypass the index.
brute_sql = hybrid_sql.replace(
"top_k => 50, distance_type => 'COSINE'",
"top_k => 50, distance_type => 'COSINE', options => '{\"use_brute_force\": true}'"
)
import time
t0 = time.time(); bq.query(hybrid_sql).result(); t_indexed = time.time() - t0
t0 = time.time(); bq.query(brute_sql).result(); t_brute = time.time() - t0
print(f"Indexed: {t_indexed:.2f}s, {estimate_query_cost(hybrid_sql)}")
print(f"Brute-force: {t_brute:.2f}s, {estimate_query_cost(brute_sql)}")
Section 6 writeup: Indexed query was N seconds, brute-force was M seconds. Bytes scanned: X vs Y. Which won, and why? At what scale would the other approach win?
Requires Section 6 complete. (Hours are the Section 7 rung of the tier table, counted per person.)
Build a governed text-to-SQL agent on the warehouse you built, and measure how much your curation lifts a bare model. This is the Module 1B recipe on your own data.
You write the text-to-SQL agent, and it must answer by composing your stored table functions, not by re-deriving SQL from scratch. Four pieces below: the vocabulary (table functions), the manifest (what you hand the model), the agent loop, and the eval.
Define 6 to 8 business terms as BigQuery table functions in your dataset. Each is named, parameterized, and callable, and pins the subtle choices in its body (time window, NULL handling). These are what the agent composes.
# FILL IN: define your vocabulary as BigQuery table functions (6 to 8 terms).
# Uses bq, PROJECT_ID, DATASET_ID from the Substrate cell at the top.
DS = f"{PROJECT_ID}.{DATASET_ID}"
bq.query(f"""
CREATE OR REPLACE TABLE FUNCTION `{DS}.listener_of`(artist STRING) AS (
SELECT DISTINCT l.user_id
FROM `{DS}.Listens` l JOIN `{DS}.Songs` s USING (song_id)
WHERE s.artist = artist
)
""").result()
# TODO: active_since(days INT64) -- pin: UTC calendar days, not rolling 24h; NULL time is not active
# TODO: liked(min_rating FLOAT64) -- pin: NULL rating is not a like
# ... 6 to 8 terms. Each one is a decision you can defend.
The model does not discover your functions. Give it a manifest: each function's name, parameters, and a one-line description, plus the schema and the join policy. This is the context the agent reads on every question.
# FILL IN: the manifest the agent puts in the model context.
MANIFEST = """
Compose these BigQuery table functions by name; prefer them over raw SQL.
- listener_of(artist STRING): users who played >= 1 song by that artist.
- active_since(days INT64): users with a listen in the last N UTC calendar days.
- liked(min_rating FLOAT64): (user, song) rated >= min_rating; a NULL rating is not a like.
# ... one line per function ...
Schema: <paste your tables and columns>
Join policy: the safe joins are the foreign keys (Listens.user_id = Users.user_id,
Listens.song_id = Songs.song_id). Never join user_id to song_id (both INT, meaningless).
Never join on rating or listen_time.
"""
Write the loop. Per question: assemble the context (manifest + schema + join policy + the question), ask the model for BigQuery SQL that composes your table functions, run it, return the rows. The model writes raw SQL only when no term fits, and it must obey the join policy. You are not training anything; the model is a swappable call.
# FILL IN: the text-to-SQL agent. It MUST compose your stored table functions.
def agent_sql(question, grounded=True):
context = MANIFEST if grounded else "" # the "bare" run gets no vocabulary
prompt = f"""{context}
Write ONE BigQuery Standard SQL query answering the question.
Compose the table functions above by name where they fit; obey the join policy.
Return only SQL.
Question: {question}"""
# Option A: BigQuery ML.GENERATE_TEXT (zero setup, uses your $50 credit)
# Option B: any external LLM API you have a key for
sql = generate_sql(prompt) # TODO: implement (ML.GENERATE_TEXT or an API)
return sql
def answer(question, grounded=True):
return bq.query(agent_sql(question, grounded)).result().to_dataframe()
Author about 10 questions with confirmed answers on a frozen snapshot (ground truth = a verified query you traced). Run the agent bare (no manifest) and grounded, and report accuracy for each. Grounded must beat bare, or your writeup explains the failure mode. Every failed question is a gap: add a table function and re-measure.
# FILL IN: about 10 questions with confirmed answers.
EVALS = [
("users who don't listen to Taylor Swift", {"Pluto"}),
# ... about 10 total; span your functions; include >= 2 gotcha questions
# (NULLs, no-activity rows, dedup).
]
def score(grounded):
hits = 0
for q, expected in EVALS:
try:
got = set(answer(q, grounded=grounded).iloc[:, 0].astype(str))
except Exception:
got = None
hits += (got == expected)
return hits / len(EVALS)
print("bare: ", score(grounded=False))
print("grounded: ", score(grounded=True)) # the lift is your headline result
Capstone rubric (7 pts). Coverage: fraction of the eval set the grounded agent answers correctly (target >= 85%). Lift: grounded minus bare (must be positive, or a defended failure-mode writeup). Breadth: about 10 questions spanning your table functions, >= 2 gotcha questions. Reference completeness: every join path and NULL-prone column documented. You are graded on the curation and the lift, never on the model you called.
If you worked alone, delete this section. If with a partner, list what each person did, broken down by section. Be specific.