Build a "chat with your data" page for a SiS app. Two flavors, by data type:
- Structured (tables, metrics) → Path A: Cortex Analyst turns the question into SQL against a semantic view, runs it, and shows results + a short answer.
- Unstructured (documents, chunked text) → Path B: Cortex Search RAG retrieves the most relevant chunks and synthesizes a grounded, cited answer.
Both share the same chat-UI patterns (multi-turn, expanders, feedback, guardrails).
Templates: references/semantic_layer.sql + references/chat_page.py + references/eval_harness.py
(Path A); references/cortex_search_rag.sql + references/rag_chat_page.py (Path B).
Choose your path
| Your data | Path | Engine |
|---|---|---|
| Rows/metrics in tables you can aggregate | A | Cortex Analyst + semantic view (NL→SQL) |
| Free text: docs, policies, transcripts, wiki | B | Cortex Search over chunks + LLM synthesis (RAG) |
| Both, in one chat | — | A Cortex Agent with both an Analyst tool and a Search tool (beyond this skill) |
When NOT to use
- A single fixed dashboard chart → just write the SQL; a chat page is overkill.
- One chat that must orchestrate many tools across domains → use a Cortex Agent.
Path A — Structured data (Cortex Analyst)
Step A1 — Model the semantic layer
- Flatten to ONE view. Do all joins and pre-derive any parsed/computed
columns (regex splits, flags, per-row composites) in a single SQL view. This
keeps the semantic view single-table: no relationships to model, and Analyst
is far more reliable. It also doubles as a pre-aggregated rollup that speeds up
the rest of the app. Mind the grain: you own the join, so a one-to-many join
will duplicate rows and inflate
SUM/AVG/COUNT— keep the join at the intended grain (or pre-aggregate the many side) and count entities withCOUNT(DISTINCT). - Create the semantic view over that one view (
CREATE SEMANTIC VIEW):- Dimensions = categorical/filter columns; metrics = aggregates.
- Encode derived/composite metrics as NAMED metrics (e.g. a ratio-of-averages score) so the LLM uses your exact formula and can never mis-derive it.
- Add
WITH SYNONYMS+COMMENTto every table/dimension/metric — this is what Analyst reads to map natural language to columns.
- Seed accuracy controls (biggest levers, cheap):
- Verified queries (
AI_VERIFIED_QUERIES): a handful of question→SQL pairs, ideally reusing your existing dashboard queries. This anchors generation on known-good patterns and is the #1 accuracy lever. AI_SQL_GENERATION: embed domain rules (use named metrics, case-insensitive filters, sensible defaults) so the model doesn't reinvent them.
- Verified queries (
- ⚠️ STOP — show the DDL and get approval before deploying.
Step A2 — Fix literal resolution with Cortex Search (the big accuracy win)
Snowflake string = is case-sensitive. Analyst will emit
WHERE category = 'cortex search' for a stored value of Cortex Search → zero
rows, no answer. Fix it structurally:
- High-cardinality string dimensions (>10 distinct values): attach a Cortex
Search service (
WITH CORTEX SEARCH SERVICE <fqn> USING <col>). Analyst then fuzzy-resolves the user's phrase to the canonical stored value before writing SQL. This also fixes wording/typo/abbreviation mismatches, not just case. - Low-cardinality (1-10): provide sample values instead (no service needed).
- Do this for every user-facing filter column (categories, model/product names, …).
- ⚠️ STOP — show the DDL and get approval before running any
CREATE OR REPLACE(search services, semantic view): it overwrites an existing object of the same name.
Step A3 — Build the chat page (pages/chat.py)
Adapt references/chat_page.py. It implements:
- Analyst REST call: in SiS use
_snowflake.send_snow_api_request("POST", "/api/v2/cortex/analyst/message", {}, {}, body, None, 60000)withbody = {"messages": [...], "semantic_view": "<fqn>"}; connector-token fallback for local dev. - Multi-turn: accumulate
messages(user + analyst turns) and resend the full history each call so follow-ups ("what about baseline?") keep context. - Run the returned SQL directly — it is already fully qualified; do NOT pass it through any bare-name qualifier (double-qualification → "object does not exist").
- Answer narrative: summarize the result via
AI_COMPLETE(<flash-model>, ...)(fast/cheap) with a deterministic fallback; feed the last ~10 turns for context. - UX: Generated SQL / Results / Answer expanders; example-question buttons via a
pending_q+st.rerun()handoff; empty-result path lists valid values. - Guardrails: treat Analyst SQL as untrusted LLM output. Run one read-only
SELECT/WITH statement only (reject
;-chained statements and DML/DDL/CALLkeywords after stripping strings/comments), wrap it in aLIMITso the row cap (MAX_ROWS) is enforced in the warehouse. The role, not the regex, is the real security boundary: create the objects with a separate admin/owner role, and own the app with a least-privilege role that has onlySELECTon the base view/tables,USAGEon the semantic view and search service,INSERTon the feedback table, andUSAGEon the database/schema/warehouse. Render Analyst warnings and clarification/suggestions (no SQL) as guidance, not errors. - Feedback loop: 👍/👎 buttons + a
CHAT_FEEDBACKtable logging question, generated SQL, request_id, and status — mine it later for new verified queries and accuracy tracking.
Step A4 — Deploy, verify, guard against regressions
- ⚠️ STOP — show the deploy command and target app name and get approval before deploying.
- Deploy the app (SiS runs owner's rights, so the app's least-privilege owner role needs the grants listed under Guardrails in Step A3, plus Cortex access).
- Verify the model BEFORE trusting the UI:
cortex analyst query "<real question>" --view <fqn>and confirm the generated SQL uses the canonical literal (e.g.'Cortex Search', not'cortex search'). - Keep a gold-question regression harness (
references/eval_harness.py) and rerun it after any semantic-view change to catch questions that silently stop working. - ⚠️ STOP — confirm end-to-end before finishing.
Path B — Unstructured docs (Cortex Search RAG)
Use references/cortex_search_rag.sql + references/rag_chat_page.py.
Step B1 — Chunk the documents & create the search service
- Get a chunks table (one row per chunk + source metadata for citations). If you
only have raw docs: extract text (
AI_PARSE_DOCUMENTfor PDFs/images) then split withSNOWFLAKE.CORTEX.SPLIT_TEXT_RECURSIVE_CHARACTER. Chunk size ~300-1800 chars with ~10-15% overlap: smaller = more precise retrieval, larger = more context per chunk. - Create a Cortex Search service
ONthe chunk-text column, withATTRIBUTESfor the metadata you'll cite (title, url, doc_id) and aTARGET_LAGto keep it fresh. ⚠️ STOP — show the DDL and get approval before running theCREATE OR REPLACE. Create it with the admin/owner role, not the app's role (see Guardrails in Step A3). - ⚠️ STOP — verify retrieval quality with
SEARCH_PREVIEW(are the top chunks actually relevant?) before wiring the app.
Step B2 — Build the RAG chat page (pages/chat.py)
Adapt references/rag_chat_page.py. It implements:
- Retrieve: query the service (Python
Root().…cortex_search_services[...].search();SEARCH_PREVIEWSQL fallback) for the top-K chunks. - Synthesize (grounded): feed ONLY the retrieved chunks to
AI_COMPLETEwith instructions to answer only from context, cite sources by [n], and say "I don't know" when the answer isn't present — this is what prevents hallucination. - Cite: a Sources expander lists each chunk with its title/link.
- Shared UI: multi-turn context, example buttons, feedback logging, and a no-relevant-results message when retrieval is empty.
Step B3 — Deploy & verify
- ⚠️ STOP — show the deploy command and target app name and get approval before deploying.
- Deploy the app (owner's rights: the owner role needs
USAGEon the search service + Cortex). - Ask a question whose answer you know and confirm the answer is correct and the cited chunks actually support it (grounding check).
- ⚠️ STOP — confirm end-to-end before finishing.
Robustness checklist
Shared (both paths): SELECT-only/grounded execution • empty-result handling • multi-turn context • feedback logging • deploy under owner's rights with Cortex + object access.
Path A (Analyst):
- Named metrics for every computed value (no formulas left to the LLM).
- View grain verified — no one-to-many fan-out; entity counts use
COUNT(DISTINCT). - NULLs COALESCE'd on filter/group dimensions; ratio metrics guard div-by-zero (
NULLIF). - Synonyms + comments on all dimensions/metrics.
- Cortex Search on high-cardinality string dims; sample values on low-cardinality.
- A few verified queries seeded (reuse existing dashboard SQL).
-
AI_SQL_GENERATIONdomain rules set. - SELECT-only execution + row cap + query safety.
- Warnings + clarification (no-SQL) responses handled gracefully.
- Empty-result path lists valid values.
- Gold-question eval harness passes before/after changes.
Path B (RAG):
- Chunk size + overlap tuned; source metadata kept for citations.
- Synthesis prompt is strictly grounded (answer only from context; "I don't know" otherwise).
- Answers cite sources; a Sources expander shows the retrieved chunks/links.
- No-relevant-results path returns a clear message (not a hallucinated answer).
-
TARGET_LAGset so the index stays fresh as documents change.
Edge cases: general vs niche
Universal (handled by the templates):
- Grain/fan-out double-counting; NULLs; divide-by-zero; case/literal mismatch; out-of-scope questions; large results; untrusted summary-prompt input (Path A). Empty retrieval, ungrounded answers, and missing citations (Path B).
Data-shape-dependent (handle if applicable):
- Time-series/trends → add a date dimension + line chart (Path A chart heuristic only does bars).
- Over-fuzzy Cortex Search matches → tighten descriptions / cardinality.
- Ambiguous synonyms (two metrics both "score") → disambiguate.
- Non-English questions → answer in the question's language.
- RAG retrieval quality: low recall → smaller chunks / higher K / hybrid filters; conflicting chunks → ask the model to note disagreement; long chunks → summarize before synthesis.
Niche (don't re-teach — model with the semantic-view patterns skill):
- Period-over-period (YoY/MoM/SPLY), rolling/YTD windows, fiscal calendars.
- Non-additive snapshot facts (balances, headcount — must not sum over time).
- SCD2 / as-of temporal joins, multi-fact / role-playing dimensions, unit conversions, geospatial.
Gotchas (hard-won)
AI_COMPLETE, notSNOWFLAKE.CORTEX.COMPLETE— the latter is deprecated / gated.- Per-item
COMMENTneeds=:COMMENT = '...'(bareCOMMENT '...'is a syntax error). - Dimension clause order is fixed:
expr → WITH SYNONYMS → COMMENT → WITH CORTEX SEARCH SERVICE(the search-service clause must come last). - Fully-qualify the search-service name (
DB.SCHEMA.NAME) in the semantic view. - Search services have a
TARGET_LAG(e.g. 24h): new values become fuzzy-matchable after refresh; exact matches always work immediately. - Compute-then-render the assistant turn once (don't render inline during the API calls) so the live path and the redraw-on-rerun path stay identical.
RESULT_SCAN/ session state don't survive across pooled connections — verify DDL by re-querying the object, not by trusting a priorSHOW/LAST_QUERY_ID.
Stopping points
- ✋ A1: approve the base-view + semantic-view DDL before running it.
- ✋ A2: approve any
CREATE OR REPLACE(search services, semantic view) before running it. - ✋ A3: none — code changes only (page file is reviewed before deploy).
- ✋ A4: approve the deploy, then confirm the NL round-trip works before calling it done.
- ✋ B1: approve the search-service
CREATE OR REPLACE, then verify retrieval withSEARCH_PREVIEW. - ✋ B2: none — code changes only.
- ✋ B3: approve the deploy, then confirm a grounded, cited answer end-to-end.
Output
A deployed SiS chat page backed by: a flattened base view, a single-table semantic view with named metrics + synonyms, and Cortex Search services on string dimensions.