CS145: Project 2 (Science Track)

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.

How to use this template

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.


Substrate: project setup

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}")

Substrate: cost-aware query helper

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))

Substrate: filter recipe library

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")

Phase 0: Spark + Wikipedia (10 pts), proposal due Oct 30

What you ship in Phase 0 (banked on-time, forfeit if late):

  1. Spark filter pulled Wikipedia down to ~1 GB of in-domain articles.

  2. Slice loaded into BigQuery next to your existing tables.

  3. One keyword join query between your dataset and the wiki slice.

  4. Filter precision sample (~30 articles checked by hand).

  5. Parity check against bigquery-public-data.samples.wikipedia.

  6. One viz from your join.

  7. Proposal writeup at the bottom of this section.

Step 0.1: Start Spark on Colab

!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)

Step 0.2: Pull Wikipedia and apply your filter

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")

Step 0.3: Load filtered slice into BigQuery

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))

Step 0.4: Filter precision sample

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.]

Step 0.5: One keyword join query (your dataset ⨝ wiki_domain)

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)

Step 0.6: Parity check against public BQ Wikipedia

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.]

Step 0.7: One viz

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()

Phase 0 proposal writeup

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]


Phase 1: Embeddings + semantic join (10 pts)

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.

Step 1.1: Create remote embedding model

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")

Step 1.2: Embed the slice (first paragraphs only, ≤ 100 MB text)

Honest cost math:

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.")

Step 1.3: Build a vector index (optional, requires ≥ 5 GB embedded data)

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')

Step 1.4: One semantic join query

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).


Section 1: 6 non-simple queries + 3 visualizations (20 pts)

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.

Query #1 [title] (Hint: at least 2 of the 6 queries must use the Wiki layer.)

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?

Query #2 [title] (Hint: at least 2 of the 6 queries must use the Wiki layer.)

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?

Query #3 [title]

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?

Query #4 [title]

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?

Query #5 [title]

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?

Query #6 [title]

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?


Section 2: BQML model (15 pts)

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:

Step 2.1: Prediction target

What are you predicting? Why is it useful? Which BQML model type fits?

Step 2.2: Extract training data

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))

Step 2.3: Train the model

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()

Step 2.4: Evaluate

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()

Step 2.5: Predict + interpret

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?


Section 3: Scale math + query plans (10 pts)

Pick two of your complex queries. For each:

  1. Run it and pull the query plan from BigQuery Query History → Execution details.

  2. Report stage timings, bytes processed, rows shuffled.

  3. 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))

Query 1 for scale analysis

# 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.

Query 2 for scale analysis

# 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.


Section 4: Writeup + AI takeaways + demo (15 pts)

Short and informal. Half a page total, not a report.

Writeup (5 pts)

Key learnings from the project, limitations of your analysis, and one or two ideas for future work. 3-4 sentences each.

AI anecdotes (6 pts)

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:

Plain-English walkthrough (4 pts)

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.

5-minute demo recording

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.


Section 5: Warehouse layout (7 pts, tier 87+)

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:

  1. Fact layer ≥ 5 GB (extended Spark ELT, or added a second source).

  2. Partition key + cluster keys on the fact table.

  3. Materialized view for an ML feature.

  4. Before/after benchmark on one query (bytes scanned, $/query).

Step 5.1: Scale fact layer to ≥ 5 GB

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.

Step 5.2: Design partition + cluster keys

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()

Step 5.3: Materialized view for an ML feature

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()

Step 5.4: Before/after benchmark on one query

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).


Section 6: Production retrieval at scale (6 pts, tier 93+)

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:

  1. Scale up the embed pass to ~1 GB of text (vs. Phase 1's ~100 MB cap). Expect $8 to $15 of BQ credit.

  2. Build a real BQ vector index on the scaled embedded table.

  3. Run one hybrid query (keyword + vector) using reciprocal-rank fusion or weighted-score fusion.

  4. Compare latency and bytes scanned between indexed and brute-force VECTOR_SEARCH on the same query.

Step 6.1: Scale up the embed pass to ~1 GB of text

Re-run ML.GENERATE_EMBEDDING on a broader slice. Two ways to hit ~1 GB:

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()

Step 6.2: Build the vector index

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()

Step 6.3: One hybrid query (keyword + semantic)

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

Step 6.4: Indexed vs brute-force latency comparison

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?


Section 7: Capstone (7 pts, tier 100)

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.

Capstone (a): Wikipedia 22 GB scale-up [data platform / data eng]

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.

Capstone (b): Retrieval eval rigor [AI / search]

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.

Capstone (c): Multi-model + calibrated decision [product DS]

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.

Capstone (d): Forecasting + backtest [quant / ops research]

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 ...

Capstone (e): Geospatial analytics [spatial / urban DS]

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 ...

Capstone (f): LLM-augmented structured extraction [modern data eng / AI]

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))

Section 8: Assigned Work (if partnered)

If you worked alone, delete this section. If with a partner, list what each person did, broken down by section. Be specific.