Project name: [fill in] Team members: [fill in] Target tier: [80 / 87 / 100] Capstone choice (if tier 100): [a / b / c / d / e / f]
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 (pick any of a-f) | 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 choice (if tier 100). [a Wikipedia 22 GB / b hybrid retrieval / c multi-model / d forecasting / e geospatial / f LLM extraction]
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. Pick one of six. Delete the cells for the five you didn't pick. Each capstone has its own rubric inline below. Expect 5 to 8 hours.
Scale your Spark ELT to ≥ 10 GB of Wikipedia (target the full 22 GB). The loaded BQ table must be partitioned and clustered. At least one analytic query must combine this scaled layer with your dataset in a way that the 1 GB Phase 0 slice could not have answered. Expect 30-60 min of Spark wall time and at least one Colab session crash you have to recover from.
# FILL IN: rerun your Spark ELT with NO domain filter (or a much broader one).
# Use checkpointing aggressively: write parquet to Drive between phases.
Section 6 already shipped one hybrid query. The capstone takes that to full eval discipline. Author 20+ eval queries in your domain with known-correct ground truth. Run all three retrievers (keyword-only, vector-only, your hybrid) and report R@1, R@5, R@10 for each. Tune your hybrid weighting; report which signal wins on which query shape and why. The hybrid must beat the better single-signal baseline on at least one metric, OR your writeup explains the failure mode.
# FILL IN: author your eval set as a small table of (query_text, relevant_article_ids).
# Run all three retrievers (keyword, vector, hybrid) on each query.
# Compute R@1, R@5, R@10 per retriever.
Train 3+ model classes on the same target. Pick one based on a holdout metric. Slice analysis on a cohort or fairness axis. Threshold tied to a business metric (max revenue at cost C, max recall subject to precision floor, etc.). Deliver a clear "ship this model" recommendation with the reasoning behind it.
# FILL IN: CREATE MODEL three times with different model_type values.
# Then ML.EVALUATE on a held-out slice.
BQML ARIMA_PLUS on a time-series target. 4-week (or domain-appropriate) holdout. Compare against a naive baseline (last-value or seasonal-naive). Decompose seasonality. Show backtest table.
# FILL IN:
# CREATE MODEL ... OPTIONS(model_type='ARIMA_PLUS', time_series_timestamp_col='ds',
# time_series_data_col='y') AS SELECT ds, y FROM ...
At least 3 queries using BQ geo functions (ST_DISTANCE, ST_INTERSECTS, ST_CLUSTERDBSCAN, or H3). Join your dataset + Wikipedia on geography (neighborhood polygons, lat/lng buckets, etc.). At least one viz renders on an actual map.
# FILL IN: examples
# SELECT ST_GEOGFROMTEXT(geom), ... FROM ...
# SELECT ST_CLUSTERDBSCAN(point, 500, 10) OVER () AS cluster_id FROM ...
Use ML.GENERATE_TEXT or AI.GENERATE_TABLE to extract structured fields from your Wikipedia text at scale (1k+ articles). Join the extracted structure into your analysis. Sanity-check extraction quality on a 100-row sample with precision/recall on a defined schema.
# FILL IN:
# CREATE OR REPLACE MODEL ... REMOTE WITH CONNECTION ... OPTIONS(endpoint='gemini-2.5-flash')
# SELECT ml_generate_text_result FROM ML.GENERATE_TEXT(MODEL ..., (SELECT ... AS prompt FROM wiki_domain))
If you worked alone, delete this section. If with a partner, list what each person did, broken down by section. Be specific.