Expedition. Free, virtual, Nov 3–6.

Technical tracks for practitioners, outcomes for leaders.

Snowflake for Developers/Guides/From PDFs to Pipelines: Document Intelligence with Cortex AI Functions
Quickstart

From PDFs to Pipelines: Document Intelligence with Cortex AI Functions

Cortex LLM
Hanna Mykula

Overview

Documents contain information teams need in two forms: selected fields for operational workflows and searchable content for research and review. Snowflake Cortex AI Functions let you process both from SQL.

This lab has two independent tracks, each in its own notebook:

  • Part A, mortgage (~25 minutes): Classify four synthetic mortgage-document PDFs with AI_CLASSIFY, extract fields from the supported page types with AI_EXTRACT, and review each result.
  • Part B, greenhouse report (~20 minutes): Parse a greenhouse gas report with AI_PARSE_DOCUMENT in AGENTIC mode, index its text, visual summaries, and chart details with Cortex Search, and answer a question about regional CO₂ emissions with AI_COMPLETE.

Prerequisites

  • Familiarity with SQL, including SELECT statements, joins, and table creation
  • Basic understanding of Snowflake stages and JSON field access

What You'll Learn

  • How to classify documents and route each supported type to an extraction schema
  • How to extract business fields from PDFs and review blank or placeholder values
  • How to parse reports in AGENTIC mode and index text, visual summaries, and chart details
  • How to answer a question from retrieved passages, with citations to the page and source type

What You'll Need

What You'll Build

  • Part A: one result row per mortgage PDF, with its document type, extracted fields, processing status, and review reason
  • Part B: a Cortex Search index over one greenhouse gas report, and a cited answer about annual regional CO₂ emissions between 2020 and 2023

The two tracks are independent. Parsing is not a prerequisite for direct-file extraction. This guide does not use native extraction citations or make loan-approval or borrower-eligibility decisions.

Setup

Duration: 5 minutes

Import the Notebooks

  1. Download vhol_document_ai_mortgage.ipynb and vhol_document_ai_greenhouse_reports.ipynb. For GitHub-hosted notebooks, select Download raw file.
  2. In Snowsight, go to Projects > Workspaces.
  3. Click + Add new > Upload files and select each downloaded .ipynb file.
  4. Open a notebook and select an available warehouse and a role with the privileges listed above.

Run the first setup cell in each notebook to set the lab context. Each track is self-contained, so you can run either one first. Cleanup is a separate manual step at the end of this guide.

Set the Lab Context

Each notebook starts with the same context cell. In a shared event account, use the participant-specific database or schema name assigned by the host in place of these defaults.

CREATE DATABASE IF NOT EXISTS VHOL_DOCUMENT_AI_DB;
CREATE SCHEMA IF NOT EXISTS VHOL_DOCUMENT_AI_DB.DOCUMENT_AI;

USE DATABASE VHOL_DOCUMENT_AI_DB;
USE SCHEMA DOCUMENT_AI;

SELECT CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA(), CURRENT_WAREHOUSE();

Classify Mortgage Documents

Duration: 25 minutes

This part uses vhol_document_ai_mortgage.ipynb. Each step below matches a cell in that notebook.

Create the Stage

Create an internal stage for the mortgage documents. Directory tables let the workflow discover the uploaded files. Use SNOWFLAKE_SSE encryption for the direct-file AI calls; see CREATE STAGE and AI_CLASSIFY limitations.

CREATE STAGE IF NOT EXISTS MORTGAGE_DOCS
    ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE')
    DIRECTORY = (ENABLE = TRUE);

Upload the Sample PDFs

  1. Download mortgage_reports.zip and unzip it. Upload the individual PDFs, not the ZIP archive. For GitHub-hosted files, select Download raw file.
  2. In Snowsight, go to Ingestion > Add Data > Load files into a Stage.
  3. Select those four PDFs and choose VHOL_DOCUMENT_AI_DB > DOCUMENT_AI > MORTGAGE_DOCS, then upload them.
