← Kembali ke Blog

Semantic Cache untuk LLM: Jawab Pertanyaan yang Sama Tanpa Bayar Dua Kali

Tiga pelanggan membuka widget support kamu di jam yang sama. Satu nanya "cara reset password gimana", satu ngetik "lupa password", satu lagi kirim "link reset password nggak works". Maksudnya sama, prompt-nya sama, jawaban benarnya juga sama. Tanpa cache, kamu bayar tiga generasi penuh dan menunggu tiga kali.

Cache exact-match menyimpan string pertama dan meleset untuk dua sisanya, karena menyamakan teks mentah adalah perbandingan yang salah untuk bahasa. Semantic cache meng-embed pertanyaan, mencari tetangga terdekat di index vektor, lalu mengembalikan jawaban tersimpan kalau tetangganya cukup dekat. Pencariannya bagian yang mudah. Yang menentukan cache ini membantu atau merusak adalah threshold dan daftar request yang kamu putuskan untuk tidak di-cache.

Yang perlu disiapkan

  • Docker, untuk image Redis yang sudah membawa modul search
  • Python 3.11 atau lebih baru, plus pip install redis openai numpy
  • API key OpenAI, atau server embedding lokal seperti Ollama (dimensi index harus cocok dengan modelmu)
  • Satu panggilan LLM yang sudah jalan

Command di bawah dijalankan di redis/redis-stack-server dengan Redis 7.4 dan search module 2.10.

Langkah 1: jalankan Redis dengan query engine

redis:7 biasa nggak punya FT.CREATE. Kalau kode ini diarahkan ke Redis polos, semua panggilan gagal dengan unknown command 'FT.CREATE'. Pakai image Stack:

docker run -d --name semcache -p 6379:6379 redis/redis-stack-server:latest
docker exec semcache redis-cli MODULE LIST

Output-nya harus memuat modul bernama search.

Langkah 2: tentukan arti cache key

Dua lapis, karena biayanya beda:

  1. Lapis exact: sha256(namespace + pertanyaan ternormalisasi) di key string biasa. Tanpa panggilan embedding, tanpa false positive, dan menangkap traffic yang datang dengan kata persis sama.
  2. Lapis semantic: satu hash per jawaban, isinya pertanyaan, embedding-nya, response, namespace, dan timestamp.

Sebagian besar desainnya ada di namespace. Masukkan nama model, versi prompt, dan tenant, misalnya v3_gpt55_tenant42. Naikkan versi prompt, jawaban lama otomatis berhenti dipakai tanpa perlu flush. Batasi ke huruf, angka, dan underscore: query TAG di Redis memperlakukan tanda baca sebagai sintaks, jadi titik dua atau strip di dalam tag butuh escaping yang gampang kelupaan.

Langkah 3: bikin index

import numpy as np
from redis import Redis
from redis.commands.search.field import NumericField, TagField, TextField, VectorField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.exceptions import ResponseError

INDEX = "semcache"
PREFIX = "semcache:vec:"
DIM = 1536

r = Redis(host="localhost", port=6379, decode_responses=False)


def ensure_index() -> None:
    schema = (
        TextField("prompt"),
        TagField("ns"),
        NumericField("created_at"),
        VectorField(
            "embedding",
            "HNSW",
            {
                "TYPE": "FLOAT32",
                "DIM": DIM,
                "DISTANCE_METRIC": "COSINE",
                "M": 16,
                "EF_CONSTRUCTION": 200,
            },
        ),
    )
    try:
        r.ft(INDEX).info()
    except ResponseError:
        r.ft(INDEX).create_index(
            fields=schema,
            definition=IndexDefinition(prefix=[PREFIX], index_type=IndexType.HASH),
        )


ensure_index()

VECTOR HNSW 6 artinya ada enam atribut setelah nama field. M dan EF_CONSTRUCTION membentuk graph saat build: makin tinggi nilainya, makin bagus recall dan makin lambat insert. Kalau dihilangkan, Redis memakai 16 dan 200, angka awal yang wajar untuk cache berisi ratusan ribu entri.

Index-nya hidup di server, bukan di proses kamu. Bikin sekali; ensure_index ditulis supaya aman dipanggil setiap start.

Langkah 4: lookup dulu, baru simpan

import hashlib
import time
import uuid

