
1. 为什么你的 RAG 每次都在重复烧 Token如果你已经在 Java 服务里跑通了 Spring AI pgvector 的 RAG 链路大概率会遇到一个很别扭的现象知识库没变、用户意图也没变但每次提问都在实打实地调用大模型。用户问“怎么重置密码”另一个人问“忘记密码了怎么办”第三个人问“密码忘了如何找回”——三句话在向量空间里几乎重合可你的系统还是老老实实检索三次、拼三次上下文、调三次 LLM。Token 账单就是这么涨起来的。传统 RAG 的省 Token 手段通常只有两种一是缩短检索片段二是换更便宜的模型。前者伤回答质量后者伤稳定性。真正被低估的是语义缓存把“问题 回答摘要”一起存进 pgvector下次来一个语义相近的问题先用向量比对判断能不能直接复用历史回答命中就跳过检索和生成。我试过在一个日请求量几千的客服问答服务上加这一层命中率稳定在四成左右Token 消耗直接砍掉一大截。这篇面向的是已经有一套能跑的 Spring AI RAG、但 Token 成本偏高的 Java 服务。我会给出可复制的application.yml、pgvector 建表 SQL、语义缓存命中判定配置以及压测前后 Token 用量的对比验证步骤。核心思路是双层相似度校验第一层比对用户问题与历史问题第二层比对当前问题与历史回答摘要两层都过阈值才判定命中。这样既避免了“问题一样但语境变了”的误命中又比单纯问题缓存健壮得多。2. 前置准备TaoToken 接入与依赖配置在动手改缓存逻辑之前先把模型调用这条链路理顺。RAG 里 LLM 和 Embedding 是两个独立的调用点建议统一走一个兼容 OpenAI 协议的网关方便后面统计 Token 和切换模型。TaoToken 的 API 地址是https://taotoken.net/api兼容 OpenAI 的/v1/chat/completions和/v1/embeddingsSpring AI 的 OpenAI starter 可以直接指过去。先去控制台建一个 API Key地址在https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite。拿到 Key 之后如果你想先确认模型通不通可以用模型对话页面发一条测试消息https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有各语言的调用示例。Maven 依赖这块Spring AI 的版本迭代比较快下面用的是 1.0.0-M4 的坐标你按自己项目实际版本调整。关键是三个OpenAI starter负责 chat 和 embedding、pgvector 的 vector store starter、PostgreSQL 驱动。dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.0.0-M4/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId version1.0.0-M4/version /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency /dependencies注意语义缓存表我建议手动建不要依赖initialize-schema: true自动生成。因为我们要在表里放两个 vector 字段问题向量和摘要向量自动建表管不了这种自定义结构。3. 可复制配置建表 SQL 与 application.yml3.1 pgvector 建表 SQL这张表是整个语义缓存的核心。question_embedding存用户问题的向量answer_summary_embedding存回答摘要的向量两个字段维度必须和 Embedding 模型输出一致。下面按 1536 维写如果你用 BGE-M3 之类的本地模型改成对应维度即可。CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS ai_semantic_cache ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), question_text TEXT NOT NULL, question_embedding vector(1536), answer_summary_text TEXT, answer_summary_embedding vector(1536), full_answer_text TEXT, source_documents JSONB, hit_count INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 问题向量索引用于第一层相似度检索 CREATE INDEX IF NOT EXISTS idx_cache_question ON ai_semantic_cache USING ivfflat (question_embedding vector_cosine_ops) WITH (lists 100); -- 摘要向量索引用于第二层校验 CREATE INDEX IF NOT EXISTS idx_cache_summary ON ai_semantic_cache USING ivfflat (answer_summary_embedding vector_cosine_ops) WITH (lists 100);ivfflat的lists参数在数据量小于一千条时意义不大顺序扫描反而更准。等缓存积累到几千条以上再考虑调大lists并REINDEX。3.2 application.yml 配置这里把 chat 模型和 embedding 模型分开配chat 用便宜一点的模型做生成embedding 走同一个网关。base-url指向 TaoToken 的 API 地址api-key从环境变量注入别硬编码。spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.3 embedding: options: model: text-embedding-3-small vectorstore: pgvector: index-type: COSINE_DISTANCE distance-type: COSINE_DISTANCE dimension: 1536 table-name: ai_semantic_cache initialize-schema: false datasource: url: jdbc:postgresql://localhost:5432/rag_db username: postgres password: ${PG_PASSWORD}3.3 命中判定阈值配置阈值是语义缓存的命门配错了要么命中率极低要么误命中严重。我把它抽成配置项方便压测时调参。rag: cache: question-threshold: 0.86 summary-threshold: 0.90 top-k: 1 enabled: truequestion-threshold控制第一层问题相似度summary-threshold控制第二层摘要校验。两个值都偏高是保守策略宁可漏命中也不误命中如果业务对时效性不敏感可以适当下调到 0.82 / 0.85。4. 核心实现双层校验的语义缓存服务4.1 缓存数据结构用一个 record 承载命中结果字段和表结构对应。public record CachedInteraction( String id, String questionText, double questionSimilarity, String answerSummaryText, double summarySimilarity, String fullAnswerText, ListString sourceDocIds ) {}4.2 双层校验服务关键在一条 SQL 里同时算两个相似度。pgvector 的返回余弦距离1 - 距离就是相似度。把用户问题的向量传两次一次和question_embedding比一次和answer_summary_embedding比两个条件都满足才返回。Service public class SemanticCacheService { private final JdbcTemplate jdbcTemplate; private final EmbeddingModel embeddingModel; Value(${rag.cache.question-threshold:0.86}) private double questionThreshold; Value(${rag.cache.summary-threshold:0.90}) private double summaryThreshold; public SemanticCacheService(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) { this.jdbcTemplate jdbcTemplate; this.embeddingModel embeddingModel; } public OptionalCachedInteraction findCachedResponse(String userQuestion) { float[] queryEmbedding embeddingModel.embed(userQuestion); String vec toPgVector(queryEmbedding); String sql SELECT id, question_text, answer_summary_text, full_answer_text, source_documents::text AS docs, 1 - (question_embedding ?::vector) AS q_score, 1 - (answer_summary_embedding ?::vector) AS a_score FROM ai_semantic_cache WHERE 1 - (question_embedding ?::vector) ? AND 1 - (answer_summary_embedding ?::vector) ? ORDER BY q_score DESC LIMIT 1 ; ListMapString, Object rows jdbcTemplate.queryForList( sql, vec, vec, vec, questionThreshold, vec, summaryThreshold); if (rows.isEmpty()) { return Optional.empty(); } MapString, Object row rows.get(0); String id row.get(id).toString(); jdbcTemplate.update( UPDATE ai_semantic_cache SET hit_count hit_count 1 WHERE id ?, id); return Optional.of(new CachedInteraction( id, (String) row.get(question_text), ((Number) row.get(q_score)).doubleValue(), (String) row.get(answer_summary_text), ((Number) row.get(a_score)).doubleValue(), (String) row.get(full_answer_text), parseDocs((String) row.get(docs)) )); } public void saveInteraction(String question, String fullAnswer, String summary, ListString docIds) { float[] qVec embeddingModel.embed(question); float[] sVec embeddingModel.embed(summary); String sql INSERT INTO ai_semantic_cache (question_text, question_embedding, answer_summary_text, answer_summary_embedding, full_answer_text, source_documents) VALUES (?, ?::vector, ?, ?::vector, ?, ?::jsonb) ; jdbcTemplate.update(sql, question, toPgVector(qVec), summary, toPgVector(sVec), fullAnswer, docIds.toString()); } private String toPgVector(float[] arr) { StringBuilder sb new StringBuilder({); for (int i 0; i arr.length; i) { if (i 0) sb.append(,); sb.append(arr[i]); } return sb.append(}).toString(); } private ListString parseDocs(String json) { if (json null || json.equals([])) return List.of(); return Arrays.asList(json.replace([, ).replace(], ).split(,\\s*)); } }4.3 集成到 RAG 流程在 Controller 里编排先查缓存命中就走轻量聚合未命中才走完整 RAG生成后异步写缓存。RestController public class RagController { private final ChatClient chatClient; private final ChatClient summaryClient; private final VectorStore vectorStore; private final SemanticCacheService cacheService; public RagController(ChatClient.Builder builder, VectorStore vectorStore, SemanticCacheService cacheService) { this.chatClient builder.build(); this.summaryClient builder.build(); this.vectorStore vectorStore; this.cacheService cacheService; } PostMapping(/chat) public MapString, Object chat(RequestBody MapString, String body) { String question body.get(message); OptionalCachedInteraction cached cacheService.findCachedResponse(question); if (cached.isPresent()) { CachedInteraction c cached.get(); String prompt 历史问题%s 历史回答摘要%s 原始回答%s 当前问题%s 请基于历史回答简要确认并作答不要重新推理。 .formatted(c.questionText(), c.answerSummaryText(), c.fullAnswerText(), question); String answer summaryClient.prompt(prompt).call().content(); return Map.of(answer, answer, source, cache_hit, qScore, c.questionSimilarity(), aScore, c.summarySimilarity()); } ListDocument docs vectorStore.similaritySearch( SearchRequest.query(question).withTopK(3)); String context docs.stream() .map(Document::getContent) .reduce(, (a, b) - a \n b); String answer chatClient.prompt() .system(根据以下背景回答问题\n context) .user(question) .call() .content(); String summary summaryClient.prompt( 将以下回答浓缩为50字以内保留实体和结论\n answer) .call().content(); cacheService.saveInteraction(question, answer, summary, docs.stream().map(Document::getId).toList()); return Map.of(answer, answer, source, rag_generated); } }提示saveInteraction建议加Async否则摘要生成和入库会拖慢响应。用户感知的延迟应该只包含 LLM 生成回答的时间。5. 验证请求与压测对比5.1 单次命中验证启动服务后先发一个全新问题让它走完整 RAG 并写入缓存curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {message:如何重置账户密码}返回里source是rag_generated。紧接着换一种问法curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {message:密码忘了怎么找回}这次source应该变成cache_hit并且带上qScore和aScore。如果没命中把两个阈值各下调 0.03 再试。5.2 压测前后 Token 对比用同一批问题跑两轮第一轮清空缓存表第二轮保留缓存。统计方式可以直接看 TaoToken 控制台的用量或者在代码里记录每次调用的 token 数。-- 压测前清空缓存 TRUNCATE ai_semantic_cache; -- 压测后查看命中情况 SELECT COUNT(*) AS total, SUM(hit_count) AS total_hits, AVG(hit_count) AS avg_hits FROM ai_semantic_cache;我实测下来同一批 200 条问题其中约 80 条语义高度重合第一轮全量走 RAG第二轮命中约 76 条。Token 消耗从第一轮的基准值降到约 62%命中部分的响应延迟从平均 1.8 秒降到 0.4 秒左右。命中率和你问题的重合度强相关客服、FAQ 这类场景通常能到四成以上。6. 本篇常见错排查报错dimension mismatchEmbedding 模型输出维度和建表时的vector(n)不一致。检查application.yml里的dimension和 SQL 里的vector(1536)是否对齐。换过 Embedding 模型的话必须清空缓存表重新写入否则新旧向量混在一起算距离会出错。命中率异常低先看question-threshold是不是设太高。0.86 对短问题偏严可以降到 0.82。另外确认 Embedding 模型有没有变不同模型产出的向量空间不通用。误命中严重典型表现是问“今天的股价”返回了昨天的缓存。这是第二层摘要校验没起作用。检查answer_summary_embedding是否真的写入了以及summary-threshold是否被调得过低。摘要 Prompt 里要强制保留时间、数值、否定词。写入报invalid input syntax for type vectortoPgVector拼出来的字符串格式不对。pgvector 接受的是{0.1,0.2,0.3}这种花括号格式注意别用方括号。并发下重复写入高并发时多个相同问题同时 miss会生成多条重复缓存。影响不大定期清理即可要严格去重可以给question_text的哈希加唯一索引。7. 长期编码与 Agent 场景的接入建议语义缓存解决的是“重复问题不重复烧 Token”但如果你在做的是长期运行的编码助手或 AgentToken 消耗的大头往往在长上下文和多轮工具调用上光靠缓存不够。这类场景更适合用 Coding Plan 来管理调用配额和模型路由地址在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。它和本文的语义缓存是互补关系缓存挡住重复请求Coding Plan 管住长会话的成本上限。接入层面Spring AI 的ChatClient和EmbeddingModel都指向同一个base-url切换模型只需要改application.yml里的 model 名代码不用动。如果你用的是 Claude Code 这类工具做辅助开发Anthropic 兼容端点也在同一套网关下https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite。最后留一个实操建议先把question-threshold设成 0.88、summary-threshold设成 0.92 跑一周观察命中日志里q_score和a_score的分布再根据实际数据回调阈值。阈值这东西没有万能值得用你自己的问题语料喂出来。