FileDocumentExpected route
01.pdfDeclaration of Citizenship / Household StatusCitizenship declaration
02.pdfAffidavit of Non-Taxpayer / Household ScheduleNon taxpayer affidavit
03.pdfResidential Loan Application, pages 1 and 2Assets and transaction details
04.pdfSettlement Charges / Disbursement WorksheetSettlement charges

Keep the filenames unchanged. The four PDFs are synthetic test documents with fictional names, numbers, and signatures.

See Staging files using Snowsight for the upload workflow. Then refresh the directory table and confirm the files are visible:

ALTER STAGE MORTGAGE_DOCS REFRESH;

LIST @MORTGAGE_DOCS;

Select the PDFs

The query keeps every PDF on the stage. Each file is classified, and files that do not match a route are reported as Other.

CREATE OR REPLACE TEMP TABLE MORTGAGE_FILES AS
SELECT
    relative_path,
    SPLIT_PART(relative_path, '/', -1) AS filename,
    size AS file_size_bytes,
    last_modified
FROM DIRECTORY(@MORTGAGE_DOCS)
WHERE relative_path ILIKE '%.pdf';

SELECT * FROM MORTGAGE_FILES ORDER BY relative_path;

Define the Routes

Define one route for each supported page type. The Other route has no extraction schema, so those documents go to review without an AI_EXTRACT call. The Citizenship declaration route extracts three fields from the declaration form.

CREATE OR REPLACE TEMP TABLE MORTGAGE_ROUTES (
    document_type VARCHAR,
    category_description VARCHAR,
    response_format VARIANT
);

INSERT INTO MORTGAGE_ROUTES
SELECT
    'Non taxpayer affidavit',
    'Affidavit of Non-Taxpayer with selected people, roles, years not filed, county, and notarization.',
    PARSE_JSON($${
  "county": "What county is entered near the top of the affidavit? Return an empty string if blank.",
  "affidavit_date": "What date does the notary acknowledgment in Section F give? Return YYYY-MM-DD, or an empty string if blank. Do not use the commission expiration date.",
  "commission_expiration_date": "What date is written as Commission expiration in the notary section? The date is in MM/DD/YYYY format. Return YYYY-MM-DD, or an empty string if blank."
}$$)
UNION ALL
SELECT
    'Assets and transaction details',
    'Residential loan application with Section VII assets and liabilities and Section VIII Details of Transaction, including purchase price, loan proceeds, and cash to close.',
    PARSE_JSON($${
  "total_assets": "What amount is written for TOTAL ASSETS in Section VII? Do not use Liquid Assets Subtotal. Return the written amount, or an empty string if blank.",
  "total_liabilities": "What amount is written for TOTAL LIABILITIES in Section VII, in the Unpaid balance column? Return the written amount, or an empty string if blank.",
  "purchase_price": "What amount is written for line a, Purchase price, in Section VIII, Details of Transaction? Return the written amount, or an empty string if blank.",
  "loan_proceeds": "What amount is written for line d, Loan proceeds, in Section VIII? Read the written amount; do not calculate it. Return an empty string if blank.",
  "cash_to_close": "What amount is written for line g, Cash to close, in Section VIII? Read the written amount; do not calculate it. Return an empty string if blank."
}$$)
UNION ALL
SELECT
    'Settlement charges',
    'Settlement charges worksheet listing fees by line number and line 1400 borrower and seller settlement charge totals.',
    PARSE_JSON($${
  "borrower_settlement_charges": "What amount is written on line 1400, Total Settlement Charges, in the Borrower paid column? Read the written total; do not calculate it. Return an empty string if blank.",
  "seller_settlement_charges": "What amount is written on line 1400, Total Settlement Charges, in the Seller paid column? Read the written total; do not calculate it. Return an empty string if blank.",
  "appraisal_fee": "What appraisal fee is written on line 803? Return the amount shown, or an empty string if blank.",
  "credit_report_fee": "What credit report fee is written on line 804? Return the amount shown, or an empty string if blank."
}$$)
UNION ALL
SELECT
    'Citizenship declaration',
    'Declaration of Citizenship / Household Status form with numbered blocks 1, 2, and 3 in Section II for citizenship status, followed by a signature and date.',
    PARSE_JSON($${
  "declarant_name": "What name is printed after I, at the top of the declaration? Return the printed name as written, or an empty string if blank.",
  "citizenship_status": "Which status block is checked? Return exactly one of: U.S. citizen; Noncitizen with eligible immigration status; Not contending eligible immigration status. Return an empty string if no block is checked.",
  "signature_date": "What date is written next to the signature? The date on this form is in MM/DD/YYYY format. Return YYYY-MM-DD, or an empty string if blank."
}$$)
UNION ALL
SELECT
    'Other',
    'Any other document, including notices and disclosures such as the Right to Financial Privacy Act notice or VA and HUD/FHA borrower notices, government program agreement summaries, and property insurance rating or underwriting forms. Choose Other for notice or disclosure text even when it appears alongside a loan application.',
    NULL::VARIANT;

