Skip to content

Measuring the Cache Hit Rate

This recipe demonstrates how to read the statistics that pg_semantic_cache keeps about lookups, and how to work out the hit rate of a cache. The hit rate is the share of lookups that the cache answers, and shows whether the cache saves enough work to justify its space. The example is a psql script, because the extension is used entirely through SQL functions.

These examples are for pg_semantic_cache 0.1.0-beta4.

The script reads the statistics, caches one result, looks it up twice, looks up an unrelated question once, and then reads the statistics again. The statistics cover every application that uses the cache on the server, so the script prints how much they changed rather than their totals. The script prints the following output:

First lookup -> hit
Second lookup -> hit
Unrelated lookup -> miss
Entries added: 1
Hits: 2
Misses: 1
Hit rate for this run: 66.7%
Removed 1 cached entry

The differences are exact only when no other session uses the cache while the script runs, because the counters include every session's lookups.

Preparing the Ingredients

The script needs the same server as the Caching a Query Result recipe: pg_semantic_cache with a vector dimension of 768, and the pgEdge Vectorizer creating embeddings with nomic-embed-text through Ollama. That recipe describes how to configure both extensions.

Setting Up the Script

The script stops at the first error, and prints plain lines rather than tables. psql variables hold the question that the script caches and an unrelated question that it does not. The following block shows the start of the script:

-- Stop at the first error, and print plain lines rather than tables.
\set ON_ERROR_STOP on
\pset format unaligned
\pset tuples_only on

-- A question that the program caches, and one that it never caches.
\set question 'What is the average value of an order this month?'
\set unrelated 'Recommend a good recipe for apple pie.'

Getting the Embeddings

The script gets the embedding of each question before it changes the cache, so that a failure to reach the model leaves nothing in the cache. The following block gets both embeddings in one query:

-- Get the embedding of each question from the pgEdge Vectorizer before
-- changing the cache, so that a failure here leaves nothing behind.
SELECT pgedge_vectorizer.generate_embedding(:'question')::text
           AS question_embedding,
       pgedge_vectorizer.generate_embedding(:'unrelated')::text
           AS unrelated_embedding
\gset

Reading the Statistics

The semantic_cache.cache_stats() function returns one row. The following table describes the columns of the row:

Column Type Description
total_entries bigint The number of entries, expired or not.
total_hits bigint The number of lookups that found a result.
total_misses bigint The number of lookups that found no result.
hit_rate_percent real The hits as a percentage of all lookups.

The counters start when the extension is created, and pg_semantic_cache 0.1.0-beta4 has no function that resets them. The following block stores the values before the script starts in psql variables:

-- The statistics cover every application that uses the cache, so record
-- them before the program starts.
SELECT total_entries AS entries_before,
       total_hits AS hits_before,
       total_misses AS misses_before
  FROM semantic_cache.cache_stats()
\gset

Making Some Lookups

The semantic_cache.get_cached_result() function adds one to the hits or the misses each time it runs, so an application needs no extra call to keep the statistics. The script caches one result, looks it up twice with the same embedding, and looks up the unrelated question once. With the nomic-embed-text model in Ollama (digest 0a109f422b47), the two questions have a similarity of about 0.47, far below the threshold of 0.95; the similarity depends on the embedding model. The following block makes the lookups:

-- Cache one result, look it up twice, and then ask an unrelated question.
-- get_cached_result() counts each hit and miss itself.
SELECT semantic_cache.cache_query(
           query_text      => :'question',
           query_embedding => :'question_embedding',
           result_data     => '{"average_order_value": 42.50}',
           ttl_seconds     => 3600,
           tags            => ARRAY['cookbook', 'cookbook-cache-statistics']
       ) AS cache_id
\gset

SELECT format('First lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'question_embedding', 0.95);
SELECT format('Second lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'question_embedding', 0.95);
SELECT format('Unrelated lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'unrelated_embedding', 0.95);

Printing the Changes

The script reads the statistics again, subtracts the values from before the run, and works out the hit rate of the run from the differences. The semantic_cache.cache_hit_rate() function returns the hit_rate_percent value on its own, which covers every lookup since the extension was created. The following block prints the changes:

-- Read the statistics again, and print what changed during the run.
SELECT total_entries - :entries_before AS entries_added,
       total_hits - :hits_before AS hits,
       total_misses - :misses_before AS misses
  FROM semantic_cache.cache_stats()
\gset

\echo Entries added: :entries_added
\echo Hits: :hits
\echo Misses: :misses

SELECT format('Hit rate for this run: %s%%',
              round(100.0 * :hits / nullif(:hits + :misses, 0), 1));

pg_semantic_cache also has a log_cache_access() function, which records one lookup with its cost in the semantic_cache.cache_access_log table, for the cost reports that get_cost_savings() returns. That function does not change the hit and miss counters, which only get_cached_result() updates.

Removing the Entries

The script removes its entry with the cookbook-cache-statistics tag. The following block removes the entry:

-- Remove the entries that this program created.
SELECT semantic_cache.invalidate_cache(tag => 'cookbook-cache-statistics')
       AS removed
\gset
\echo Removed :removed cached entry

The script runs the steps that change the cache between BEGIN and COMMIT. If any step fails, psql stops, and PostgreSQL rolls back the transaction, which removes the entry and also undoes the changes to the counters. The first lookup locks the row of the semantic_cache.cache_metadata table that holds the counters, so other sessions' lookups wait until the transaction ends; keep such transactions short.

Running the Example

The complete script is available in the cookbook repository. psql reads its connection settings from the standard PG* environment variables. Set the variables for your server, and then run the following command from the sql directory of the recipe:

psql -X -q -v ON_ERROR_STOP=1 -f main.sql

Viewing the Complete Program

The following block shows the complete script:

-- Ellie's Postgres Cookbook
-- Measuring the hit rate of a pg_semantic_cache cache.
--
-- Copyright (c) 2026, pgEdge, Inc.
-- This code is released under the PostgreSQL License.

-- Stop at the first error, and print plain lines rather than tables.
\set ON_ERROR_STOP on
\pset format unaligned
\pset tuples_only on

-- A question that the program caches, and one that it never caches.
\set question 'What is the average value of an order this month?'
\set unrelated 'Recommend a good recipe for apple pie.'

-- Get the embedding of each question from the pgEdge Vectorizer before
-- changing the cache, so that a failure here leaves nothing behind.
SELECT pgedge_vectorizer.generate_embedding(:'question')::text
           AS question_embedding,
       pgedge_vectorizer.generate_embedding(:'unrelated')::text
           AS unrelated_embedding
\gset

-- Run every step that changes the cache in one transaction, so that
-- PostgreSQL rolls the new entry back if any step fails.
BEGIN;

-- The statistics cover every application that uses the cache, so record
-- them before the program starts.
SELECT total_entries AS entries_before,
       total_hits AS hits_before,
       total_misses AS misses_before
  FROM semantic_cache.cache_stats()
\gset

-- Cache one result, look it up twice, and then ask an unrelated question.
-- get_cached_result() counts each hit and miss itself.
SELECT semantic_cache.cache_query(
           query_text      => :'question',
           query_embedding => :'question_embedding',
           result_data     => '{"average_order_value": 42.50}',
           ttl_seconds     => 3600,
           tags            => ARRAY['cookbook', 'cookbook-cache-statistics']
       ) AS cache_id
\gset

SELECT format('First lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'question_embedding', 0.95);
SELECT format('Second lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'question_embedding', 0.95);
SELECT format('Unrelated lookup -> %s',
              CASE WHEN found THEN 'hit' ELSE 'miss' END)
  FROM semantic_cache.get_cached_result(:'unrelated_embedding', 0.95);

-- Read the statistics again, and print what changed during the run.
SELECT total_entries - :entries_before AS entries_added,
       total_hits - :hits_before AS hits,
       total_misses - :misses_before AS misses
  FROM semantic_cache.cache_stats()
\gset

\echo Entries added: :entries_added
\echo Hits: :hits
\echo Misses: :misses

SELECT format('Hit rate for this run: %s%%',
              round(100.0 * :hits / nullif(:hits + :misses, 0), 1));

-- Remove the entries that this program created.
SELECT semantic_cache.invalidate_cache(tag => 'cookbook-cache-statistics')
       AS removed
\gset
\echo Removed :removed cached entry

COMMIT;

Next Steps

The following resources provide more information about the topics in this recipe: