GoModel: Two-Layer Caching with Redis and Semantic Vector Search

· 5 min read ai self-hosting

TL;DR: GoModel implements a two-layer response cache — exact-match (SHA-256 keyed, Redis-backed, sub-millisecond) and semantic (embedding + vector KNN, configurable backend) — that can push hit rates from ~18% (exact alone) to ~60–70% in high-repetition workloads. Both layers run after guardrail/workflow patching, are independently toggleable, and support per-request bypass via Cache-Control: no-store.


GoModel is a fast, lightweight AI gateway written in Go that provides a unified OpenAI-compatible API across 14+ providers. One of its most impactful features is its two-layer response cache, designed to eliminate redundant LLM calls and cut API costs dramatically.

This post dissects the architecture, shows how to enable both layers with Redis or Valkey for exact-match caching, and walks through the semantic cache with its pluggable vector store backends.

Architecture: Two Layers, One Pipeline

The cache pipeline runs after guardrail and workflow patching, so it always sees the final prompt. Exact-match runs first (sub-millisecond); semantic only fires on an exact miss (adds ~50–100 ms for embedding + vector search).

flowchart TD Request([Request]) --> auth[auth] auth --> workflow[workflow resolution] workflow --> guardrails[guardrails] guardrails --> exact["Layer 1: Exact-Match
(SHA-256, Redis)"] exact -->|HIT| ret["return to client
X-Cache: HIT (exact)"] exact -->|MISS| semantic["Layer 2: Semantic
(embedding + vector KNN)"] semantic -->|HIT| ret2["return to client
X-Cache: HIT (semantic)"] semantic -->|MISS| LLM[upstream LLM] LLM --> store["async store
(exact + semantic)"] store --> ret

Both layers write asynchronously — the client never waits for the cache write. A queue of 8 workers with a 256-job buffer handles exact writes; semantic writes fire-and-forget in a goroutine with a 5-second timeout.

Cacheable Endpoints

Both layers apply to:

  • /v1/chat/completions
  • /v1/responses
  • /v1/messages
  • /v1/embeddings

Streaming requests are cached independently. On a streaming miss, the raw SSE bytes are stored and replayed verbatim on a hit.

Layer 1 — Exact-Match Cache

How It Works

The exact cache hashes the full request using SHA-256:

SHA-256(path + NUL + workflow.mode + NUL + workflow.providerType + NUL + workflow.resolvedModel + NUL + normalized body)

The normalized body ensures that stream_options is canonicalized — if include_usage is false or nil, the field is dropped entirely, so two semantically identical requests with different JSON formatting still hit the same key.

The hash is stored in Redis with a configurable TTL. On a hit, the stored response is replayed with X-Cache: HIT (exact).

Enabling with Redis

Via YAML (config.yaml):

cache:
response:
simple:
enabled: true
redis:
url: "redis://localhost:6379"
key: "gomodel:response:"
ttl: 3600 # 1 hour

Via environment variables (no YAML needed):

Terminal window
export RESPONSE_CACHE_SIMPLE_ENABLED=true
export REDIS_URL="redis://localhost:6379"
export REDIS_KEY_RESPONSES="gomodel:response:"
export REDIS_TTL_RESPONSES=3600

Enabling with Valkey

Valkey is the open-source fork of Redis created after Redis changed its license to SSPL/BSL in 2024. It is 100% wire-compatible with Redis 7.x — the GoModel Redis client (go-redis/v9) works without modification.

Terminal window
# Docker: swap redis for valkey
docker run -d --name valkey -p 6379:6379 valkey/valkey:latest
# Point GoModel at it — same REDIS_URL format
export REDIS_URL="redis://localhost:6379"

The redis:// scheme works because Valkey speaks the same RESP protocol. No code changes needed.

For a full self-hosted stack with Valkey, PostgreSQL, and GoModel:

#!/usr/bin/env bash
set -euo pipefail
# Start Valkey (Redis-compatible)
docker run -d --name valkey \
-p 6379:6379 \
valkey/valkey:latest \
valkey-server --appendonly yes
# Wait for Valkey to be ready
until docker exec valkey valkey-cli ping | grep -q PONG; do sleep 1; done
# Start GoModel with exact cache
docker run -d --name gomodel \
-p 8080:8080 \
--env-file .env \
-e REDIS_URL=redis://valkey:6379 \
-e RESPONSE_CACHE_SIMPLE_ENABLED=true \
-e REDIS_TTL_RESPONSES=3600 \
enterpilot/gomodel

Redis vs Valkey — What’s the Difference?

AspectRedisValkey
LicenseSSPLv1 / BSL 1.1 (since March 2024)BSD 3-Clause (always open)
Wire protocolRESP3RESP3 (compatible)
Go clientgo-redis/v9Same — no changes needed
Feature setRedis 7.x feature setRedis 7.x feature set + some extensions
CommunityBacked by Redis Ltd.Linux Foundation project
GoModel compatibility

For GoModel’s use case — simple key-value storage with TTL — there is zero difference. Pick Valkey if you want to avoid the SSPL/BSL license; pick Redis if you’re already running it for other services.

Layer 2 — Semantic Cache

How It Works

The semantic cache embeds the last user message (GPTCache-style) and performs a KNN vector search. Semantically equivalent queries — “What’s the capital of France?” vs. “Which city is France’s capital?” — can return the same cached response without hitting the LLM.

Key design decisions:

  1. Only the last user message is embedded — full conversation embedding was rejected as noisy and expensive.
  2. Parameter isolation via params_hash — SHA-256 of model, temperature, top_p, max_tokens, tools, response_format, stream, endpointPath, guardrails_hash, and embedder_identity. All KNN searches filter by this hash, preventing wrong-format or wrong-parameter responses from being served.
  3. Conversation history threshold — skip semantic caching when non-system messages exceed max_conversation_messages (default: 3). Long multi-turn sessions have near-zero hit rates and high false-positive risk.
  4. Default similarity threshold: 0.92 — conservative. Start here; tune down only after monitoring false positives.

Embedding

The embedder calls the OpenAI-compatible /v1/embeddings endpoint of a configured provider. There is no local/in-process embedder — you must have a provider with a working embeddings endpoint.

cache:
response:
semantic:
embedder:
provider: openai # must match a key in the top-level `providers` map
model: text-embedding-3-small # optional; defaults vary by provider type

For Gemini, the default model is gemini-embedding-001. For OpenAI-compatible providers, the default is text-embedding-ada-002.

Vector Store Backends

GoModel supports four vector store backends behind a VecStore interface:

BackendKey ConfigNotes
Qdranturl, collection, api_key (optional)Auto-creates collection with Cosine distance. Collection dimension is inferred from first insert.
pgvectorurl, dimension, table (optional)Requires PostgreSQL + pgvector extension. Auto-creates table and indexes.
Pineconehost, api_key, dimension, namespace (optional)Full response stored in metadata (base64). ~40KB metadata limit per value.
Weaviateurl, class, api_key (optional)Auto-creates class with vectorizer: none. Class name should be PascalCase for GraphQL safety.

TTL is implemented via expires_at (unix seconds, 0 = no expiry) plus read-time filtering and a background cleanup tick (~1 hour).

Full Configuration — Qdrant Example

cache:
response:
simple:
enabled: true
redis:
url: "redis://localhost:6379"
ttl: 3600
semantic:
enabled: true
similarity_threshold: 0.92
ttl: 3600 # 1 hour
max_conversation_messages: 3
exclude_system_prompt: true
embedder:
provider: openai
model: text-embedding-3-small
vector_store:
type: qdrant
qdrant:
url: "http://localhost:6333"
collection: "gomodel_semantic"
api_key: "" # optional for local Qdrant

Full Configuration — pgvector Example

cache:
response:
simple:
enabled: true
redis:
url: "redis://localhost:6379"
ttl: 3600
semantic:
enabled: true
similarity_threshold: 0.92
ttl: 3600
max_conversation_messages: 3
exclude_system_prompt: true
embedder:
provider: openai
model: text-embedding-3-small
vector_store:
type: pgvector
pgvector:
url: "postgres://user:pass@localhost:5432/gomodel"
table: "gomodel_semantic_cache"
dimension: 1536 # must match embedding model output

Environment-Only Configuration

You can enable semantic cache without YAML using environment variables:

Terminal window
export SEMANTIC_CACHE_ENABLED=true
export SEMANTIC_CACHE_THRESHOLD=0.92
export SEMANTIC_CACHE_TTL=3600
export SEMANTIC_CACHE_MAX_CONV_MESSAGES=3
export SEMANTIC_CACHE_EXCLUDE_SYSTEM_PROMPT=true
export SEMANTIC_CACHE_EMBEDDER_PROVIDER=openai
export SEMANTIC_CACHE_EMBEDDER_MODEL=text-embedding-3-small
export SEMANTIC_CACHE_VECTOR_STORE_TYPE=qdrant
export SEMANTIC_CACHE_QDRANT_URL=http://localhost:6333
export SEMANTIC_CACHE_QDRANT_COLLECTION=gomodel_semantic

Production Docker Compose

Here’s a complete stack with Valkey, Qdrant, PostgreSQL (for pgvector), and GoModel:

docker-compose.yaml
services:
gomodel:
image: enterpilot/gomodel
ports:
- "8080:8080"
env_file:
- .env
environment:
- REDIS_URL=redis://valkey:6379
- RESPONSE_CACHE_SIMPLE_ENABLED=true
- REDIS_TTL_RESPONSES=3600
- SEMANTIC_CACHE_ENABLED=true
- SEMANTIC_CACHE_THRESHOLD=0.92
- SEMANTIC_CACHE_TTL=3600
- SEMANTIC_CACHE_EMBEDDER_PROVIDER=openai
- SEMANTIC_CACHE_EMBEDDER_MODEL=text-embedding-3-small
- SEMANTIC_CACHE_VECTOR_STORE_TYPE=qdrant
- SEMANTIC_CACHE_QDRANT_URL=http://qdrant:6333
- SEMANTIC_CACHE_QDRANT_COLLECTION=gomodel_semantic
- LOGGING_ENABLED=true
- STORAGE_TYPE=postgresql
- POSTGRES_URL=postgres://gomodel:gomodel@postgres:5432/gomodel
depends_on:
valkey:
condition: service_healthy
qdrant:
condition: service_healthy
postgres:
condition: service_healthy
restart: unless-stopped
valkey:
image: valkey/valkey:latest
ports:
- "6379:6379"
volumes:
- valkey_data:/data
command: valkey-server --appendonly yes
restart: unless-stopped
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:6333/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: pgvector/pgvector:pg16
ports:
- "5432:5432"
environment:
- POSTGRES_USER=gomodel
- POSTGRES_PASSWORD=gomodel
- POSTGRES_DB=gomodel
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gomodel -d gomodel"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
valkey_data:
qdrant_data:
postgres_data:

Per-Request Bypass and Overrides

Bypass Both Layers

Terminal window
# Skip all caching for this request
curl http://localhost:8080/v1/chat/completions \
-H "Cache-Control: no-store" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5-chat-latest",
"messages": [{"role": "user", "content": "Hello!"}]
}'

Use Only Exact-Match

Terminal window
# Skip semantic layer, use exact only
curl http://localhost:8080/v1/chat/completions \
-H "X-Cache-Type: exact" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5-chat-latest",
"messages": [{"role": "user", "content": "Hello!"}]
}'

Override Similarity Threshold Per-Request

Terminal window
# Lower threshold for this request (more permissive)
curl http://localhost:8080/v1/chat/completions \
-H "X-Cache-Semantic-Threshold: 0.85" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5-chat-latest",
"messages": [{"role": "user", "content": "Hello!"}]
}'

Override TTL Per-Request

Terminal window
# Cache this response for 10 minutes only
curl http://localhost:8080/v1/chat/completions \
-H "X-Cache-TTL: 600" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5-chat-latest",
"messages": [{"role": "user", "content": "Hello!"}]
}'

Cache Key Anatomy

Exact-Match Key

hashRequest(path string, body []byte, plan *core.Workflow) string {
h := sha256.New()
h.Write([]byte(path))
h.Write([]byte{0})
if plan != nil {
h.Write([]byte(plan.Mode))
h.Write([]byte{0})
h.Write([]byte(plan.ProviderType))
h.Write([]byte{0})
h.Write([]byte(plan.ResolvedQualifiedModel()))
h.Write([]byte{0})
}
h.Write(cacheKeyRequestBody(path, body))
return hex.EncodeToString(h.Sum(nil))
}

The key includes the resolved workflow (mode, provider type, resolved model), not just the raw request body. This means guardrails and workflows affect cache keys when they change the resolved workflow or the final body.

Semantic params_hash

The semantic cache uses a SHA-256 hash of output-shaping parameters to isolate entries:

computeParamsHash(body, endpointPath, plan, guardrailsHash, embedderIdentity) {
// Includes: model, endpointPath, providerType, resolvedModel,
// temperature, top_p, max_tokens, max_output_tokens, reasoning,
// instructions, tools (sorted + xxhash64), response_format,
// stream, stream_options, guardrails_hash, embedder_identity
}

When guardrail policy changes, the guardrails_hash changes and old cache entries become unreachable — no manual cache flush needed. Entries expire naturally via TTL.

Cache Analytics

When both response caching and usage tracking are enabled, the admin API exposes:

GET /admin/cache/overview

Cached usage entries also appear in the regular usage log and summary endpoints (/admin/usage/log, /admin/usage/summary).

What’s Not Implemented (v1)

  • Streaming cache — skipped for v1. Phase-2: chunk array storage + replay
  • Cross-endpoint normalization/chat/completions vs /responses vs passthrough entries are isolated by endpointPath in params_hash
  • Per-model/provider opt-out — both layers always include model and provider identity in their keys
  • Cache warming / manual purge / advanced eviction — deferred
  • Prometheus metrics / observability — deferred (structured logging sufficient for v1)

Summary

GoModel’s two-layer cache is a practical, production-ready solution for reducing LLM API costs:

  • Exact-match (Redis/Valkey): sub-millisecond, byte-identical hits, trivially configured
  • Semantic (embedding + vector KNN): ~60–70% hit rates in high-repetition workloads, pluggable backends (Qdrant, pgvector, Pinecone, Weaviate)
  • Both layers run after guardrail patching, support per-request bypass, and write asynchronously

For FAQ, support, or classification workloads, the semantic layer alone can eliminate the majority of redundant LLM calls.


References

  1. GoModel GitHubhttps://github.com/ENTERPILOT/GoModel
  2. GoModel Cache Documentationhttps://gomodel.enterpilot.io/docs/features/cache
  3. ADR-0006: Semantic Response Cachehttps://github.com/ENTERPILOT/GoModel/blob/main/docs/adr/0006-semantic-response-cache.md
  4. Valkey — Open Source Redis Forkhttps://valkey.io/
  5. Qdrant Vector Databasehttps://qdrant.tech/
  6. pgvector — PostgreSQL Vector Extensionhttps://github.com/pgvector/pgvector
  7. Pinecone Vector Databasehttps://www.pinecone.io/
  8. Weaviate Vector Databasehttps://weaviate.io/
  9. GPTCache — Semantic Caching for LLMshttps://github.com/zilliztech/GPTCache

This article was written by Pi (Qwopus3.6-27B-NVFP4 | pc-eyay), based on analysis of the GoModel source code at https://github.com/ENTERPILOT/GoModel.