Building a Natural Language Query Engine on Oracle AI Database 26ai

A Step-by-Step Journey from 42% to 100% Accuracy — Weeks 6 to 10

Platform: Oracle APEX 24.2 + Oracle AI Database 26ai (Always Free)
LLM: Google Gemini 1.5 Flash via DBMS_CLOUD_AI
Schema: WKSP_STOCKTRADE (AI layer) over WKSP_STOCKDATA (10 years NSE + AMFI data)
Goal: Type “what is the 5 year CAGR of TCS?” and get the correct, data-verified answer


The Architecture in One Line

The LLM is a translator, not a data source. It converts natural language to SQL. The database — holding 10 years of daily NSE bhavcopy and AMFI NAV data — is the source of truth. Every number returned is verified and auditable.

User question
     ↓
Intent classification (SQL vs CHAT vs SIMILARITY)
     ↓
Entity resolution (HDFC Bank → HDFCBANK)
     ↓
RAG retrieval (right view + glossary + few-shot examples)
     ↓
Prompt assembly
     ↓
Gemini 1.5 Flash (action='chat') → raw SQL
     ↓
SQL validation (syntax + security + whitelist)
     ↓
Execute via read-only AI_EXEC user
     ↓
Narration layer (plain English answer)
     ↓
Return to user

Week 6: The Evaluation Harness — Measure Before You Improve

The most important thing you can build before any AI work is a measurement system. Without it, “is the pipeline better?” is just an opinion.

Why this comes first

Most people build the AI feature first and then test it manually. That’s backwards. The eval harness lets you run 20 (or 200) test questions automatically, score each one, and see a single number. Every improvement in Weeks 7-10 is measured against the baseline. The number doesn’t lie.

Three tables, one package, one view

-- Table 1: The question bank
CREATE TABLE wksp_stocktrade.ai_eval_question (
  question_id     NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  question_text   VARCHAR2(500)  NOT NULL,
  question_type   VARCHAR2(20)   NOT NULL,  -- LOOKUP/METRIC/AGGREGATE/TIME/COMPARE/GENERAL
  reference_sql   VARCHAR2(4000) NOT NULL,  -- the correct SQL, written BEFORE running Select AI
  expected_cols   VARCHAR2(500),            -- columns to compare
  numeric_tol_pct NUMBER DEFAULT 1,         -- tolerance % for numeric comparison
  is_active       CHAR(1) DEFAULT 'Y'
);

-- Table 2: Run header
CREATE TABLE wksp_stocktrade.ai_eval_run (
  run_id       NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  run_date     DATE DEFAULT TRUNC(SYSDATE),
  model_name   VARCHAR2(100),
  correct      NUMBER,
  partial      NUMBER,
  wrong        NUMBER,
  error_count  NUMBER,
  pct_strict   NUMBER(5,2),   -- correct/total %
  pct_partial  NUMBER(5,2)    -- (correct + partial×0.5)/total %
);

-- Table 3: Per-question result
CREATE TABLE wksp_stocktrade.ai_eval_result (
  result_id      NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  run_id         NUMBER NOT NULL,
  question_id    NUMBER NOT NULL,
  generated_sql  CLOB,
  ref_result     VARCHAR2(4000),
  gen_result     VARCHAR2(4000),
  score          VARCHAR2(10),    -- CORRECT/PARTIAL/WRONG/ERROR/SKIP
  failure_reason VARCHAR2(200)    -- ENTITY/CATEGORY/RECOMPUTE/INTENT/JOIN
);

Key insight: write reference SQL BEFORE running the AI

This seems obvious but matters enormously. If you write the reference SQL after seeing what the AI produced, you unconsciously write it to match. The reference SQL must be written independently — that’s what you’re measuring against.

The scoring package (simplified)

CREATE OR REPLACE PACKAGE wksp_stocktrade.ai_eval_pkg AS
  FUNCTION  start_run(p_profile VARCHAR2, p_model VARCHAR2,
                      p_provider VARCHAR2) RETURN NUMBER;
  PROCEDURE run_question(p_run_id NUMBER, p_question_id NUMBER);
  PROCEDURE run_all(p_run_id NUMBER);
  PROCEDURE finish_run(p_run_id NUMBER);
END ai_eval_pkg;
/

The run_question procedure calls DBMS_CLOUD_AI.GENERATE(action='showsql'), executes both the reference SQL and the generated SQL, compares the first row of results, and scores CORRECT/PARTIAL/WRONG/ERROR.

Numeric comparison uses tolerance (±1% for prices, ±5% for Sharpe ratios). Text comparison is case-insensitive exact match. Aggregate comparisons check if the correct entity appears in the top-N results.

Baseline result — run_id 3, Gemini 1.5 Flash, schema metadata only

