Cache Engine
Caching LLM responses is a practical optimization that reduces API costs, lowers latency for repeated queries, and makes your applications more resilient to network interruptions. The cache engine presented here stores text responses in a local SQLite database, supports keyword-based retrieval, and automatically cleans up stale entries — a pattern directly applicable to any Prolog system that calls external LLM APIs.
This chapter is a SWI-Prolog port of the Common Lisp cache-engine library. The Prolog version uses the prosqlite pack for native SQLite access and exposes a clean, modular API.
Design Overview
The cache engine is built around a single SQLite table:
| Column | Type | Description |
|---|---|---|
| id | INTEGER | Auto-incrementing primary key |
| content | TEXT | The cached text string |
| created_at | DATETIME | Automatic timestamp on insertion |
The API provides six core operations:
- cache_open/2 — Create or open a SQLite database file
- cache_add/2 — Insert a text string into the cache
- cache_lookup/3,4 — Retrieve matching entries by keyword search
- cache_count/2 — Count total cached items
- cache_clear/1 — Remove all entries
- cache_clear_older_one_week/1 — Remove entries older than 7 days
Implementation
The cache engine module uses prosqlite for SQLite access. Install it with:
1 ?- pack_install(prosqlite).
Here is the file cache_engine/prolog/cache_engine.pl:
1 :- module(cache_engine, [
2 cache_open/2,
3 cache_close/1,
4 cache_add/2,
5 cache_lookup/3,
6 cache_lookup/4,
7 cache_count/2,
8 cache_clear/1,
9 cache_clear_older_one_week/1
10 ]).
11
12 :- use_module(library(prosqlite)).
13
14 %% cache_open(+DbPath, -Connection)
15 %% Opens (or creates) a SQLite database at DbPath and ensures the
16 %% cache table exists. Returns the Connection alias.
17 %% DbPath should be the base name without extension — prosqlite
18 %% appends '.db' automatically via the ext(db) option.
19 cache_open(DbPath, Connection) :-
20 gensym(cache_db_, Connection),
21 sqlite_connect(DbPath, Connection, [ext(db), exists(false)]),
22 ensure_cache_table(Connection).
23
24 ensure_cache_table(Conn) :-
25 atom_concat(
26 'CREATE TABLE IF NOT EXISTS cache ',
27 '(id INTEGER PRIMARY KEY, ',
28 S1),
29 atom_concat(
30 S1,
31 'content TEXT, ',
32 S2),
33 atom_concat(
34 S2,
35 'created_at DATETIME DEFAULT ',
36 S3),
37 atom_concat(
38 S3,
39 'CURRENT_TIMESTAMP)',
40 SQL),
41 ( sqlite_query(Conn, SQL, _Row)
42 -> true
43 ; true
44 ).
45
46 %% cache_close(+Connection)
47 %% Closes the SQLite database connection.
48 cache_close(Connection) :-
49 sqlite_disconnect(Connection).
50
51 %% cache_add(+Connection, +Text)
52 %% Adds a string to the cache.
53 cache_add(Connection, Text) :-
54 escape_sql(Text, Escaped),
55 format(atom(SQL), "INSERT INTO cache (content) VALUES ('~w')",
56 [Escaped]),
57 ( sqlite_query(Connection, SQL, _Row) -> true ; true ).
58
59 %% cache_lookup(+Connection, +SearchTerms, -Results)
60 %% Returns matching cached strings (default limit 3).
61 %% SearchTerms is a list of atoms/strings to match against content.
62 %% When SearchTerms is empty, returns up to 3 most recent entries.
63 cache_lookup(Connection, SearchTerms, Results) :-
64 cache_lookup(Connection, SearchTerms, Results, [limit(3)]).
65
66 %% cache_lookup(+Connection, +SearchTerms, -Results, +Options)
67 %% Options: limit(N), match_any(true/false)
68 %% limit(N) — max number of results (default 3)
69 %% match_any(true) — OR matching (default: AND)
70 cache_lookup(Connection, [], Results, Options) :-
71 option_limit(Options, Limit),
72 format(atom(SQL),
73 "SELECT content FROM cache ORDER BY created_at DESC LIMIT ~d",
74 [Limit]),
75 findall(Content,
76 sqlite_query(Connection, SQL, row(Content)),
77 Results).
78
79 cache_lookup(Connection, SearchTerms, Results, Options) :-
80 SearchTerms \= [],
81 option_limit(Options, Limit),
82 option_match_any(Options, MatchAny),
83 build_where_clause(SearchTerms, MatchAny, WhereClause),
84 format(atom(SQL),
85 "SELECT content FROM cache WHERE ~w ORDER BY created_at DESC LIMIT ~d",
86 [WhereClause, Limit]),
87 findall(Content,
88 sqlite_query(Connection, SQL, row(Content)),
89 Results).
90
91 %% cache_count(+Connection, -Count)
92 %% Returns the number of items in the cache.
93 cache_count(Connection, Count) :-
94 sqlite_table_count(Connection, cache, Count).
95
96 %% cache_clear(+Connection)
97 %% Removes all items from the cache.
98 cache_clear(Connection) :-
99 ( sqlite_query(Connection, "DELETE FROM cache", _Row)
100 -> true ; true ).
101
102 %% cache_clear_older_one_week(+Connection)
103 %% Removes items older than 7 days from the cache.
104 cache_clear_older_one_week(Connection) :-
105 SQL = "DELETE FROM cache WHERE created_at <= datetime('now', '-7 days')",
106 ( sqlite_query(Connection, SQL, _Row)
107 -> true
108 ; true
109 ).
The helper predicates handle SQL construction and escaping:
1 option_limit(Options, Limit) :-
2 ( member(limit(Limit), Options) -> true ; Limit = 3 ).
3
4 option_match_any(Options, MatchAny) :-
5 ( member(match_any(MatchAny), Options) -> true ; MatchAny = false ).
6
7 %% build_where_clause(+Terms, +MatchAny, -Clause)
8 %% Builds a SQL WHERE clause from search terms.
9 build_where_clause([Term], _, Clause) :-
10 escape_sql(Term, Escaped),
11 format(atom(Clause), "content LIKE '%~w%'", [Escaped]).
12 build_where_clause([Term|Rest], MatchAny, Clause) :-
13 Rest \= [],
14 ( MatchAny = true -> Connector = " OR " ; Connector = " AND " ),
15 escape_sql(Term, Escaped),
16 format(atom(TermClause), "content LIKE '%~w%'", [Escaped]),
17 build_where_clause(Rest, MatchAny, RestClause),
18 format(atom(Clause), "~w~w~w", [TermClause, Connector, RestClause]).
19
20 %% escape_sql(+Input, -Escaped)
21 %% Doubles single quotes for safe SQL interpolation.
22 escape_sql(Input, Escaped) :-
23 atom_string(Input, Str),
24 split_string(Str, "'", "", Parts),
25 atomics_to_text(Parts, "''", Escaped).
26
27 %% atomics_to_text(+Parts, +Sep, -Text)
28 %% Joins a list of strings with separator Sep.
29 atomics_to_text([P], _, P) :- !.
30 atomics_to_text([P|Ps], Sep, Text) :-
31 atomics_to_text(Ps, Sep, Rest),
32 atom_concat(P, Sep, Temp),
33 atom_concat(Temp, Rest, Text).
Usage Examples
Open a cache, add entries, and look them up:
1 ?- cache_open(my_cache, C),
2 cache_add(C, 'The quick brown fox jumps over the lazy dog'),
3 cache_add(C, 'Common Lisp is powerful'),
4 cache_add(C, 'SQLite is a great database').
5
6 ?- cache_lookup(C, [fox], Results).
7 Results = ['The quick brown fox jumps over the lazy dog'].
8
9 ?- cache_lookup(C, ['Lisp', powerful], R).
10 R = ['Common Lisp is powerful'].
Use OR matching to broaden the search:
1 ?- cache_lookup(C, [fox, database], R, [match_any(true)]).
2 R = ['SQLite is a great database',
3 'The quick brown fox jumps over the lazy dog'].
Clean up old entries and close:
1 ?- cache_clear_older_one_week(C).
2 ?- cache_close(C).
Key Design Decisions
Why prosqlite? SWI-Prolog does not ship with a built-in SQLite interface, but the prosqlite pack provides native C bindings to libsqlite3. This gives us proper SQL semantics, ACID transactions, and the full power of SQLite’s query language — including LIKE for fuzzy matching and datetime() functions for timestamp arithmetic.
SQL construction vs. parameterized queries. The prosqlite pack does not support parameterized queries (prepared statements with ? placeholders). We construct SQL strings using format/2 and escape single quotes in user input via the escape_sql/2 helper. For a cache engine handling LLM responses, this is sufficient and keeps the code straightforward.
Connection management. Each call to cache_open/2 generates a unique connection alias via gensym/2. This allows multiple independent caches to be open simultaneously — useful when different subsystems (e.g., an LLM client and a web scraper) maintain separate caches.
Practical Applications
This cache engine is designed to sit between your Prolog application and an external LLM API. Common use patterns include:
- Deduplication: Before calling an expensive LLM API, look up the cache for similar prior responses.
- Session context: Accumulate LLM responses during a session and use
cache_lookup/4withmatch_any(true)to retrieve relevant context for follow-up queries. - Cost control: Cache responses to avoid redundant API calls, especially during development and testing.
- Stale data management: Use
cache_clear_older_one_week/1in a periodic cleanup to prevent unbounded growth.