from openai import OpenAI
from redis.commands.search.query import Query

openai_client = OpenAI()
THRESHOLD = 0.92


def embed(text: str) -> np.ndarray:
    resp = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=text[:8000],
    )
    return np.asarray(resp.data[0].embedding, dtype=np.float32)


def normalize(text: str) -> str:
    return " ".join(text.lower().split()).strip(" ?!.")


def exact_key(ns: str, question: str) -> str:
    digest = hashlib.sha256(f"{ns}:{normalize(question)}".encode()).hexdigest()
    return f"semcache:exact:{ns}:{digest}"


def semantic_lookup(ns: str, vector: np.ndarray) -> tuple[str, float] | None:
    query = (
        Query(f"(@ns:{{{ns}}})=>[KNN 1 @embedding $vec AS dist]")
        .return_field("dist")
        .return_field("response")
        .dialect(2)
    )
    result = r.ft(INDEX).search(query, query_params={"vec": vector.tobytes()})
    if not result.docs:
        return None
    doc = result.docs[0]
    similarity = min(1.0, 1.0 - float(doc.dist))
    if similarity < THRESHOLD:
        return None
    return doc.response.decode(), similarity


def ask_model(question: str) -> str:
    resp = openai_client.responses.create(
        model="gpt-5-mini",  # ganti dengan model yang sudah kamu pakai
        input=question,
    )
    return resp.output_text


def answer(ns: str, question: str, ttl: int = 86_400) -> tuple[str, str]:
    cached = r.get(exact_key(ns, question))
    if cached is not None:
        return cached.decode(), "exact"

    vector = embed(question)
    hit = semantic_lookup(ns, vector)
    if hit is not None:
        return hit[0], "semantic"

    reply = ask_model(question)

    doc_key = f"{PREFIX}{ns}:{uuid.uuid4().hex}"
    pipe = r.pipeline()
    pipe.set(exact_key(ns, question), reply, ex=ttl)
    pipe.hset(
        doc_key,
        mapping={
            "prompt": question,
            "ns": ns,
            "created_at": time.time(),
            "response": reply,
            "embedding": vector.tobytes(),
        },
    )
    pipe.expire(doc_key, ttl)
    pipe.execute()
    return reply, "miss"

Empat detail di kode itu menentukan semuanya.

decode_responses=False bukan pilihan. Kalau decoding dinyalakan, redis-py mengembalikan str untuk setiap value, dan vektor float32 nggak selamat dari round trip. Embedding yang tersimpan balik dalam kondisi rusak dan semua skor kemiripan jadi sampah.

Redis melaporkan cosine distance, bukan similarity. Untuk metrik COSINE, similarity = 1 - dist. Error float bisa mendorong vektor identik ke 1.0000001, jadi clamp di 1.0 sebelum dibandingkan atau dicatat.

Hash dan key string dua-duanya butuh TTL. Menyetel satu dan lupa yang lain adalah cara cache pelan-pelan berubah jadi database.

Embedding dihitung sekali per request dan dipakai untuk lookup maupun simpan. Memanggil embed dua kali menggandakan biaya embedding untuk setiap miss, yaitu traffic yang justru mau kamu tekan.

Langkah 5: pilih threshold pakai angka, bukan feeling

Di 0.99 hampir nggak ada hit. Di 0.80 kamu mulai menjawab "cara reset password" dengan instruksi ganti alamat email, disampaikan dengan penuh percaya diri. Dua-duanya nggak kelihatan di dashboard kalau kamu nggak mencarinya.

Susun set berlabel dari pertanyaan nyata, lalu sweep:

import numpy as np

from cache import embed


def cosine(a, b) -> float:
    a, b = np.asarray(a), np.asarray(b)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))


# maksud sama, kata beda: harus kena
same = [
    ("how do i reset my password", "i forgot my password"),
    ("how do i reset my password", "password reset link not working"),
    ("can i export my data as csv", "download all my data to excel"),
    ("what is the refund window", "how many days do i have to get a refund"),
]

# topik berdekatan, jawaban beda: tidak boleh kena
different = [
    ("how do i reset my password", "how do i change my email address"),
    ("how do i export my data as csv", "how do i delete my account"),
    ("what is the refund window", "do you ship internationally"),
    ("how do i reset my password", "the login page is slow"),
]

