CS145: Project 2 (Science Track)

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.

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


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


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 — the Analyst's Agent (7 pts, tier 100)

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.

Step 7.1: The vocabulary — stored table functions

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.

Step 7.2: The manifest — what you hand the model

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

Step 7.3: The text-to-SQL agent

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

Step 7.4: The eval set and the bare-vs-grounded measurement

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.


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.