上线了一个客服机器人之后,你会发现一个规律:70%的问题都是重复的。“怎么退款”、“物流到哪了”、“密码忘了怎么办”——每天被问一万遍。每次都调一遍LLM,钱烧得心疼,延迟还高。

这篇文章分享一套基于向量相似度的语义缓存方案,实测能拦截80%的重复请求,响应时间从2秒降到50毫秒。

为什么需要语义缓存

传统缓存用精确匹配,“我的订单在哪"和"订单怎么查"意思一样但缓存命中率为零。语义缓存的核心思想是:把问题编码成向量,用相似度判断是否命中。

用户问:"怎么退货"
缓存里有:"如何办理退款"
→ 字面匹配:miss
→ 语义匹配:similarity=0.92,hit!直接返回缓存答案

架构设计

整体流程分三层:向量化层、缓存层、决策层。

flowchart TD A[用户提问] --> B[Embedding] B --> C[向量检索] C --> D{是否命中?} D -->|是| E[返回缓存] D -->|否| F[调用LLM] F --> G[写入缓存]

核心实现

向量化与存储

用sentence-transformers做embedding,Redis Stack做向量存储:

import numpy as np
from sentence_transformers import SentenceTransformer
import redis
from redis.commands.search.field import VectorField, TextField
from redis.commands.search.indexDefinition import IndexDefinition

class SemanticCache:
    def __init__(self, redis_url="redis://localhost:6379",
                 model_name="BAAI/bge-small-zh-v1.5",
                 threshold=0.85):
        self.model = SentenceTransformer(model_name)
        self.redis = redis.from_url(redis_url)
        self.threshold = threshold
        self.dim = 512  # bge-small维度
        self._ensure_index()

    def _ensure_index(self):
        try:
            self.redis.ft("cache_idx").info()
        except:
            schema = (
                TextField("question"),
                TextField("answer"),
                VectorField("embedding", "HNSW", {
                    "TYPE": "FLOAT32",
                    "DIM": self.dim,
                    "DISTANCE_METRIC": "COSINE"
                })
            )
            self.redis.ft("cache_idx").create_index(
                schema,
                index_definition=IndexDefinition(prefix=["cache:"])
            )

    def _embed(self, text: str) -> np.ndarray:
        return self.model.encode(text, normalize_embeddings=True)

缓存查询

用KNN做近似最近邻搜索,O(log n)时间复杂度:

    def get(self, question: str) -> str | None:
        vec = self._embed(question)
        vec_bytes = vec.astype(np.float32).tobytes()

        results = self.redis.ft("cache_idx").search(
            f"*=>[KNN 1 @embedding $vec AS score]",
            query_params={"vec": vec_bytes}
        )

        if results.docs:
            score = float(results.docs[0].score)
            similarity = 1 - score  # Redis返回距离,转为相似度
            if similarity >= self.threshold:
                return results.docs[0].answer
        return None

缓存写入

写入时附带TTL,热点问题自动续期:

    def put(self, question: str, answer: str, ttl: int = 86400):
        vec = self._embed(question)
        key = f"cache:{hash(question)}"
        self.redis.hset(key, mapping={
            "question": question,
            "answer": answer,
            "embedding": vec.astype(np.float32).tobytes()
        })
        self.redis.expire(key, ttl)

集成到业务

cache = SemanticCache(threshold=0.85)

async def ask(question: str) -> str:
    # 先查缓存
    cached = cache.get(question)
    if cached:
        logger.info(f"Cache hit for: {question}")
        return cached

    # 缓存未命中,调LLM
    response = await llm.chat(question)

    # 写入缓存
    cache.put(question, response)
    return response

阈值调优

相似度阈值是最关键的参数。太低会误命中(答非所问),太高会漏掉语义相同的变体。

阈值    命中率    误命中率    推荐场景
0.95    45%      <0.1%     医疗/法律等高精度场景
0.85    72%      ~1%       客服/FAQ(推荐)
0.75    85%      ~5%       闲聊机器人
0.65    92%      ~15%      不推荐

实际调优方法:收集一批历史问答,人工标注哪些应该命中、哪些不应该,然后网格搜索找最优阈值。

def evaluate_threshold(pairs, thresholds):
    for t in thresholds:
        cache = SemanticCache(threshold=t)
        hits, false_hits = 0, 0
        for q, expected_answer in pairs:
            cached = cache.get(q)
            if cached:
                if cached == expected_answer:
                    hits += 1
                else:
                    false_hits += 1
        print(f"阈值={t}: 命中率={hits/len(pairs):.1%}, 误命中={false_hits/len(pairs):.1%}")

缓存失效策略

不是所有问题都适合缓存。时间敏感的问题需要特殊处理:

import re

NO_CACHE_PATTERNS = [
    r"今天", r"现在", r"最新", r"实时",
    r"\d{4}年\d{1,2}月",  # 具体日期
]

def should_cache(question: str) -> bool:
    for pattern in NO_CACHE_PATTERNS:
        if re.search(pattern, question):
            return False
    return True

另外加一个手动清缓存的接口,答案过期时主动删除:

    def invalidate(self, question: str):
        """精确删除某个缓存"""
        key = f"cache:{hash(question)}"
        self.redis.delete(key)

    def invalidate_pattern(self, keyword: str):
        """按关键词批量删除"""
        results = self.redis.ft("cache_idx").search(
            f"@question:*{keyword}*"
        )
        for doc in results.docs:
            self.redis.delete(doc.id)

性能数据

在生产环境跑了30天,数据如下:

总请求量:      120,000
缓存命中:       89,400 (74.5%)
LLM调用节省:    89,400 次
平均响应时间:
  缓存命中:     52ms
  缓存未命中:   1,840ms
API成本节省:    ~¥3,600/月(按GPT-4o计费)

写在最后

语义缓存本质上是用空间换时间、用向量距离换调用成本。实现不复杂,但效果很实在——尤其是客服、FAQ这类重复率高的场景。

唯一的坑是阈值调优,建议先上线观察一周,根据误命中反馈逐步收紧。另外记得给缓存加监控,命中率跌破60%就要排查是不是用户提问分布变了。