same_sims = [cosine(embed(a), embed(b)) for a, b in same]
diff_sims = [cosine(embed(a), embed(b)) for a, b in different]

for t in np.arange(0.80, 0.99, 0.01):
    recall = sum(s >= t for s in same_sims) / len(same_sims)
    false_accept = sum(s >= t for s in diff_sims) / len(diff_sims)
    print(f"{t:.2f}  recall {recall:.2f}  false accept {false_accept:.2f}")

Baca output-nya sebagai kurva trade-off, bukan skor. Ambil threshold tertinggi yang masih menangkap parafrase yang kamu pedulikan, lalu cek manual 20 hit acak. Threshold yang kelihatan aman di delapan pasangan bisa salah di traffic asli, dan satu-satunya cara tahu adalah membaca apa yang dikembalikan cache.

Kalau nggak ada threshold yang memberi recall layak sekaligus nol false accept, model embedding-nya terlalu lemah untuk domain itu. Coba model yang lebih besar sebelum menurunkan standar.

Yang tidak boleh masuk cache

  • Request yang membawa riwayat percakapan. Pertanyaan sama di thread berbeda punya jawaban berbeda.
  • Apa pun yang memanggil tool, menulis data, refund, atau kirim email. Cache hit melewati efek sampingnya, dan retry yang diam-diam tidak melakukan apa-apa lebih buruk daripada request lambat.
  • Data yang berubah tiap menit: status order, saldo, stok.
  • Jawaban yang difilter izin. Kalau jawaban tersimpan berasal dari sesi admin, pelanggan bisa membacanya. Tenant di namespace adalah batas minimum, bukan seluruh kontrolnya.
  • Output personalisasi apa pun. Cache hasil retrieval-nya, bukan balasannya.

Streaming tetap aman. Simpan teks jadi, lalu kirim ulang sebagai satu chunk, atau stream ulang dari string tersimpan. Pembaca tetap dapat jawaban lebih cepat.

Operasionalnya

Hitung tiga hasil, bukan satu: exact, semantic, miss. Miss rate naik sementara traffic datar berarti threshold terlalu tinggi atau versi prompt berubah. Exact rate naik biasanya berarti client retry.

Catat similarity untuk setiap hit semantic. Kalau median hit cuma 0.02 di atas threshold, cache bekerja di tepi akurasinya dan satu deploy buruk akan melewatinya.

Memori bisa dihitung. Vektor float32 1536 dimensi makan 6.144 byte, jadi 100.000 entri sekitar 614MB sebelum overhead index. text-embedding-3-small menerima argumen dimensions kalau kamu perlu memangkasnya, dengan sedikit penurunan akurasi.

Saat ganti model embedding, bikin nama index baru dan drop yang lama dengan FT.DROPINDEX semcache DD. Dimensi nggak bisa diubah di tempat, dan vektor yang tidak cocok gagal saat query, bukan saat tulis.

Kapan pakai ini, kapan jangan

Exact cache saja: kata-kata tetap, halaman FAQ, response template. Nol false positive dan tanpa biaya embedding. Sering sudah cukup.

Prompt caching dari provider: system prompt atau dokumen panjang yang sama, pertanyaannya beda. Ini memangkas biaya input di prefix dan nggak membantu saat dua user menanyakan hal sama dengan kata berbeda. Dua teknik ini bisa dipakai bersamaan.

Library seperti GPTCache atau semantic cache dari RedisVL: vectorizer, similarity evaluator, dan kebijakan eviction lebih lengkap. Pakai kalau kamu nggak mau merawat kode ini sendiri, atau butuh adapter untuk beberapa vector store.

Lewati lapis semantic kalau volume kamu kecil, kalau setiap jawaban spesifik per user, atau kalau agent memanggil tool di setiap turn. Cache-nya cuma jadi sistem stateful tambahan untuk di-debug dan hit rate-nya nggak pernah menutup biayanya.

Langkah berikutnya

Tiga peningkatan yang layak dikerjakan, berurutan: tambah filter TAG per tenant supaya satu workspace nggak bisa membaca jawaban cache workspace lain, masukkan delapan pasangan di atas ke CI sebagai regression test yang gagal saat pasangan yang dulu aman mulai match, dan tambah negative cache untuk pertanyaan yang ditolak model supaya traffic abuse nggak dapat generasi gratis setiap kali.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis