背景痛点:实时性、多轮对话与长上下文的三重夹击
去年双十一,我们给电商客户做的 RAG 智能客服第一次面对 10k 并发,结果 99 分位响应飙到 4.3 s,GPU 显存直接打满,OOM 把 Pod 一波带走。复盘下来,瓶颈集中在三点:
- 向量检索慢:Milvus 默认 IVF-Flat,nprobe=64 时 QPS 只有 180,长尾查询把 P99 拉高。
- 长上下文:多轮对话把历史记录全塞进 prompt,平均 3.2 k token,LLM 推理时间线性增长。
- 同步阻塞:Django 视图函数里“检索+生成”串行,I/O 空等导致 CPU 利用率 <30 %。
一句话:传统“先搜后写”的 RAG 链路在实时场景下,各环节串行叠加,长尾被放大,体验直接翻车。
技术对比:检索式、生成式与 RAG 的硬指标
我们用同一批 10 万 FAQ、A100-40G×4 的环境压测 30 min,结果如下:
| 方案 | 平均 QPS | 99分位延迟 | GPU 显存峰值 | 答案准确率 |
|---|---|---|---|---|
| 传统检索式(ES) | 2 800 | 120 ms | 0 G | 68 % |
| 纯生成式(GPT-3.5) | 120 | 2 100 ms | 38 G | 85 % |
| 基础 RAG(IVF-Flat) | 180 | 1 800 ms | 30 G | 82 % |
| 优化 RAG(本文) | 650 | 380 ms | 22 G | 84 % |
可以看到,优化后的 RAG 把 QPS 提升 3.6 倍,延迟降到纯生成式的 1/5,同时保持 84 % 准确率,基本兼顾了“快”与“准”。
核心优化 1:分层向量索引设计(IVF+PQ)
思路:用 IVF 减少候选集,再用 PQ 压缩向量,降低内存与计算量。
- 训练阶段:对 2000 万条 FAQ 向量做 K-means,簇数 4096,保证每簇 <5 k 条。
- 量化阶段:PQ-64,把 768 维 float32 拆成 64 个 8 位子码本,单条向量从 3 kB→64 B,内存节省 46 倍。
- 查询阶段:nprobe=32,在 A100 上单卡 QPS 从 180→650,GPU 内存峰值 30 G→22 G。
FAISS 关键配置如下:
def build_index(vectors: np.ndarray) -> faiss.Index: """ Build IVF-PQ index with 4096 clusters and 64-byte PQ. Args: vectors: float32 array of shape [N, 768] Returns: FAISS GPU index """ d = vectors.shape[1] quantizer = faiss.IndexFlatIP(d) # inner-product, cosine after norm index = faiss.IndexIVFPQ(quantizer, d, 4096, 64, 8) index.train(vectors) index.add(vectors) index.nprobe = 32 # tuned by benchmark return faiss.index_cpu_to_gpu(faiss.StandardGpuResources(), 0, index)核心优化 2:动态上下文窗口压缩
多轮对话历史直接塞 prompt 会爆炸,我们设计了一个“滑动压缩”算法:对历史轮次做语义相似度打分,保留与当前问题最相关的 K 条,其余用摘要替代。
公式:
相关性得分 S_i = α·cos(q, h_i) + β·(1 − t/T),
其中 q 为当前问题向量,h_i 为第 i 轮历史向量,t 为时间衰减,T 为会话最大长度。α=0.7,β=0.3 时线下 F1 最高。
时间复杂度:O(M·N),M 为历史条数,N 为向量维度;M≤20 时单次 <5 ms,可忽略。
代码示例(带 token 裁剪):
def compress_history( query_vec: np.ndarray, history: List[Dict[str, Any]], max_tokens: int = 800 ) -> str: """ Return compressed context under max_tokens. Complexity: O(M*N) where M<=20, N=768 """ scores = [] for idx, item in enumerate(history): sim = np.dot(query_vec, item["vec"]) time_decay = 1 - idx / len(history) scores.append((idx, 0.7 * sim + 0.3 * time_decay)) scores.sort(key=lambda x: x[1], reverse=True) kept = [history[i] for i, _ in scores[:8]] # top-8 text = "\n".join(k["text"] for k in kept) return truncate_by_tokens(text, max_tokens)truncate_by_tokens 用 tiktoken 库,按 token 级裁剪,保证不截断 UTF-8。
核心优化 3:基于 Celery 的异步处理流水线
把“检索+生成”拆成三步:①接收→②检索→③生成,全部丢进 Celery,Django 视图只负责回一个 task_id,前端轮询。
- 任务拆分:检索任务 GPU-free,跑在 CPU 节点;生成任务调度到 Triton+TensorRT,显存隔离。
- 队列隔离:检索用 queue=cpu,生成用 queue=gpu,避免互相阻塞。
- 结果缓存:Redis 缓存 60 s,同一问题命中后直接返回,QPS 再翻 1.8 倍。
Django 集成要点:
# views.py def ask(request): question = request.POST["q"] task = retrieve_then_generate.delay(question, request.session["sid"]) return JsonResponse({"task_id": task.id}) # tasks.py @shared_task(bind=True, queue="cpu") def retrieve_then_generate(self, question: str, session_id: str): vec = encoder.encode(question) topk = faiss_index.search(vec, 10) context = compress_history(vec, get_history(session_id)) generate_task.delay(question, context, session_id) @shared_task(queue="gpu") def generate_task(question, context, session_id): prompt = build_prompt(question, context) answer = triton_client.generate(prompt) save_answer(session_id, answer)性能测试:10k 并发压测结果
使用 locust 模拟 10k 并发,持续 10 min,数据如下:
- 99分位响应:优化前 4.3 s → 优化后 380 ms
- GPU 显存峰值:30 G → 22 G
- 错误率(超时+OOM):5.2 % → 0.3 %
避坑指南:生产踩过的三个大坑
向量维度对齐错误
训练时 encoder 输出 768,线上新模型 1024,直接 add 进索引导致段错误。解决:启动时做维度校验,不一致强制重建索引。对话状态 race condition
两个请求同时更新 Redis 里的 history,出现乱序。解决:用 Redis Lua 脚本保证“读-改-写”原子,或用分布式锁(Redlock)。冷启动抖动
新 Pod 启动时 FAISS 索引从对象存储拉取,3 G 文件耗时 30 s,首请求超时。解决:Sidecar 容器预拉取+内存映射,就绪探针延迟 60 s,同时保证滚动发布不中断。
开放问题:如何平衡检索精度与响应速度?
优化到最后,我们发现把 nprobe 调到 64 可以再涨 2 % 准确率,但 P99 延迟会多 60 ms。业务方问:“能不能既要 90 % 准确率,又要 <300 ms?”
目前做法是动态 nprobe:根据实时负载,用 PID 控制器每 10 s 调一次 nprobe,让延迟和准确率都在 SLA 线上。
你觉得这种“精度-速度”的 trade-off 还有更优雅的解法吗?欢迎一起交流。