33 语义缓存(Semantic Cache)

语义缓存(Semantic Cache)

为什么需要缓存?

在生产环境中,LLM 调用的主要成本来源是:

  • Token 费用:每次调用都消耗 input + output tokens
  • 延迟:即使 DeepSeek 很快,也需要几百毫秒到几秒

如果用户问的是意思相近的问题,没必要每次都调用 LLM,直接返回缓存的答案就行。

普通缓存 vs 语义缓存

类型 匹配方式 局限
精确缓存(exact match) 字符串完全相等 "手机多少钱" ≠ "这款手机价格是多少"
语义缓存(semantic cache) Embedding 向量相似度 语义相近即命中

核心原理

复制代码
用户提问
    ↓
把问题转成 Embedding 向量
    ↓
在缓存里搜索相似向量(cosine similarity)
    ↓
相似度 > 阈值?
    ├─ 是 → 直接返回缓存答案(0 token 消耗)
    └─ 否 → 调用 LLM → 把问题+答案存入缓存

技术组件

组件 作用 我们用什么
Embedding 模 把文本转成向量型 BAAI/bge-m3(SiliconFlow)
向量存储 存储和检索向量 FAISS(本地,无需服务器)
相似度阈值 控制命中灵敏度 cosine similarity,推荐 0.95

FAISS(Facebook AI Similarity Search)

  • 纯本地的向量检索库,不需要启动服务
  • 速度极快,适合缓存这种小规模场景
  • 相比 Chroma,FAISS 更轻量

与 RAG 的区别

  • RAG:用向量搜索找"相关文档"来增强回答
  • 语义缓存:用向量搜索找"相同问题的历史回答",直接返回,不调用 LLM

示例代码

py 复制代码
class SemanticCache:
    def __init__(self, embedder, threshold=0.8):
        # 存储结构:list of (向量, 问题, 答案)
        self.embedder = embedder
        self.threshold = threshold
        self.cache = []

    def get(self, query: str) -> str | None:
        # 1. 把 query 转成向量
        query_vector = self.embedder.embed_query(query)
        # 2. 与缓存里每条向量计算 cosine similarity
        best_match = None
        best_score = 0
        for vector, question, answer in self.cache:
            score = calculate_cosine_similarity(query_vector, vector)
            if score > best_score:
                best_score = score
                best_match = (question, answer)
        # 3. 如果最高分 >= threshold,返回对应答案
        if best_score >= self.threshold:
            print(f"  → Hit (best={best_score:.4f})")
            return best_match[1]
        print(f"  → Miss (best={best_score:.4f})")
        # 4. 否则返回 None
        return None

    def set(self, query: str, answer: str):
        # 把 (向量, 问题, 答案) 存入缓存
        self.cache.append((self.embedder.embed_query(query), query, answer))


def ask_with_cache(query: str, cache: SemanticCache, llm) -> tuple[str, bool]:
    # 先查缓存,命中返回 (答案, True)
    cached = cache.get(query)
    # 未命中调 LLM,存入缓存,返回 (答案, False)
    if cached is not None:
        return cached, True
    answer = llm.invoke(
        [
            SystemMessage(content="简短回答问题,最多不超过150个字"),
            HumanMessage(content=query),
        ]
    ).content
    cache.set(query, answer)
    return answer, False

def main():
    env = validate_env()
    if not env:
        return
    api_key, base_url, model, embedding_api_key, embedding_base_url, embedding_model = (
        env
    )

    # 初始化 embedder、llm、cache
    llm = ChatOpenAI(api_key=api_key, base_url=base_url, model=model)
    embeddings = OpenAIEmbeddings(
        base_url=embedding_base_url, api_key=embedding_api_key, model=embedding_model
    )
    cache = SemanticCache(embeddings, threshold=0.70)
    # 用以下测试组验证:
    test_pairs = [
        ("这款手机多少钱?", False),  # 首次:未命中,存入缓存
        ("这款手机的价格是多少?", True),  # 同义问句:命中
        ("请问这款手机售价是多少?", True),  # 同义问句:命中
        ("这款手机有什么颜色?", False),  # 不同话题:未命中,存入缓存
        ("这款手机是什么颜色的?", True),  # 同义问句:命中(0.74)
    ]
    # 打印每次是否命中缓存,以及返回的答案

    for query, expected_hit in test_pairs:
        answer, hit = ask_with_cache(query, cache, llm)
        print(f"Query: {query}")
        print(f"Answer: {answer}")
        print(f"Expected hit: {expected_hit}")
        print(f"Hit: {hit}")
        print("-" * 50)