DEV Community

Gleb Otochkin for Google AI

Posted on Originally published at Medium on

Evaluating AI models with SQL in Google Cloud databases

Introduction

Do you need to evaluate AI models with your data or cases? Yes, absolutely. Should it be done using complex and elaborate pipelines — it depends. Every time a new version of a model comes out, it behaves differently. You have to be able to evaluate each case with the model and see the differences. Sometimes you might realize that a lightweight, maybe cheaper model can work for your case as good as the more expensive one. In Google Cloud we have Gemini Enterprise Agent Platform which provides agent evaluation tools and you might have your own workflow going through sophisticated and elaborate tests. But what if you want to quickly check the new model or compare two different models available in your Cloud SQL for PostgreSQL or AlloyDB? You can use plain SQL and create a simple, quick evaluation procedure directly in your database. Let me show you an example of a single-answer point scoring evaluation.

AI Model Evaluation Components

What do you need for this evaluation? You need sample requests, registered model endpoint(s) you want to evaluate, a scale to quantify the performance, and evaluation criteria.

Here is a set of my sample requests:

'Explain Model Context Protocol (MCP) in 50 words or less.',
'Explain write-ahead logging (WAL) in PostgreSQL in 50 words or less.',
'Explain database connection pooling in 50 words or less.',
'Explain the difference between primary keys and unique constraints in 50 words or less.',
'Explain database normalization and its 3NF form in 50 words or less.',
'Explain transaction isolation levels like Serializable in 50 words or less.',
'Explain vector embeddings and cosine similarity in 50 words or less.',
'Explain database indexes (B-Tree vs Hash) in 50 words or less.',
'Explain the purpose of Foreign Keys in relational schemas in 50 words or less.',
'Explain optimistic vs pessimistic locking in databases in 50 words or less.'
Enter fullscreen mode Exit fullscreen mode

Of course, all my questions were about databases. And since we were working in database — I put all my questions into the table:

CREATE TABLE IF NOT EXISTS benchmark_prompts (
    id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    category TEXT NOT NULL,
    prompt TEXT NOT NULL,
    max_words INT DEFAULT 50,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT clock_timestamp()
);
-- Index for fast active prompt lookups
CREATE INDEX IF NOT EXISTS idx_benchmark_prompts_active 
ON benchmark_prompts (is_active, category);

INSERT INTO benchmark_prompts (category, prompt, max_words) VALUES
('Protocols', 'Explain Model Context Protocol (MCP) in 50 words or less.', 50),
('Architecture', 'Explain write-ahead logging (WAL) in PostgreSQL in 50 words or less.', 50),
('Networking', 'Explain database connection pooling in 50 words or less.', 50),
('Data Modeling','Explain the difference between primary keys and unique constraints in 50 words or less.', 50),
('Data Modeling','Explain database normalization and its 3NF form in 50 words or less.', 50),
('Concurrency', 'Explain transaction isolation levels like Serializable in 50 words or less.', 50),
('Vector Search','Explain vector embeddings and cosine similarity in 50 words or less.', 50),
('Storage', 'Explain database indexes (B-Tree vs Hash) in 50 words or less.', 50),
('Data Modeling','Explain the purpose of Foreign Keys in relational schemas in 50 words or less.', 50),
('Concurrency', 'Explain optimistic vs pessimistic locking in databases in 50 words or less.', 50);
Enter fullscreen mode Exit fullscreen mode

Next, I registered three different models in my AlloyDB database. The models were Gemini 3.5 Flash and Gemini 3.5 Flash Lite. All three models were available in AlloyDB, I just needed to register them. Here is an example of how you can register the Gemini 3.5 Flash Lite model:

CALL google_ml.create_model(
model_id => 'gemini-3.5-flash-lite',
model_request_url => 'https://aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/global/publishers/google/models/gemini-3.5-flash-lite:generateContent',
model_provider => 'google',
model_type => 'llm'
);
Enter fullscreen mode Exit fullscreen mode

Don’t forget to replace the PROJECT_ID placeholder with your real project id if you use the procedure to register a model in your database.

The list of evaluation criteria depends on the nature of your data and how you use the AI models. In my case, I wanted to put some basic quality criteria for the model’s response:

1. Accuracy: Is the response technically accurate regarding the subject described in the prompt?
2. Word compliance: Is the response 50 words or less?
3. Quality: How well does it answer the question?
Enter fullscreen mode Exit fullscreen mode

I wanted all my evaluations to be scaled from 1 to 10, where 10 would be the best response.

How did I evaluate the responses? I registered one more model in my database just for the evaluation. I used the Gemini 3.1 Pro Preview as the Pro model should provide higher-quality evaluations. But nothing stops you from using any other models. For example, you can test it with our partner models from Anthropic or Meta which are available in our Model Garden.

To save some tokens and time I put a limit of 50 words for each response to a question.

And last but not least, I count the total tokens for each request and average them at the end because cost is important and can help to choose the right model for each case.

SQL Function for Evaluation

Here is an example of a SQL function with all the evaluation conditions directly in the SQL code. The function takes three parameters: model to evaluate, model which will be doing evaluation and number of executions:

CREATE OR REPLACE FUNCTION benchmark_model_quality(
    p_target_model TEXT,
    p_evaluator_model TEXT,
    p_num_runs INT DEFAULT 10
)
RETURNS TABLE(
    total_runs INT,
    avg_quality_score NUMERIC,
    median_quality_score NUMERIC,
    min_quality_score NUMERIC,
    max_quality_score NUMERIC,
    avg_tokens_per_prompt NUMERIC
) AS $$
DECLARE
    v_all_prompts TEXT[];
    v_selected_prompts TEXT[] := '{}';
    v_current_prompt TEXT;
    v_predict_result JSON;
    v_target_response TEXT;
    v_tokens INT;
    v_total_tokens INT := 0;
    v_eval_prompt TEXT;
    v_eval_raw TEXT;
    v_score INT;
    v_scores INT[] := '{}';
    v_pool_size INT;
BEGIN
    -- Input validation
    IF p_num_runs <= 0 THEN
        RAISE EXCEPTION 'p_num_runs must be greater than 0';
    END IF;

    -- Load prompts directly from the table
    SELECT array_agg(prompt)
    INTO v_all_prompts
    FROM benchmark_prompts;

    v_pool_size := cardinality(v_all_prompts);

    IF v_pool_size IS NULL OR v_pool_size = 0 THEN
        RAISE EXCEPTION 'No prompts found in benchmark_prompts table';
    END IF;

    -- 1. Select exactly p_num_runs prompts WITH replacement from table pool
    SELECT ARRAY(
        SELECT v_all_prompts[floor(random() * v_pool_size + 1)::INT]
        FROM generate_series(1, p_num_runs)
    ) INTO v_selected_prompts;

    -- 2. Loop through each of the selected subjects and execute exactly ONCE
    FOREACH v_current_prompt IN ARRAY v_selected_prompts LOOP

        -- A. Query target model
        SELECT google_ml.predict_row(
            model_id => p_target_model,
            request_body => json_build_object(
                'contents', json_build_array(
                    json_build_object(
                        'role', 'user',
                        'parts', json_build_array(
                            json_build_object('text', v_current_prompt)
                        )
                    )
                )
            )
        ) INTO v_predict_result;

        -- B. Extract response text and tokens
        v_target_response := v_predict_result -> 'candidates' -> 0 -> 'content' -> 'parts' -> 0 ->> 'text';
        v_tokens := COALESCE((v_predict_result -> 'usageMetadata' ->> 'totalTokenCount')::INT, 0);
        v_total_tokens := v_total_tokens + v_tokens;

        -- C. Build adaptive judge prompt
        v_eval_prompt := format(
            'You are an expert technical evaluator. Evaluate the following generated response to the prompt: "%s"

Generated Response to Evaluate:
"%s"

Instructions:
Evaluate the response based on:
1. Accuracy: Is the response technically accurate regarding the subject described in the prompt?
2. Word compliance: Is the response 50 words or less?
3. Quality: How well does it answer the question?

You must respond ONLY with a raw JSON object conforming to the following schema, and no other text or Markdown formatting:
{
  "score": <integer from 1 to 10>,
  "reasoning": "<short description of why the score was given under 50 words>"
}', 
            v_current_prompt, 
            v_target_response
        );

        -- D. Query evaluator model
        SELECT google_ml.predict_row(
            model_id => p_evaluator_model,
            request_body => json_build_object(
                'contents', json_build_array(
                    json_build_object(
                        'role', 'user',
                        'parts', json_build_array(
                            json_build_object('text', v_eval_prompt)
                        )
                    )
                )
            )
        ) -> 'candidates' -> 0 -> 'content' -> 'parts' -> 0 ->> 'text' INTO v_eval_raw;

        -- E. Clean up markdown code blocks
        v_eval_raw := regexp_replace(v_eval_raw, '^```

json\s*', '', 'i');
        v_eval_raw := regexp_replace(v_eval_raw, '\s*

```$', '', 'i');
        v_eval_raw := trim(v_eval_raw);

        -- F. Parse and store score
        BEGIN
            v_score := (v_eval_raw::json ->> 'score')::INT;
            v_scores := array_append(v_scores, v_score);
        EXCEPTION WHEN OTHERS THEN
            RAISE WARNING 'Failed to parse JSON score from evaluator output: %', v_eval_raw;
        END;

    END LOOP;

    -- 3. Compute aggregate stats over successfully parsed scores
    RETURN QUERY
    WITH sorted_scores AS (
        SELECT s, 
               row_number() OVER (ORDER BY s) as rn, 
               count(*) OVER () as total_count
        FROM unnest(v_scores) AS s
    ),
    stats AS (
        SELECT 
            avg(s)::NUMERIC(10,2) as v_avg,
            min(s) as v_min,
            max(s) as v_max
        FROM unnest(v_scores) AS s
    ),
    median_stat AS (
        SELECT avg(s)::NUMERIC(10,2) as v_median
        FROM sorted_scores
        WHERE rn IN (floor((total_count + 1)/2.0), ceil((total_count + 1)/2.0))
    )
    SELECT 
        cardinality(v_scores),
        stats.v_avg,
        median_stat.v_median,
        stats.v_min::NUMERIC,
        stats.v_max::NUMERIC,
        (v_total_tokens::NUMERIC / NULLIF(cardinality(v_scores), 0))::NUMERIC(10,2)
    FROM stats, median_stat;
END;
$$ LANGUAGE plpgsql;
Enter fullscreen mode Exit fullscreen mode

There are some potential improvements to the procedure and the method depending on use case. For example you can expand it with “Golden” responses for each question for evaluation. It is useful when you are working with your own domain knowledge and know exactly how the best answer should sound like.

Testing

I tried it on two different models and compared the results.

The first test was for the Gemini 3.5 Flash model. I set the test to run 45 times:

SELECT * FROM benchmark_model_quality('gemini-3.5-flash', 'gemini-3.1-pro-preview', 45);
Enter fullscreen mode Exit fullscreen mode

The Gemini 3.5 Flash showed really good results:

 total_runs | avg_quality_score | median_quality_score | min_quality_score | max_quality_score | avg_tokens_per_prompt
------------+-------------------+----------------------+-------------------+-------------------+-----------------------
         45 | 9.96 | 10.00 | 8 | 10 | 916.27
Enter fullscreen mode Exit fullscreen mode

Then I ran the same test for Gemini 3.5 Flash Lite:

 total_runs | avg_quality_score | median_quality_score | min_quality_score | max_quality_score | avg_tokens_per_prompt
------------+-------------------+----------------------+-------------------+-------------------+-----------------------
         45 | 9.82 | 10.00 | 7 | 10 | 77.11
Enter fullscreen mode Exit fullscreen mode

For that sample set, Gemini 3.5 Flash Lite showed almost the same quality and cost much less.

If you compare the tokens per request, you can see an 11x reduction in token count. And those tokens are 3.5 times cheaper than Gemini 3.5 Flash. So, you can effectively pay 38 times less for the same quality response.

Conclusion

There are two things I want to say in this article. If you are working in AlloyDB or Cloud SQL and need to quickly evaluate some AI models you can try it using SQL function thanks to the direct integration with AI in Google Cloud databases.

And maybe you can use a less expensive model for your workload and get acceptable results saving some money.

Happy testing and let me know if you think such kind of simple recipes are useful for you.


Top comments (0)