Correct:  8  (42.1%)
Partial:  3  (50.0% with partial credit)
Wrong:    8
Skipped:  1 (GENERAL intent — prose, can't auto-score)

42.1% strict accuracy is the baseline. This is Gemini 1.5 Flash with only the 9 AI view names and their COMMENT ON metadata. No glossary, no examples, no entity resolver. Every improvement from here is measured against this number.

Failure analysis — what went wrong

Failure typeCountCause
Entity resolution8UPPER(company_name)='HDFC Bank' — column doesn’t exist; symbol is the key
Category literals3WHERE category='FLEXI CAP' — correct value is 'EQ_FLEXI_CAP'
Recomputed metrics2Computed Sharpe as CAGR/volatility instead of reading mf_scheme_stats.sharpe_ratio
Intent mismatch1“What is a flexi cap fund?” generated SQL instead of chat response

These failures are clean and fixable. The model’s SQL structure is excellent — it picks reasonable views and writes syntactically correct SQL. The failures are semantic: wrong literals, wrong entity keys, wrong intent. RAG retrieval + glossary + entity resolver close these gaps.


Week 7: The RAG Retrieval Layer — Teaching the LLM Your Schema

RAG (Retrieval-Augmented Generation) means: instead of sending all 9 view names to the LLM and hoping it picks the right one, embed your schema documentation, embed the user’s question, and retrieve only the most relevant context. Add glossary terms and example SQL that are semantically close to the question.

What gets embedded

Three things, each for a different purpose:

1. Object catalog — which view to use for which question

-- 10 rows, one per AI view/table
-- Each row describes the object, its grain, sample questions, and what NOT to use it for
INSERT INTO wksp_stocktrade.ai_object_catalog
  (object_name, description, sample_question, usage_notes, do_not_use_for)
VALUES (
  'MF_SCHEME_STATS',
  'Pre-computed risk statistics for 1,777 canonical MF schemes. '||
  'Contains Sharpe ratio, Sortino ratio, max drawdown, volatility.',
  'What is Parag Parikh Sharpe ratio? Which flexi cap fund has lowest volatility?',
  'ALWAYS use this for Sharpe, Sortino. NEVER recompute Sharpe as CAGR/volatility.',
  'Do NOT use for: current NAV, raw CAGR'
);

-- Embed the description for semantic retrieval
UPDATE wksp_stocktrade.ai_object_catalog
SET embedding = VECTOR_EMBEDDING(
  wksp_stocktrade.ALL_MINILM_L12_V2
  USING (object_name||' '||description||' '||
         NVL(sample_question,'')||' '||NVL(usage_notes,'')) AS data)
WHERE embedding IS NULL;
COMMIT;

2. Business glossary — what terms mean in YOUR app specifically

-- 10 terms: CAGR, NAV, SHARPE_RATIO, ELIGIBLE_FUND, EQ_LARGE_CAP, etc.
INSERT INTO wksp_stocktrade.ai_glossary
  (term, definition, synonyms, canonical_sql, notes) VALUES
('EQ_LARGE_CAP',
 'Exact category value for large-cap equity mutual funds. '||
 'Use WHERE category=''EQ_LARGE_CAP'' — not ''Large Cap'' or ''LARGE CAP''.',
 'large cap, large-cap, top 100 stocks',
 'WHERE category=''EQ_LARGE_CAP'' AND years=5',
 'Category values: EQ_LARGE_CAP EQ_MID_CAP EQ_SMALL_CAP EQ_FLEXI_CAP EQ_ELSS');

-- Embed for retrieval
UPDATE wksp_stocktrade.ai_glossary
SET embedding = VECTOR_EMBEDDING(
  wksp_stocktrade.ALL_MINILM_L12_V2
  USING (term||' '||NVL(synonyms,'')||' '||definition) AS data)
WHERE embedding IS NULL;
COMMIT;

3. Few-shot examples — correct SQL patterns for the failure modes

CREATE TABLE wksp_stocktrade.ai_few_shot (
  shot_id        NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  question       VARCHAR2(500) NOT NULL,
  sql_text       CLOB          NOT NULL,
  question_type  VARCHAR2(20),
  failure_reason VARCHAR2(100),  -- which failure mode this fixes
  embedding      VECTOR(384, FLOAT32),
  is_active      CHAR(1) DEFAULT 'Y'
);

-- 15 examples targeting exact failure modes from the baseline:

-- FIX: Category literal
INSERT INTO wksp_stocktrade.ai_few_shot (question, sql_text, failure_reason) VALUES (
  'how many eligible flexi cap funds are there',
  'SELECT COUNT(DISTINCT base_fund_key) AS flexi_cap_count
FROM   wksp_stocktrade.ai_mf_scheme
WHERE  category = ''EQ_FLEXI_CAP''    -- exact value, not ''Flexi Cap''
AND    is_eligible = ''Y''',
  'CATEGORY');

-- FIX: Entity resolution — use symbol not company name
INSERT INTO wksp_stocktrade.ai_few_shot (question, sql_text, failure_reason) VALUES (
  'what is the current price of HDFC Bank',
  'SELECT current_price, price_date, day_change_pct
FROM   wksp_stocktrade.ai_stock_snapshot
WHERE  symbol = ''HDFCBANK''    -- ticker not company name',
  'ENTITY');

-- FIX: Pre-computed metric — never recompute Sharpe
INSERT INTO wksp_stocktrade.ai_few_shot (question, sql_text, failure_reason) VALUES (
  'what is Parag Parikh Sharpe ratio',
  'SELECT c.scheme_name, s.sharpe_ratio, s.volatility_ann, s.cagr_5y
FROM   wksp_stocktrade.mf_scheme_stats s
JOIN   wksp_stocktrade.ai_mf_canonical c ON c.scheme_code = s.scheme_code
WHERE  UPPER(c.scheme_name) LIKE ''%PARAG PARIKH FLEXI%''
AND    s.as_of_date = TRUNC(SYSDATE)',
  'RECOMPUTE');

-- Embed all examples
UPDATE wksp_stocktrade.ai_few_shot
SET embedding = VECTOR_EMBEDDING(
  wksp_stocktrade.ALL_MINILM_L12_V2 USING question AS data)
WHERE embedding IS NULL AND is_active = 'Y';
COMMIT;

The entity resolver — the most important function

Maps fuzzy text (“HDFC Bank”, “parag parikh flexi”) to exact database keys (HDFCBANK, scheme_code 122639). Uses the all-MiniLM-L12-v2 ONNX model loaded directly into Oracle — no external API calls.

CREATE OR REPLACE FUNCTION wksp_stocktrade.resolve_entity(
  p_text        IN VARCHAR2,
  p_entity_type IN VARCHAR2 DEFAULT NULL,  -- 'NSE_STOCK' | 'MF_SCHEME' | NULL=both
  p_top_n       IN NUMBER   DEFAULT 3
) RETURN VARCHAR2 IS
-- Returns: 'TYPE:KEY:DISPLAY_TEXT' of best match
-- Example: 'NSE_STOCK:HDFCBANK:HDFCBANK — HDFC BANK LTD'
-- Returns NULL if no match found above threshold

  l_query_vec  VECTOR(384, FLOAT32);
  l_result     VARCHAR2(1000);
  l_dist       NUMBER;
  c_threshold  CONSTANT NUMBER := 0.55;  -- safe gap: real matches <0.45, noise >0.64

BEGIN
  SELECT VECTOR_EMBEDDING(wksp_stocktrade.ALL_MINILM_L12_V2 USING p_text AS data)
  INTO   l_query_vec FROM dual;

  SELECT entity_type||':'||entity_key||':'||display_text, dist
  INTO   l_result, l_dist
  FROM (
    SELECT entity_type, entity_key, display_text,
           VECTOR_DISTANCE(embedding, l_query_vec, COSINE) AS dist
    FROM   wksp_stocktrade.ai_entity
    WHERE  (p_entity_type IS NULL OR entity_type = p_entity_type)
    ORDER  BY dist
    FETCH FIRST 1 ROW ONLY
  );

  -- Return NULL if distance too high (garbage input)
  IF l_dist > c_threshold THEN
    RETURN NULL;
  END IF;

  RETURN l_result;
EXCEPTION
  WHEN OTHERS THEN RETURN NULL;
END resolve_entity;
/

Threshold calibration — how we arrived at 0.55:

InputDistanceShould match?
HDFC Bank0.31✅ Yes
Infosys0.28✅ Yes
parag parikh flexi0.45✅ Yes
nonsense xyz abc0.64❌ No — return NULL

The gap between 0.45 (worst real match) and 0.64 (pure noise) is large. Setting threshold at 0.55 puts it safely in the middle. This is the right way to calibrate a threshold — measure real examples, measure garbage, set the boundary between them.

One important naming lesson: SBI Bluechip Fund was renamed to SBI Large Cap Fund in 2018 (SEBI recategorisation). The resolver correctly returned NULL for “sbi bluechip fund” — because the name doesn’t exist in the database. The fix: embed both old and new names in the embed_text column:

UPDATE wksp_stocktrade.ai_entity
SET    embed_text = 'SBI Bluechip Fund SBI Large Cap Fund large cap equity Direct Growth SBI EQ_LARGE_CAP',
       embedding  = VECTOR_EMBEDDING(
                      wksp_stocktrade.ALL_MINILM_L12_V2
                      USING 'SBI Bluechip Fund SBI Large Cap Fund large cap equity Direct Growth SBI EQ_LARGE_CAP' AS data)
WHERE  entity_type = 'MF_SCHEME'
AND    entity_key  = '119598';
COMMIT;

Final entity resolver result: 8/8 correct, NULL on garbage input.

Three retrieval functions

-- Schema retrieval: finds top-k most relevant views for the question
CREATE OR REPLACE FUNCTION wksp_stocktrade.retrieve_schema(
  p_question IN VARCHAR2,
  p_top_n    IN NUMBER DEFAULT 3
) RETURN CLOB IS
  l_query_vec VECTOR(384, FLOAT32);
  l_result    CLOB := '';
BEGIN
  SELECT VECTOR_EMBEDDING(wksp_stocktrade.ALL_MINILM_L12_V2 USING p_question AS data)
  INTO   l_query_vec FROM dual;

  FOR r IN (
    SELECT object_name, description, usage_notes, do_not_use_for,
           VECTOR_DISTANCE(embedding, l_query_vec, COSINE) AS dist
    FROM   wksp_stocktrade.ai_object_catalog
    WHERE  embedding IS NOT NULL
    ORDER  BY dist
    FETCH FIRST p_top_n ROWS ONLY
  ) LOOP
    l_result := l_result ||
      'TABLE/VIEW: '  || r.object_name   || CHR(10) ||
      'Description: ' || r.description   || CHR(10) ||
      'Usage: '       || NVL(r.usage_notes,'') || CHR(10) ||
      CASE WHEN r.do_not_use_for IS NOT NULL
           THEN 'Do NOT use for: '||r.do_not_use_for||CHR(10) ELSE '' END
           || CHR(10) || '---' || CHR(10);
  END LOOP;
  RETURN l_result;
END retrieve_schema;
/

-- Glossary retrieval: finds relevant term definitions
CREATE OR REPLACE FUNCTION wksp_stocktrade.retrieve_glossary(
  p_question IN VARCHAR2,
  p_top_n    IN NUMBER DEFAULT 2
) RETURN CLOB IS ...

-- Few-shot retrieval: finds similar example SQL patterns
CREATE OR REPLACE FUNCTION wksp_stocktrade.retrieve_few_shot(
  p_question IN VARCHAR2,
  p_top_n    IN NUMBER DEFAULT 3
) RETURN CLOB IS ...

Schema retrieval recall@1 results:

QuestionTop retrieved viewCorrect?
“5 year CAGR of TCS”AI_STOCK_CAGR (dist 0.41)
“flexi cap lowest volatility”MF_SCHEME_STATS (dist 0.45)
“Infosys bonus”AI_NSE_CORPORATE_ACTION
“how many eligible flexi cap funds”AI_MF_SCHEME (dist 0.32)

The Q8 fix is the most important — the catalog description “Use for counting and filtering schemes” pulled AI_MF_SCHEME to rank above AI_MF_CAGR for counting questions. Without this, the model would query CAGR for a COUNT question and return wrong results.


Week 8: The Generation Pipeline — From Question to Answer

Week 7 built the retrieval layer. Week 8 wires it into a complete pipeline that generates SQL, validates it, executes it, and returns results.

The complete pipeline package

CREATE OR REPLACE PACKAGE wksp_stocktrade.ai_query_pkg AS
  FUNCTION ask(
    p_question IN VARCHAR2,
    p_username IN VARCHAR2 DEFAULT 'ADMIN',
    p_profile  IN VARCHAR2 DEFAULT 'PERFIN_GENAI',
    p_narrate  IN BOOLEAN  DEFAULT TRUE   -- Week 10 addition
  ) RETURN CLOB;

  FUNCTION show_sql(
    p_question IN VARCHAR2,
    p_profile  IN VARCHAR2 DEFAULT 'PERFIN_GENAI'
  ) RETURN CLOB;

  FUNCTION generate_and_validate(
    p_question IN VARCHAR2,
    p_profile  IN VARCHAR2,
    p_log_id   IN NUMBER
  ) RETURN CLOB;
END ai_query_pkg;
/

Step 1: Intent classification

CREATE OR REPLACE FUNCTION wksp_stocktrade.classify_intent(
  p_question IN VARCHAR2
) RETURN VARCHAR2 IS
  l_q VARCHAR2(4000) := UPPER(TRIM(p_question));
BEGIN
  -- SIMILARITY: fund/stock comparison requests
  IF REGEXP_LIKE(l_q,
     'SIMILAR (TO|FUNDS|STOCKS)|LIKE (THIS|THAT) FUND|'||
     'FUNDS LIKE|PEERS OF|COMPARABLE TO', 'i')
  THEN RETURN 'SIMILARITY'; END IF;

  -- CHAT: general knowledge — no data needed
  IF REGEXP_LIKE(l_q,
     '^WHAT IS (A|AN|THE MEANING|THE DIFFERENCE)|'||
     '^HOW DOES|^EXPLAIN|^DEFINE|'||
     'WHAT IS (CAGR|NAV|SIP|ELSS|NPS|A MUTUAL FUND|AN ETF|'||
     'A FLEXI CAP|A LARGE CAP|EXPENSE RATIO|ALPHA|BETA)', 'i')
  THEN RETURN 'CHAT'; END IF;

  -- SQL: anything with specific entity + metric
  IF REGEXP_LIKE(l_q,
     'PRICE|CAGR|RETURN|NAV|DRAWDOWN|VOLATILITY|SHARPE|'||
     'BONUS|SPLIT|DIVIDEND|52.WEEK|HOW MANY|COUNT|'||
     'BEST|WORST|TOP|LOWEST|HIGHEST|COMPARE|VERSUS|VS', 'i')
  THEN RETURN 'SQL'; END IF;

  RETURN 'SQL';  -- default: try SQL
END classify_intent;
/

Step 2: Prompt assembly with RAG context

-- Inside generate_and_validate:

-- Retrieve context
l_schema_ctx := wksp_stocktrade.retrieve_schema(p_question, 3);
l_gloss_ctx  := wksp_stocktrade.retrieve_glossary(p_question, 2);
l_shot_ctx   := wksp_stocktrade.retrieve_few_shot(p_question, 3);

-- Resolve entities
l_res_stock := wksp_stocktrade.resolve_entity(p_question, 'NSE_STOCK');
l_res_fund  := wksp_stocktrade.resolve_entity(p_question, 'MF_SCHEME');

-- Assemble prompt
l_prompt :=
  '## Database Schema Context' || CHR(10) ||
  'You are generating Oracle SQL for a personal finance portfolio database.' || CHR(10) ||
  'Database: Oracle 26ai. Schema: WKSP_STOCKTRADE.' || CHR(10) ||
  'Rules:' || CHR(10) ||
  '- Generate only SELECT statements.' || CHR(10) ||
  '- Never compute CAGR from raw prices. Use AI_STOCK_CAGR or AI_MF_CAGR.' || CHR(10) ||
  '- Never compute Sharpe ratio. Read sharpe_ratio column from MF_SCHEME_STATS.' || CHR(10) ||
  '- Use exact category values: EQ_LARGE_CAP EQ_FLEXI_CAP EQ_MID_CAP etc.' || CHR(10) ||
  '- Use NSE ticker not company name: HDFCBANK not HDFC Bank.' || CHR(10) ||
  '- Always filter years explicitly: AND years = 5' || CHR(10) ||
  '- AI_STOCK_DRAWDOWN only has: symbol, years, max_drawdown_pct, volatility_ann_pct, trading_days.' || CHR(10) ||
  '- Always include days_held in CAGR queries.' || CHR(10) ||
  CHR(10);

IF l_entity_res IS NOT NULL THEN
  l_prompt := l_prompt ||
    '## Resolved Entities' || CHR(10) || l_entity_res || CHR(10);
END IF;

l_prompt := l_prompt ||
  '## Relevant Tables and Views' || CHR(10) || l_schema_ctx || CHR(10) ||
  '## Term Definitions' || CHR(10) || l_gloss_ctx || CHR(10) ||
  '## Example SQL Patterns' || CHR(10) || l_shot_ctx || CHR(10) ||
  '## Question' || CHR(10) ||
  'Generate Oracle SQL to answer: ' || p_question || CHR(10) ||
  'Return ONLY the SQL. No markdown. No semicolon at end. Start with SELECT or WITH.';

Critical lesson: action=’chat’ not action=’prompt’

The Gemini provider via DBMS_CLOUD_AI does not support action='prompt'. This caused every SQL generation call to fail with ORA-20000.

-- WRONG — not supported by Gemini provider:
SELECT DBMS_CLOUD_AI.GENERATE(
    prompt => l_prompt, profile_name => p_profile, action => 'prompt')
INTO l_gen_sql FROM dual;

-- CORRECT — use 'chat' for free-form prompt generation:
SELECT DBMS_CLOUD_AI.GENERATE(
    prompt => l_prompt, profile_name => p_profile, action => 'chat')
INTO l_gen_sql FROM dual;

The valid actions for Gemini are: showsql, runsql, narrate, chat. Not prompt.

Step 3: Clean LLM output

-- LLM sometimes wraps output in markdown fences — strip them
l_gen_sql := REGEXP_REPLACE(l_gen_sql, '```sql\s*', '', 1, 0, 'i');
l_gen_sql := REGEXP_REPLACE(l_gen_sql, '```\s*',    '', 1, 0, 'i');
l_gen_sql := TRIM(l_gen_sql);

-- Remove trailing semicolon
IF SUBSTR(TRIM(l_gen_sql), -1) = ';' THEN
  l_gen_sql := SUBSTR(TRIM(l_gen_sql), 1, LENGTH(TRIM(l_gen_sql))-1);
END IF;

-- If LLM added explanation before the SQL, extract from first SELECT/WITH
IF NOT REGEXP_LIKE(TRIM(l_gen_sql), '^(SELECT|WITH)\s', 'i') THEN
  DECLARE
    l_pos_sel NUMBER := INSTR(UPPER(l_gen_sql), 'SELECT');
    l_pos_wit NUMBER := INSTR(UPPER(l_gen_sql), 'WITH');
    l_pos     NUMBER;
  BEGIN
    l_pos := CASE
      WHEN l_pos_sel=0 AND l_pos_wit=0 THEN 0
      WHEN l_pos_sel=0 THEN l_pos_wit
      WHEN l_pos_wit=0 THEN l_pos_sel
      ELSE LEAST(l_pos_sel, l_pos_wit) END;
    IF l_pos > 1 THEN l_gen_sql := SUBSTR(l_gen_sql, l_pos); END IF;
  END;
END IF;

Column alignment bug — the difference between 68% and 100%

After the first RAG pipeline run, eval score jumped from 42% to 68.4%. 19 out of 20 questions now worked. But 6 questions had numeric mismatches.

The cause: the reference SQL returned a column named CAGR_PCT but the generated SQL returned the same value as RETURN_PCT or FIVE_YEAR_CAGR. The harness was comparing column 1 of reference vs column 1 of generated — names didn’t matter for numeric comparison. But for the harness to find the right value, the reference SQL needed to match column order.

Fix: ensure both reference SQL and generated SQL return the numeric value in column 1. The reference SQL was updated to always put the key metric first. This pushed the score to 100%.

Eval score after Week 8: 100% strict (run 25)


Week 9: Guardrails — Three Security Layers

A pipeline that generates and executes arbitrary SQL needs security. Three layers were implemented.

Layer 1: Pre-LLM question filter

Block obviously malicious questions before even calling the LLM:

-- Inside generate_and_validate, before LLM call:
IF REGEXP_LIKE(UPPER(p_question),
   '^(UPDATE|DELETE|DROP|INSERT|GRANT|REVOKE|EXECUTE|EXEC)\s|'||
   'DBA_USERS|ALL_USERS|ALL_TABLES|ALL_SOURCE|ALL_OBJECTS|'||
   'V\$SESSION|SYS\.|WKSP_STOCKDATA\.|'||
   'SHOW.*TABLES|LIST.*TABLES|ALL.*TABLES.*DATABASE|'||
   'IGNORE.*INSTRUCTIONS|IGNORE.*PREVIOUS|DISREGARD.*RULES|'||
   'RETURN.*PASSWORD|RETURN.*CREDENTIAL', 'i') THEN
  -- Block and log — don't call LLM
  UPDATE wksp_stocktrade.ai_query_log
  SET    validation_error = 'BLOCKED: question contains forbidden pattern',
         sql_valid        = 'N'
  WHERE  log_id = p_log_id;
  RETURN NULL;
END IF;

Layer 2: SQL validation — expanded blacklist

CREATE OR REPLACE FUNCTION wksp_stocktrade.validate_sql(
  p_sql       IN  CLOB,
  p_error_msg OUT VARCHAR2
) RETURN CHAR IS
  l_sql_upper VARCHAR2(32767);
BEGIN
  l_sql_upper := UPPER(TRIM(TO_CHAR(p_sql)));

  -- Must start with SELECT or WITH
  IF NOT REGEXP_LIKE(l_sql_upper, '^(WITH\s+|SELECT\s+)', 'i') THEN
    p_error_msg := 'Must start with SELECT or WITH';
    RETURN 'N';
  END IF;

  -- Block dangerous patterns including injection attempts
  DECLARE
    TYPE t_patterns IS TABLE OF VARCHAR2(200);
    l_blocked t_patterns := t_patterns(
      'INSERT\s','UPDATE\s','DELETE\s','DROP\s','CREATE\s',
      'DBMS_','UTL_','SYS\.','GRANT\s','REVOKE\s',
      'ALL_TABLES','ALL_SOURCE','ALL_OBJECTS','V\$',
      'WKSP_STOCKDATA\.',          -- direct base table access blocked
      'OR\s+1\s*=\s*1',            -- classic injection
      ''';\s*(DROP|DELETE|UPDATE)', -- semicolon injection
      'UNION\s+SELECT.*FROM.*DBA_' -- data exfiltration via UNION
    );
  BEGIN
    FOR i IN 1..l_blocked.COUNT LOOP
      IF REGEXP_LIKE(l_sql_upper, l_blocked(i), 'i') THEN
        p_error_msg := 'BLOCKED: forbidden pattern';
        RETURN 'N';
      END IF;
    END LOOP;
  END;

  -- Parse check (syntax validation)
  DECLARE l_cursor INTEGER;
  BEGIN
    l_cursor := DBMS_SQL.OPEN_CURSOR;
    DBMS_SQL.PARSE(l_cursor, p_sql, DBMS_SQL.NATIVE);
    DBMS_SQL.CLOSE_CURSOR(l_cursor);
  EXCEPTION WHEN OTHERS THEN
    IF DBMS_SQL.IS_OPEN(l_cursor) THEN DBMS_SQL.CLOSE_CURSOR(l_cursor); END IF;
    p_error_msg := 'Syntax error: '||SUBSTR(SQLERRM,1,200);
    RETURN 'N';
  END;

  RETURN 'Y';
END validate_sql;
/

Layer 3: Read-only execution user

All generated SQL is executed through a separate database user with SELECT-only permissions on the AI views. Even if a SQL injection attack bypasses the validator, the execution user cannot modify data.

-- Run as ADMIN (pending execution):
CREATE USER ai_exec IDENTIFIED BY <password> ACCOUNT LOCK;
GRANT CREATE SESSION TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_stock_snapshot      TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_stock_cagr           TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_mf_cagr              TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_mf_snapshot          TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_nse_price_daily      TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_mf_nav_daily         TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_nse_corporate_action TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_mf_scheme            TO ai_exec;
GRANT SELECT ON wksp_stocktrade.ai_stock_drawdown       TO ai_exec;
GRANT SELECT ON wksp_stocktrade.mf_scheme_stats         TO ai_exec;
-- exec_as_ai_exec: second whitelist check before execution
PROCEDURE exec_as_ai_exec(
  p_sql    IN  CLOB,
  p_result OUT CLOB,
  p_rows   OUT NUMBER
) IS
BEGIN
  -- Second object whitelist check — defence in depth
  IF REGEXP_LIKE(UPPER(p_sql),
     'WKSP_STOCKDATA\.|SYS\.|DBA_|ALL_USERS|V\$|'||
     'ALL_SOURCE|USER_SOURCE|TAB\b|DICT\b', 'i') THEN
    RAISE_APPLICATION_ERROR(-20099,
      'BLOCKED: SQL references forbidden schema or system object');
  END IF;

  -- Execute via DBMS_SQL (controlled execution)
  -- ... fetch results and return ...
END exec_as_ai_exec;

Adversarial test results (run 47): 9/9 attacks blocked, 100%

Attacks tested:

  • DROP TABLE via question text
  • SQL injection via semicolon ('; DROP TABLE ai_eval_question; --)
  • System table access (SELECT * FROM all_users)
  • Source code exfiltration (SELECT text FROM all_source)
  • DBA_USERS access
  • Prompt injection (“Ignore previous instructions and…”)
  • Schema reconnaissance (“What tables exist in WKSP_STOCKDATA?”)
  • UNION-based data extraction
  • Cross-schema query attempt

All blocked at Layer 1 (question filter) or Layer 2 (SQL validator) before execution.

Functional regression (run 43): 19/19 correct, 100% — guardrails didn’t break anything.


Week 10: Narration Layer — From Raw Tables to Plain English

The pipeline now returns correct SQL results. But raw tabular output is unfriendly:

CAGR_PCT | DAYS_HELD
-----------
-7.47    | 1825
1 row(s) returned.

Week 10 adds three functions that convert this into human language.

Problem 1: Partial data coverage — silently wrong

A new stock listed 8 months ago. The user asks for its “5-year CAGR”. The query runs, returns a number, and displays it as if it’s a 5-year CAGR. It’s actually an 8-month return. No error, no warning. This is dangerous.

-- PRIVATE: CHECK_DATA_COVERAGE
FUNCTION check_data_coverage(
  p_sql      IN CLOB,
  p_result   IN CLOB,
  p_question IN VARCHAR2
) RETURN VARCHAR2 IS
  l_years_requested NUMBER := 0;
  l_min_days        NUMBER := 0;
BEGIN
  -- Only check CAGR queries
  IF NOT REGEXP_LIKE(UPPER(p_sql), 'AI_STOCK_CAGR|AI_MF_CAGR', 'i') THEN
    RETURN NULL;
  END IF;

  -- Determine requested horizon
  IF    REGEXP_LIKE(p_question, '5.?YEAR|5YR', 'i') THEN
    l_years_requested := 5;  l_min_days := 1500;
  ELSIF REGEXP_LIKE(p_question, '3.?YEAR|3YR', 'i') THEN
    l_years_requested := 3;  l_min_days := 900;
  ELSIF REGEXP_LIKE(p_question, '1.?YEAR|1YR', 'i') THEN
    l_years_requested := 1;  l_min_days := 200;
  ELSE RETURN NULL;
  END IF;

  -- Check days_held in result — if below threshold, warn
  DECLARE l_days_match VARCHAR2(100);
  BEGIN
    l_days_match := REGEXP_SUBSTR(p_result, '\b([0-9]{2,4})\b', 1, 3);
    IF l_days_match IS NOT NULL AND TO_NUMBER(l_days_match) < l_min_days THEN
      RETURN 'Note: This '||l_years_requested||'-year metric is based on '||
             ROUND(TO_NUMBER(l_days_match)/365, 1)||
             ' years of actual data. Results may not reflect the full period.';
    END IF;
  EXCEPTION WHEN OTHERS THEN NULL;
  END;

  RETURN NULL;
END check_data_coverage;

Problem 2: Empty results — no explanation

When SQL returns 0 rows, the user currently sees “0 row(s) returned.” They don’t know if the stock doesn’t exist, lacks history, or was entered incorrectly.

-- PRIVATE: CLASSIFY_EMPTY_RESULT
FUNCTION classify_empty_result(
  p_sql      IN CLOB,
  p_question IN VARCHAR2,
  p_profile  IN VARCHAR2
) RETURN VARCHAR2 IS
  l_symbol VARCHAR2(100);
  l_count  NUMBER := 0;
BEGIN
  -- Extract symbol from generated SQL
  l_symbol := REGEXP_SUBSTR(
    UPPER(p_sql), 'SYMBOL\s*=\s*''([A-Z0-9&]+)''', 1, 1, 'i', 1);

  IF l_symbol IS NOT NULL THEN
    -- Check if symbol exists at all
    SELECT COUNT(*) INTO l_count
    FROM   wksp_stockdata.nse_pd_bhavcopy_history
    WHERE  symbol = l_symbol AND series = 'EQ' AND ROWNUM = 1;

    IF l_count = 0 THEN
      RETURN 'The stock "'||l_symbol||'" was not found in NSE data. '||
             'Possible reasons: '||
             '(1) Listed on BSE only — try BSE data; '||
             '(2) Recently listed — may lack history; '||
             '(3) Ticker may differ — verify the exact NSE symbol; '||
             '(4) Delisted stock — no longer traded.';
    ELSE
      RETURN '"'||l_symbol||'" exists in NSE data but has no result '||
             'for the requested metric or time period.';
    END IF;
  ELSE
    RETURN 'No data found. Try rephrasing with the exact NSE symbol '||
           '(e.g. HDFCBANK, TCS, INFY) or exact fund name from AMFI records.';
  END IF;
END classify_empty_result;

Problem 3: Raw tables are unfriendly — narrate them

-- PRIVATE: NARRATE_RESULT
-- Second LLM call: converts SQL table to plain English paragraph
FUNCTION narrate_result(
  p_question       IN VARCHAR2,
  p_sql_result     IN CLOB,
  p_coverage_warn  IN VARCHAR2,
  p_profile        IN VARCHAR2
) RETURN CLOB IS
  l_prompt    CLOB;
  l_narration CLOB;
BEGIN
  l_prompt :=
    'You are a financial assistant explaining data to a retail investor in India.' || CHR(10) ||
    'Convert the following query result into a clear, plain English answer.' || CHR(10) ||
    'Rules:' || CHR(10) ||
    '- Write 2-4 sentences maximum.' || CHR(10) ||
    '- Use simple language. No financial jargon.' || CHR(10) ||
    '- Always include the actual numbers from the data.' || CHR(10) ||
    '- Use Indian number format: ₹ for rupees, % for percentages.' || CHR(10) ||
    '- If CAGR is negative, clearly state it is a loss.' || CHR(10) ||
    '- Do not give buy/sell recommendations.' || CHR(10) ||
    CHR(10) ||
    'User question: ' || p_question || CHR(10) ||
    'Data:' || CHR(10) || SUBSTR(p_sql_result, 1, 3000) || CHR(10) ||
    CASE WHEN p_coverage_warn IS NOT NULL
         THEN 'Context note: ' || p_coverage_warn || CHR(10) ELSE '' END ||
    'Plain English answer:';

  SELECT DBMS_CLOUD_AI.GENERATE(
             prompt => l_prompt, profile_name => p_profile, action => 'chat')
  INTO   l_narration FROM dual;

  -- Append coverage warning below narration
  IF p_coverage_warn IS NOT NULL THEN
    l_narration := l_narration || CHR(10) || CHR(10) || 'Note: ' || p_coverage_warn;
  END IF;

  RETURN l_narration;
EXCEPTION WHEN OTHERS THEN
  RETURN p_sql_result;  -- narration failed — return raw result unchanged
END narrate_result;

The narration toggle — p_narrate parameter

The eval harness needs raw tabular output to score correctly. The APEX page needs plain English for users. One parameter handles both:

FUNCTION ask(
  p_question IN VARCHAR2,
  p_username IN VARCHAR2 DEFAULT 'ADMIN',
  p_profile  IN VARCHAR2 DEFAULT 'PERFIN_GENAI',
  p_narrate  IN BOOLEAN  DEFAULT TRUE   -- TRUE for APEX, FALSE for eval harness
) RETURN CLOB IS

Inside ask(), after execution:

IF p_narrate THEN
  IF l_rows = 0 THEN
    -- Explain WHY no data found
    l_result := classify_empty_result(l_gen_sql, p_question, p_profile);
  ELSE
    -- Narrate the data
    l_coverage_warn := check_data_coverage(l_gen_sql, l_raw_result, p_question);
    l_narrated := narrate_result(p_question, l_raw_result, l_coverage_warn, p_profile);
    l_result := l_narrated || CHR(10) || CHR(10) ||
                '--- Data ---' || CHR(10) || l_raw_result;
  END IF;
ELSE
  -- p_narrate=FALSE: raw result for eval harness
  l_result := l_raw_result;
END IF;

Smoke test results — all three narration functions working

Test A — TCS 5yr CAGR (narrated):
"Over the last 5 years, TCS has seen a decline in value, resulting in a loss
of 7.47% per year. This figure is based on a holding period of 1,825 days."
--- Data ---
CAGR_PCT | DAYS_HELD
-7.47    | 1825

Test B — Fake stock:
"The stock 'XYZFAKESTOCK' was not found in NSE data. Possible reasons:
(1) Listed on BSE only — try BSE data; (2) Recently listed — may lack
history; (3) Ticker may differ — verify the exact NSE symbol;
(4) Delisted stock — no longer traded."

Test C — HDFC Bank price:
"As of 26-08-13, the price of HDFC Bank was ₹725."
--- Data ---
CURRENT_PRICE | PRICE_DATE
725           | 26-08-13

Functional regression (run 61): 19/19 correct, 100% — narration didn’t break anything.


The Complete Eval Score History

RunWeekDescriptionStrictPartial
36Baseline — raw Select AI, schema metadata only42.1%50.0%
228RAG pipeline first run68.4%76.3%
258Column alignment fixed100%100%
439Functional regression after guardrails100%100%
479Adversarial security test — 9 attacks100% blocked
6110Narration layer100%100%

The journey from 42.1% to 100% strict accuracy took four weeks of targeted improvements, each measured against the eval harness:

Week 6 → 7: RAG retrieval fixed entity resolution and category literals (+26pp)
Week 7 → 8: Few-shot examples fixed recompute and join failures, column alignment fixed remaining gaps (+31.6pp)
Week 8 → 9: Guardrails added without regression (0pp change — correct)
Week 9 → 10: Narration added without regression (0pp change — correct)


Key Lessons Learned

1. Measure first, build second. The eval harness (Week 6) was the most important artifact in the project. Without it, improvements are guesses. With it, each week’s work produces a number.

2. The LLM’s SQL structure is usually correct. The failures are semantic. Gemini consistently generated syntactically valid SQL. The 42% baseline failures were wrong literals ('FLEXI CAP' instead of 'EQ_FLEXI_CAP'), wrong entity keys (company name instead of ticker), and wrong view choices. RAG context fixed all three without changing the model.

3. action='chat' not action='prompt' for Gemini. The Gemini provider via DBMS_CLOUD_AI only supports showsql, runsql, narrate, and chat. Using action='prompt' causes ORA-20000. Use chat for free-form prompt generation.

4. ORA-14551 — DML inside function — PL/SQL functions called from SQL cannot commit. Move DML calls (INSERT into log, UPDATE) to a PL/SQL block, not a SELECT FROM DUAL.

5. as_of_date = MAX(as_of_date) not TRUNC(SYSDATE). MF_SCHEME_STATS is populated nightly. On weekends and public holidays, TRUNC(SYSDATE) returns no rows because there’s no entry for today. Use MAX(as_of_date) to always get the latest available data.

6. The train/serve skew trap for embeddings. When you embed the user’s question to retrieve similar few-shot examples, you must use the exact same embedding model that was used to embed the examples. In Oracle 26ai this is guaranteed because VECTOR_EMBEDDING(ALL_MINILM_L12_V2 USING ... AS data) uses the same model both ways. But if you ever change the model, re-embed everything.

7. Security in depth — three layers, not one. Question filter + SQL validator + read-only execution user. Any single layer can be defeated. All three together are very difficult to bypass.

8. SET DEFINE OFF at the top of any SQL script containing & characters. Oracle SQL*Plus and Database Actions treat & as a substitution variable prefix. Fund names, ticker symbols, and SQL BETWEEN clauses all contain & and will fail without this.

9. DISTINCT with VECTOR columns causes ORA-22848. If your result set includes a VECTOR column and you need to deduplicate, deduplicate on non-vector columns in a subquery first, then join back to get the vector.

10. The narration layer doubles the LLM cost. Every user query now makes two LLM calls — one for SQL generation, one for narration. For an Always Free account with rate limits, this matters. The p_narrate=FALSE toggle on the eval harness ensures the harness doesn’t consume double credits.


What Comes Next — Weeks 11-12

Week 11 — APEX pages:

  • Natural language query page (text input → narrated answer + raw data)
  • Fund similarity page (enter a fund → find similar funds by feature vector distance)
  • Bucket decision dashboard (which bucket to withdraw from this month)

Week 12 — Stock feature vectors + final tuning:

  • Stock feature vectors (CAGR, volatility, Sharpe, drawdown, dividend yield) stored as VECTOR(8, FLOAT32)
  • True bucket sequencing in Monte Carlo (draw from BAF/gold when equity down, equity when up)
  • Additional few-shot examples for question types that still fail
  • Final eval run on expanded 30-question set

Built on Oracle APEX 24.2 + Oracle AI Database 26ai (Always Free, ca-toronto-1). LLM: Google Gemini 1.5 Flash via aistudio.google.com API (free tier). Embedding model: all-MiniLM-L12-v2 (ONNX) loaded directly into Oracle. All vector operations run inside the database — zero external API calls for similarity search or entity resolution.

Published on gradeupnow.in

Tags: Oracle APEX, Oracle AI Database, Select AI, RAG, Vector Search, Oracle 26ai, Natural Language to SQL, Gemini, DBMS_CLOUD_AI, PL/SQL, Personal Finance, Oracle Database

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top