Classify Each PDF

Build the category list from the routes and classify each PDF. The final TRUE returns error details in the result instead of failing the query. Compare the assigned types with the document headings. 01.pdf should route to Citizenship declaration, 02.pdf to Non taxpayer affidavit, 03.pdf to Assets and transaction details, and 04.pdf to Settlement charges. The filename is not supplied to the classifier as evidence.

CREATE OR REPLACE TEMP TABLE MORTGAGE_CLASSIFIED AS
WITH categories AS (
    SELECT ARRAY_AGG(
        OBJECT_CONSTRUCT('label', document_type, 'description', category_description)
    ) WITHIN GROUP (ORDER BY document_type) AS categories
    FROM MORTGAGE_ROUTES
),
classified AS (
    SELECT
        f.relative_path,
        f.filename,
        AI_CLASSIFY(
            TO_FILE('@MORTGAGE_DOCS', f.relative_path),
            c.categories,
            {
                'task_description': 'Classify the supplied document page by its visible headings and sections. Distinguish loan application pages by section content. Use Other when none of the defined form types matches. Do not use the filename as evidence.',
                'output_mode': 'single'
            },
            TRUE  -- returns error details instead of failing the query
        ) AS raw_classification
    FROM MORTGAGE_FILES AS f
    CROSS JOIN categories AS c
)
SELECT
    relative_path,
    filename,
    NULLIF(raw_classification:value:labels[0]::VARCHAR, 'null') AS document_type,
    NULLIF(NULLIF(TRIM(raw_classification:error::VARCHAR), ''), 'null') AS classification_error
FROM classified;

SELECT filename, document_type, classification_error
FROM MORTGAGE_CLASSIFIED
ORDER BY filename;
Classification results showing the four source filenames, assigned document types, and any processing errors

Extract Business Fields

Route each classified document to its extraction schema and call AI_EXTRACT on the original PDF. Unsupported types and classification errors are skipped.

CREATE OR REPLACE TEMP TABLE MORTGAGE_EXTRACTED AS
WITH extraction_raw AS (
    SELECT
        c.relative_path,
        c.filename,
        c.document_type,
        AI_EXTRACT(
            file => TO_FILE('@MORTGAGE_DOCS', c.relative_path),
            responseFormat => r.response_format
        ) AS raw_extraction
    FROM MORTGAGE_CLASSIFIED AS c
    JOIN MORTGAGE_ROUTES AS r ON c.document_type = r.document_type
    WHERE c.classification_error IS NULL
      AND r.response_format IS NOT NULL
)
SELECT
    relative_path,
    filename,
    document_type,
    raw_extraction:response AS extracted_data,
    NULLIF(NULLIF(TRIM(raw_extraction:error::VARCHAR), ''), 'null') AS extraction_error
FROM extraction_raw;

SELECT filename, document_type, extracted_data, extraction_error
FROM MORTGAGE_EXTRACTED
ORDER BY filename;

Review the Results

Each document gets one processing status. A field is flagged for review when it is blank, null, or a placeholder such as unknown. A citizenship status outside the three allowed options is also flagged. This check does not verify that values are correct.

  • EXTRACTED: the call succeeded, no field is blank, and any citizenship status is one of the three allowed options
  • FIELD_REVIEW: at least one field is blank, or a citizenship status is outside the three allowed options
  • UNSUPPORTED_DOCUMENT: no extraction schema exists for this type
  • CLASSIFICATION_ERROR or EXTRACTION_ERROR: the call returned an error
CREATE OR REPLACE TEMP TABLE MORTGAGE_RESULTS AS
WITH blank_fields AS (
    SELECT
        e.relative_path,
        LISTAGG(f.key, ', ') WITHIN GROUP (ORDER BY f.key) AS blank_fields
    FROM MORTGAGE_EXTRACTED AS e,
         LATERAL FLATTEN(INPUT => e.extracted_data) AS f
    WHERE NULLIF(TRIM(f.value::VARCHAR), '') IS NULL
       OR LOWER(TRIM(f.value::VARCHAR)) IN ('null', 'none', 'unknown', 'n/a')
    GROUP BY e.relative_path
)
SELECT
    c.filename,
    c.document_type,
    CASE
        WHEN c.classification_error IS NOT NULL THEN 'CLASSIFICATION_ERROR'
        WHEN r.response_format IS NULL THEN 'UNSUPPORTED_DOCUMENT'
        WHEN e.extraction_error IS NOT NULL OR e.extracted_data IS NULL THEN 'EXTRACTION_ERROR'
        WHEN b.blank_fields IS NOT NULL THEN 'FIELD_REVIEW'
        WHEN c.document_type = 'Citizenship declaration'
             AND NOT COALESCE(
                 (e.extracted_data:citizenship_status::VARCHAR) IN (
                     'U.S. citizen',
                     'Noncitizen with eligible immigration status',
                     'Not contending eligible immigration status'
                 ), FALSE)
            THEN 'FIELD_REVIEW'
        ELSE 'EXTRACTED'
    END AS processing_status,
    b.blank_fields,
    e.extracted_data
FROM MORTGAGE_CLASSIFIED AS c
LEFT JOIN MORTGAGE_ROUTES AS r ON c.document_type = r.document_type
LEFT JOIN MORTGAGE_EXTRACTED AS e ON c.relative_path = e.relative_path
LEFT JOIN blank_fields AS b ON c.relative_path = b.relative_path;

SELECT filename, document_type, processing_status, blank_fields, extracted_data
FROM MORTGAGE_RESULTS
ORDER BY processing_status, filename;

EXTRACTED means the call succeeded and no field is blank. It does not establish accuracy, completeness, or borrower eligibility. Check sample values against the source pages.

Check Values Against the Source PDFs

The query below returns the extracted values as text. Compare them with the source pages. The values in the table are the ones read from the sample PDFs.

SourceFieldSource value
03.pdfSection VII total assets355,000.00
03.pdfSection VII total liabilities, unpaid balance27,950.00
03.pdfSection VIII line a, purchase price480,000.00
03.pdfSection VIII line d, loan proceeds384,000.00
03.pdfSection VIII line g, cash to close96,963.68
04.pdfLine 1400, borrower paid11,963.68
04.pdfLine 1400, seller paid33,275.00
SELECT
    filename,
    extracted_data:total_assets::VARCHAR AS total_assets,
    extracted_data:total_liabilities::VARCHAR AS total_liabilities,
    extracted_data:purchase_price::VARCHAR AS purchase_price,
    extracted_data:loan_proceeds::VARCHAR AS loan_proceeds,
    extracted_data:cash_to_close::VARCHAR AS cash_to_close,
    extracted_data:borrower_settlement_charges::VARCHAR AS borrower_settlement_charges,
    extracted_data:seller_settlement_charges::VARCHAR AS seller_settlement_charges
FROM MORTGAGE_RESULTS
WHERE filename IN ('03.pdf', '04.pdf');
Extraction results for 03.pdf showing total assets of $355,000.00, total liabilities of $27,950.00, and purchase price of $480,000.00
Extraction results for 04.pdf showing borrower settlement charges of $11,963.68 and seller settlement charges of $33,275.00

Try it: Refine the description for the assets-and-transaction route, then re-run the classification cell and compare the results. Re-running model calls can incur additional usage.

Search Greenhouse Reports

Duration: 20 minutes

This part uses vhol_document_ai_greenhouse_reports.ipynb. Each step below matches a cell in that notebook. The source is a single synthetic report, greenhouse_gas_report_2024.pdf.

Create the Stage

Create an internal stage for the greenhouse report. Directory tables let the workflow discover the uploaded file.

CREATE STAGE IF NOT EXISTS GHG_REPORTS
    ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE')
    DIRECTORY = (ENABLE = TRUE);

Upload the PDF

  1. Download greenhouse_gas_report_2024.pdf. For GitHub-hosted files, select Download raw file.
  2. In Snowsight, go to Ingestion > Add Data > Load files into a Stage and upload the PDF to GHG_REPORTS.
  3. Refresh the directory table and confirm the file is listed:
ALTER STAGE GHG_REPORTS REFRESH;

LIST @GHG_REPORTS;

Register the Report

The report year is read from the filename. The query returns one row for the 2024 report.

CREATE OR REPLACE TEMP TABLE GHG_FILES AS
SELECT
    relative_path,
    SPLIT_PART(relative_path, '/', -1) AS filename,
    REGEXP_SUBSTR(SPLIT_PART(relative_path, '/', -1), '(20[0-9]{2})', 1, 1, 'e', 1) AS report_year,
    size AS file_size_bytes,
    last_modified
FROM DIRECTORY(@GHG_REPORTS)
WHERE relative_path ILIKE '%.pdf';

SELECT * FROM GHG_FILES ORDER BY report_year;

Parse the Report with AGENTIC Mode

AI_PARSE_DOCUMENT in AGENTIC mode returns page text and a visual interpretation for each image. page_split returns one record per page. The final TRUE returns error details in the result instead of failing the query.

This step calls the model and can take a few minutes.

CREATE OR REPLACE TEMP TABLE GHG_PARSED AS
SELECT
    relative_path,
    filename,
    report_year,
    AI_PARSE_DOCUMENT(
        TO_FILE('@GHG_REPORTS', relative_path),
        {'mode': 'AGENTIC', 'page_split': TRUE},
        TRUE
    ) AS raw_parse
FROM GHG_FILES;

SELECT
    filename,
    report_year,
    ARRAY_SIZE(raw_parse:value:pages) AS returned_pages,
    raw_parse:error AS call_error,
    raw_parse:value:error AS document_error
FROM GHG_PARSED
ORDER BY report_year;

Split Pages, Visual Summaries, and Chart Details

The parsed output is split into one row per page and one row per image. Images that return an error are skipped. Each image's structured details are kept so the chart series can be indexed.

CREATE OR REPLACE TEMP TABLE GHG_PAGES AS
SELECT
    r.relative_path,
    r.filename,
    r.report_year,
    p.value:index::NUMBER + 1 AS pdf_page_number,
    p.value:content::VARCHAR AS page_content,
    p.value:images AS images
FROM GHG_PARSED AS r,
     LATERAL FLATTEN(INPUT => r.raw_parse:value:pages) AS p
WHERE NULLIF(r.raw_parse:error::VARCHAR, 'null') IS NULL;

CREATE OR REPLACE TEMP TABLE GHG_VISUALS AS
SELECT
    p.relative_path,
    p.filename,
    p.report_year,
    p.pdf_page_number,
    i.index AS image_index,
    i.value:results:image_type::VARCHAR AS image_type,
    i.value:results:summary::VARCHAR AS summary,
    i.value:results:details AS details
FROM GHG_PAGES AS p,
     LATERAL FLATTEN(INPUT => p.images) AS i
WHERE NULLIF(TRIM(i.value:results:summary::VARCHAR), '') IS NOT NULL
  AND COALESCE(LOWER(i.value:results:description::VARCHAR), '') <> 'error';

SELECT report_year, COUNT(*) AS pages FROM GHG_PAGES GROUP BY report_year ORDER BY report_year;

SELECT report_year, COUNT(*) AS visuals_with_summary FROM GHG_VISUALS GROUP BY report_year ORDER BY report_year;

List All Extracted Images

This lists every image the parse returned, with its page, image ID, type, summary, and structured details. Images without a summary or details show NULL in those columns.

SELECT
    p.pdf_page_number,
    i.index AS image_index,
    i.value:id::VARCHAR AS image_id,
    i.value:results:image_type::VARCHAR AS image_type,
    i.value:results:summary::VARCHAR AS summary,
    i.value:results:details AS details
FROM GHG_PAGES AS p,
     LATERAL FLATTEN(INPUT => p.images) AS i
ORDER BY p.pdf_page_number, i.index;

Check the Regional Figure on Page 2

Page 2 has the regional CO₂ figure. The query lists each image on that page with its summary and chart details. Values read from the chart are estimates. If the details do not include the values you need, the answer relies on the printed text instead.

SELECT
    pdf_page_number,
    image_index,
    image_type,
    summary,
    LEFT(TO_JSON(details), 1200) AS details_preview
FROM GHG_VISUALS
WHERE report_year = '2024'
  AND pdf_page_number = 2
ORDER BY image_index;

Build the Search Content

Index three kinds of content: the page text, the visual summaries, and the chart details. Each chunk repeats the report year, PDF page, and content type, so a search can be traced back to its page.

CREATE OR REPLACE TEMP TABLE GHG_SEARCH_UNITS AS
SELECT relative_path, filename, report_year, pdf_page_number,
       'text'::VARCHAR AS content_type,
       page_content AS body
FROM GHG_PAGES
WHERE NULLIF(TRIM(page_content), '') IS NOT NULL
UNION ALL
SELECT relative_path, filename, report_year, pdf_page_number,
       'visual_summary'::VARCHAR AS content_type,
       summary AS body
FROM GHG_VISUALS
UNION ALL
SELECT
    v.relative_path,
    v.filename,
    v.report_year,
    v.pdf_page_number,
    'visual_details'::VARCHAR AS content_type,
    LISTAGG(d.path || ': ' || d.value::VARCHAR, '\n') WITHIN GROUP (ORDER BY d.path) AS body
FROM GHG_VISUALS AS v,
     LATERAL FLATTEN(INPUT => v.details, RECURSIVE => TRUE) AS d
WHERE TYPEOF(d.value) NOT IN ('OBJECT', 'ARRAY', 'NULL_VALUE')
  AND d.path NOT LIKE '%value_range%'
  AND d.path NOT LIKE '%value_precision%'
GROUP BY v.relative_path, v.filename, v.report_year, v.pdf_page_number, v.image_index;

CREATE OR REPLACE TABLE GHG_SEARCH_CHUNKS
    DATA_RETENTION_TIME_IN_DAYS = 1 AS
SELECT
    MD5(u.relative_path || '|' || u.pdf_page_number::VARCHAR || '|' ||
        u.content_type || '|' || c.index::VARCHAR) AS chunk_id,
    u.relative_path,
    u.filename,
    u.report_year,
    u.pdf_page_number,
    u.content_type,
    'Report: Greenhouse Gas Report ' || u.report_year ||
    '. PDF page: ' || u.pdf_page_number::VARCHAR ||
    '. Content: ' || u.content_type || CHR(10) || c.value::VARCHAR AS search_text
FROM GHG_SEARCH_UNITS AS u,
     LATERAL FLATTEN(INPUT => SNOWFLAKE.CORTEX.SPLIT_TEXT_RECURSIVE_CHARACTER(
         u.body, 'markdown', 1400, 150
     )) AS c
WHERE NULLIF(TRIM(c.value::VARCHAR), '') IS NOT NULL;

ALTER TABLE GHG_SEARCH_CHUNKS SET CHANGE_TRACKING = TRUE;

SELECT report_year, content_type, COUNT(*) AS chunks
FROM GHG_SEARCH_CHUNKS
GROUP BY report_year, content_type
ORDER BY report_year, content_type;

Create the Cortex Search Service

The service uses the warehouse selected in the notebook and filters on report year, content type, and filename. The index builds after creation, which can take a short time. If the retrieval below returns nothing right away, wait a minute and run the DESCRIBE statement again to check that indexing_state is ACTIVE.

-- Replaces only the Search service created by this notebook.
DROP CORTEX SEARCH SERVICE IF EXISTS GHG_REPORT_SEARCH;

EXECUTE IMMEDIATE $$
DECLARE
    wh VARCHAR DEFAULT CURRENT_WAREHOUSE();
    ddl VARCHAR;
    missing_warehouse EXCEPTION (-20001, 'Select a lab warehouse before creating Search.');
BEGIN
    IF (wh IS NULL) THEN
        RAISE missing_warehouse;
    END IF;
    ddl := 'CREATE OR REPLACE CORTEX SEARCH SERVICE GHG_REPORT_SEARCH '
        || 'ON search_text ATTRIBUTES report_year, content_type, filename '
        || 'WAREHOUSE = "' || REPLACE(wh, '"', '""') || '" '
        || 'TARGET_LAG = ''1 hour'' INITIALIZE = ON_CREATE '
        || 'AS SELECT chunk_id, search_text, relative_path, filename, '
        || 'report_year, pdf_page_number, content_type FROM GHG_SEARCH_CHUNKS';
    EXECUTE IMMEDIATE :ddl;
    RETURN 'Created GHG_REPORT_SEARCH.';
END;
$$;

DESCRIBE CORTEX SEARCH SERVICE GHG_REPORT_SEARCH;

Retrieve Passages for the Question

The question is searched within the 2024 report. Each passage is shown with the chart details from the same page, so the regional figure values reach the answer step.

CREATE OR REPLACE TEMP TABLE GHG_RETRIEVED_PASSAGES AS
WITH hits AS (
    SELECT
        r.value:FILENAME::VARCHAR AS filename,
        r.value:REPORT_YEAR::VARCHAR AS report_year,
        r.value:PDF_PAGE_NUMBER::NUMBER AS pdf_page_number,
        r.value:CONTENT_TYPE::VARCHAR AS content_type,
        r.value:SEARCH_TEXT::VARCHAR AS passage
    FROM (
        SELECT PARSE_JSON(SNOWFLAKE.CORTEX.SEARCH_PREVIEW(
            'GHG_REPORT_SEARCH',
            '{"query": "Which year had the highest and which had the lowest annual regional total CO2 emissions between 2020 and 2023", "columns": ["SEARCH_TEXT", "FILENAME", "REPORT_YEAR", "PDF_PAGE_NUMBER", "CONTENT_TYPE"], "filter": {"@eq": {"REPORT_YEAR": "2024"}}, "limit": 6}'
        )) AS response
    ) AS s,
    LATERAL FLATTEN(INPUT => s.response:results) AS r
),
details AS (
    SELECT report_year, pdf_page_number, LISTAGG(search_text, '\n') WITHIN GROUP (ORDER BY chunk_id) AS chart_details
    FROM GHG_SEARCH_CHUNKS
    WHERE content_type = 'visual_details'
    GROUP BY report_year, pdf_page_number
)
SELECT h.filename, h.report_year, h.pdf_page_number, h.content_type, h.passage, d.chart_details
FROM hits AS h
LEFT JOIN details AS d
  ON d.report_year = h.report_year
 AND d.pdf_page_number = h.pdf_page_number
ORDER BY h.pdf_page_number;

SELECT * FROM GHG_RETRIEVED_PASSAGES ORDER BY pdf_page_number;

Answer the Question with AI_COMPLETE

The answer step passes the retrieved passages and chart details to claude-sonnet-5-5. The model names the year with the highest and lowest annual regional total CO₂ between 2020 and 2023, using values read from the chart where the passages give them. It cites each claim by report year, PDF page, and source type (text, visual summary, or visual details), and says when a value comes from a chart description rather than printed text.

WITH context AS (
    SELECT
        LISTAGG(
            'Report ' || report_year || ', PDF page ' || pdf_page_number::VARCHAR || ' (' || content_type || '):\n' || passage ||
            IFF(chart_details IS NULL, '', '\nChart details for page ' || pdf_page_number::VARCHAR || ':\n' || chart_details),
            '\n---\n'
        ) WITHIN GROUP (ORDER BY pdf_page_number) AS passages
    FROM GHG_RETRIEVED_PASSAGES
)
SELECT AI_COMPLETE(
    'claude-sonnet-5-5',
    'You answer questions about a 2024 greenhouse gas report using only the passages below. Question: which year had the highest and which had the lowest annual regional total CO2 emissions between 2020 and 2023? Cite each claim as [Report 2024, PDF page P, source], where source is the content label of the passage you used: text, visual_summary, or visual_details. If the passages do not name a region or the fire series, say so rather than guessing. If the passages do not contain the answer, say what is missing.\n\nPassages:\n' || passages
) AS answer
FROM context;

The answer returned by the notebook looks like this:

Highest: 2020. Lowest: 2023. I read "between 2020 and 2023" as inclusive of both years.

Annual regional total CO₂ (GtC yr⁻¹), from the dark/"Black Bars" series in Figure 1:

  • 2020: 0.57 [Report 2024, PDF page 2, visual_details]
  • 2021: 0.36 [Report 2024, PDF page 2, visual_details]
  • 2022: 0.33 [Report 2024, PDF page 2, visual_details]
  • 2023: 0.21 [Report 2024, PDF page 2, visual_details]

The 2023 total of 0.21 is also given in Table 3 (fire 0.47, NBE −0.26) [Report 2024, PDF page 2, text].

  • Series identification: The chart legend is illegible in the extracted details, so the series are labeled only by appearance. The Figure 1 caption states that dark bars show total emissions [Report 2024, PDF page 2, text]. I therefore took the "Black Bars" series as the total.
  • Consistency check: For each of these years, fire plus NBE matches the total, as the report's formula requires [Report 2024, PDF page 1, text]. For 2020, grey bars (0.43) plus striped bars (0.14) give 0.57 [Report 2024, PDF page 2, visual_details].
  • Estimated values: The chart details are flagged values_estimated: true [Report 2024, PDF page 2, visual_details]. Only the 2023 figure is confirmed by an exact table value. The ranking is still clear, because the gaps between years are large relative to rounding.
  • Region: The passages do not name a region. They describe only "one illustrative land area" [Report 2024, PDF page 1, text] and an "Illustrative region" [Report 2024, PDF page 2, text]. The totals exclude fossil-fuel and ocean fluxes, so they are not a global inventory [Report 2024, PDF page 1, text].
  • Exclusive reading: If you meant only the years strictly between 2020 and 2023 (2021 and 2022), the highest would be 2021 (0.36) and the lowest 2022 (0.33).

Cleanup

Duration: 1 minute

Both notebooks create their objects in VHOL_DOCUMENT_AI_DB. Dropping the database removes everything the lab created: the mortgage and greenhouse stages and their uploaded PDFs, the GHG_REPORT_SEARCH Cortex Search service, and the tables.

DROP DATABASE IF EXISTS VHOL_DOCUMENT_AI_DB;

Do not run this against a shared event database. In a shared account, ask the host which objects you can remove.

Conclusion And Resources

Duration: 1 minute

You classified mortgage pages and extracted fields from the supported types, including the citizenship declarations. You also parsed a greenhouse gas report with AGENTIC mode, indexed its text, visual summaries, and chart details, and answered a question with citations to the report, page, and source type.

Use AI_CLASSIFY to select a processing route, AI_EXTRACT for selected business fields, AI_PARSE_DOCUMENT for reusable document content and interpretation, and Cortex Search with AI_COMPLETE to answer from the saved content.

What You Learned

  • How to classify document pages and select a type-specific extraction schema
  • How to extract values from the original PDFs, review blank or placeholder values, and flag unexpected statuses
  • How to parse reports in AGENTIC mode and index their text, visual summaries, and chart details
  • How to answer a question from retrieved passages, with citations to the page and source type

Dataset Sources

  • Mortgage PDFs are synthetic test documents with fictional names, numbers, and signatures. They are not real loan records.
  • The greenhouse gas report greenhouse_gas_report_2024.pdf is a synthetic document created for this lab.

Related Resources

Updated Nov 5, 2026

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances