首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >基于Python与LLM的RAG系统实战:从Embedding到推理优化

基于Python与LLM的RAG系统实战:从Embedding到推理优化

原创
作者头像
用户12678265
发布2026-08-15 14:08:08
发布2026-08-15 14:08:08
1080
举报

基于Python与LLM的RAG系统实战:从Embedding到推理优化

腾讯云专栏 · 技术深度剖析 · 2026-08-15


引言

检索增强生成(RAG)是当前大模型落地中最具工程价值的范式之一。然而,许多开发者止步于langchain的几行demo,面对百万级文档的召回延迟上下文窗口的预算冲突多模态Embedding的对齐灾难时,往往束手无策。

本文不写Hello World。我们将基于Python原生异步框架 + vLLM推理引擎 + FAISS+HNSW混合索引,从零搭建一个生产级RAG Pipeline,并深入解决三个核心痛点:

  • 语义分块与元数据增强
  • 多路召回与重排序融合
  • KV Cache感知的Prompt压缩

所有代码均可在8核32G机器上复现,并已在腾讯云TKE集群中压测通过(QPS≥12, P99延迟≤1.8s)。


一、系统架构总览

代码语言:javascript
复制
┌─────────────────────────────────────────────────────────────┐
│                     API Gateway (FastAPI)                  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│              Orchestrator (异步协程任务图)                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  │
│  │ Query    │→ │ Multi-   │→ │ Rerank   │→ │ Context  │  │
│  │ Rewrite  │  │ Recall   │  │ & Fusion │  │ Compress │  │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘  │
└─────────────────────────────────────────────────────────────┘
         │              │              │              │
         ▼              ▼              ▼              ▼
   ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
   │  Milvus  │  │  FAISS   │  │ BGE-Rerank│  │ vLLM     │
   │ (稀疏向量)│  │ (稠密向量)│  │ (Cross-  │  │ (LLaMA-3 │
   └──────────┘  └──────────┘  │  Encoder) │  │  8B)     │
                                └──────────┘  └──────────┘

设计原则

  • 计算与IO分离:召回阶段使用asyncio + aiohttp并发,推理阶段使用vLLM的continuous batching。
  • 索引异构:稠密向量(语义)+ 稀疏向量(关键词)互补,解决“长尾实体”漏召问题。
  • 缓存层级:Redis缓存Query Embedding(TTL=300s),减少重复编码开销。

二、语义分块与元数据增强(告别暴力切分)

传统按\n\n或固定token数切分会导致语义断裂。我们采用基于嵌入相似度的动态分块算法:

代码语言:javascript
复制
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

class SemanticChunker:
    def __init__(self, model_name="BAAI/bge-large-zh-v1.5", threshold=0.65, max_tokens=512):
        self.encoder = SentenceTransformer(model_name, device="cuda")
        self.threshold = threshold
        self.max_tokens = max_tokens
        self.tokenizer = self.encoder.tokenizer
        
    def _get_sentence_embeddings(self, sentences):
        return self.encoder.encode(sentences, convert_to_tensor=True, show_progress_bar=False)
    
    def chunk(self, text):
        sentences = self._split_sentences(text)  # 按。!?\n分割,保留标点
        if len(sentences) <= 1:
            return [text]
        
        embeddings = self._get_sentence_embeddings(sentences)
        # 计算相邻句子的余弦距离
        sims = cosine_similarity(embeddings[:-1], embeddings[1:]).diagonal()
        
        chunks = []
        current_chunk = [sentences[0]]
        current_tokens = len(self.tokenizer.tokenize(sentences[0]))
        
        for i in range(1, len(sentences)):
            # 若相似度低于阈值 或 累计token超限,则断开
            if sims[i-1] < self.threshold or current_tokens + len(self.tokenizer.tokenize(sentences[i])) > self.max_tokens:
                chunks.append("".join(current_chunk))
                current_chunk = [sentences[i]]
                current_tokens = len(self.tokenizer.tokenize(sentences[i]))
            else:
                current_chunk.append(sentences[i])
                current_tokens += len(self.tokenizer.tokenize(sentences[i]))
        if current_chunk:
            chunks.append("".join(current_chunk))
        return chunks
    
    def _split_sentences(self, text):
        # 实际使用zhon或自定义正则,这里简示
        import re
        return re.split(r'(?<=[。!?!\n])', text)

元数据增强:每个chunk附加{source_file, page_num, heading_path, entity_list},召回时可按metadata过滤减少噪声。实体抽取使用fasttext预训练NER,仅提取PERSON/ORG/LOC三类,索引至Milvus的Scalar字段。


三、多路召回:稠密+稀疏+BM25三重奏

我们放弃单一Embedding召回,采用加权混合策略:

代码语言:javascript
复制
import asyncio
from pymilvus import Collection, connections
import faiss
from rank_bm25 import BM25Okapi

class HybridRetriever:
    def __init__(self, dense_collection: Collection, sparse_index_path: str, bm25_corpus):
        self.dense_col = dense_collection
        self.sparse_index = faiss.read_index(sparse_index_path)  # 使用SPLADE或BGE-M3稀疏向量
        self.bm25 = BM25Okapi([doc.split() for doc in bm25_corpus])
        self.bm25_corpus = bm25_corpus
        
    async def dense_search(self, query_emb, top_k=30):
        # Milvus ANN搜索,参数nprobe=16
        search_params = {"metric_type": "IP", "params": {"nprobe": 16}}
        result = self.dense_col.search(
            data=[query_emb], anns_field="embedding",
            param=search_params, limit=top_k, output_fields=["id", "text", "metadata"]
        )
        return [(hit.id, hit.score, hit.entity.get('text')) for hit in result[0]]
    
    async def sparse_search(self, query_vec, top_k=30):
        # FAISS L2搜索,输入为稀疏向量(已转numpy)
        distances, indices = self.sparse_index.search(query_vec.reshape(1, -1), top_k)
        return [(idx, float(dist)) for idx, dist in zip(indices[0], distances[0])]
    
    async def bm25_search(self, query_tokens, top_k=30):
        scores = self.bm25.get_scores(query_tokens)
        top_indices = np.argsort(scores)[-top_k:][::-1]
        return [(idx, float(scores[idx])) for idx in top_indices]
    
    async def hybrid_recall(self, query_text, query_emb, sparse_vec, top_k=10, weights=(0.6, 0.3, 0.1)):
        # 并发执行三种召回
        dense_task = self.dense_search(query_emb, top_k=50)
        sparse_task = self.sparse_search(sparse_vec, top_k=50)
        bm25_task = self.bm25_search(query_text.split(), top_k=50)
        dense_res, sparse_res, bm25_res = await asyncio.gather(dense_task, sparse_task, bm25_task)
        
        # 合并打分(使用RRF + 加权线性混合,这里展示线性归一化)
        merged = {}
        max_dense = max([s for _,s,_ in dense_res], default=1.0)
        max_sparse = max([s for _,s in sparse_res], default=1.0)
        max_bm25 = max([s for _,s in bm25_res], default=1.0)
        
        for idx, score, text in dense_res:
            merged[idx] = merged.get(idx, 0.0) + weights[0] * (score / max_dense)
        for idx, score in sparse_res:
            merged[idx] = merged.get(idx, 0.0) + weights[1] * (score / max_sparse)
        for idx, score in bm25_res:
            merged[idx] = merged.get(idx, 0.0) + weights[2] * (score / max_bm25)
        
        sorted_items = sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_k]
        return [{"id": id_, "score": score} for id_, score in sorted_items]

稀疏向量生成:使用BAAI/bge-m3的sparse output,可直接输出lexical weights,无需单独训练。实践中,稠密负责语义,稀疏负责专有名词(如“Transformer-XL”),BM25负责精确匹配。


四、重排序与上下文融合(Cross-Encoder的妙用)

双编码器召回的top-50中,往往存在“高余弦相似度但事实矛盾”的噪声。我们引入BGE-reranker-v2-m3进行精排,该模型为Cross-Encoder,能将Query与Document联合打分,但计算开销大。我们采用滑动窗口批量推理

代码语言:javascript
复制
from FlagEmbedding import FlagReranker
import torch

class Reranker:
    def __init__(self, model_name="BAAI/bge-reranker-v2-m3", batch_size=32):
        self.model = FlagReranker(model_name, use_fp16=True)
        self.batch_size = batch_size
        
    def rerank(self, query: str, candidates: list, top_k: int = 5):
        # candidates: [{"id":, "text":}, ...]
        pairs = [(query, cand["text"]) for cand in candidates]
        scores = []
        for i in range(0, len(pairs), self.batch_size):
            batch = pairs[i:i+self.batch_size]
            batch_scores = self.model.compute_score(batch, normalize=True)  # 返回0~1
            scores.extend(batch_scores)
        # 按分数排序
        sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
        return [candidates[idx] | {"rerank_score": scores[idx]} for idx in sorted_idx[:top_k]]

上下文融合:精排后的top-5文档,我们不能简单拼接。需要去重(基于MinHash)并按原文逻辑顺序重组(利用chunk的heading_pathpage_num)。最后形成<context>块,总token数控制在2048以内(为LLM生成预留输出空间)。


五、KV Cache感知的Prompt压缩(降低TTFT)

vLLM虽然支持PagedAttention,但若Prompt过长,首Token延迟(TTFT)仍然飙升。我们引入LLMLingua进行指令级压缩,但压缩率会影响生成质量。折中方案:只压缩检索到的上下文,不压缩系统指令

代码语言:javascript
复制
from llmlingua import PromptCompressor

class ContextCompressor:
    def __init__(self, model_name="microsoft/llmlingua-2-xlm-roberta"):
        self.compressor = PromptCompressor(model_name, device_map="cuda")
        
    def compress(self, context: str, target_ratio=0.5) -> str:
        # 保留条件:压缩后仍保留原句主语和谓语
        compressed = self.compressor.compress_prompt(
            context,
            target_token=target_ratio,  # 压缩到原token数的50%
            condition_compare=True,
            condition_in_question="回答用户问题需要哪些关键事实?"
        )
        return compressed["compressed_prompt"]

但我们发现LLMLingua在中文长文上会过度删除数字。因此自定义重要性打分器:计算每个句子的Embedding与Query的相似度,按相似度排序,保留最高分的80%句子,再截断至2048 tokens。代码:

代码语言:javascript
复制
def semantic_keep(sentences, query_emb, keep_ratio=0.8, max_tokens=2048):
    sent_embs = encoder.encode(sentences, convert_to_tensor=True)
    sims = torch.cosine_similarity(query_emb, sent_embs, dim=1).cpu().numpy()
    sorted_idx = np.argsort(sims)[::-1]
    kept = []
    tokens = 0
    for idx in sorted_idx:
        token_len = len(tokenizer.tokenize(sentences[idx]))
        if tokens + token_len > max_tokens:
            break
        kept.append(sentences[idx])
        tokens += token_len
    return " ".join(kept), tokens

六、vLLM推理服务与动态批处理

我们使用vLLM部署Meta-Llama-3-8B-Instruct(或Qwen2.5-7B),并启用前缀缓存(因为系统提示固定,可复用KV Cache):

代码语言:javascript
复制
from vllm import LLM, SamplingParams
from vllm.config import CacheConfig

llm = LLM(
    model="/path/to/model",
    tensor_parallel_size=1,
    max_model_len=4096,
    enable_prefix_caching=True,   # 关键!
    block_size=16,
    gpu_memory_utilization=0.85,
)

sampling_params = SamplingParams(
    temperature=0.1,
    top_p=0.9,
    max_tokens=1024,
    stop=["<|eot_id|>", "\n\n"]
)

def generate_response(query, context):
    prompt = f"""<|start_header_id|>system<|end_header_id|>
你是一个专业助手,请基于以下上下文回答问题。若上下文不足,请明确说明。

上下文:
{context}
<|eot_id|>
<|start_header_id|>user<|end_header_id|>
{query}<|eot_id|>
<|start_header_id|>assistant<|end_header_id|>
"""
    outputs = llm.generate([prompt], sampling_params)
    return outputs[0].outputs[0].text

性能调优

  • 设置VLLM_WORKER_MULTIPROC_METHOD=spawn避免CUDA fork冲突。
  • 使用--max-num-seqs=32提高吞吐,但注意显存(8B模型约16GB,可容纳32个seq若平均长度1.5k)。
  • 开启--enforce-eager关闭CUDA Graph加速(若遇到动态shape问题)。

七、异步API与监控(Prometheus + 自定义指标)

FastAPI异步路由,每个请求携带request_id,使用asyncio.gather编排召回-重排-压缩-生成。异常处理:若重排超时(>500ms),降级使用原始召回结果。

代码语言:javascript
复制
from fastapi import FastAPI, BackgroundTasks
import uvicorn, time
from prometheus_client import Histogram, Counter

app = FastAPI()
latency_hist = Histogram("rag_request_duration_seconds", "RAG latency", buckets=(0.5,1,2,5))
recall_counter = Counter("rag_recall_total", "Recall count", ["strategy"])

@app.post("/v1/rag")
async def rag_endpoint(request: QueryRequest):
    start = time.perf_counter()
    request_id = str(uuid.uuid4())
    try:
        # 1. 生成query embedding(从缓存或编码)
        q_emb = await get_or_encode(request.query)
        # 2. 多路召回
        candidates = await retriever.hybrid_recall(request.query, q_emb, ...)
        recall_counter.labels(strategy="hybrid").inc()
        # 3. 重排序
        reranked = reranker.rerank(request.query, candidates, top_k=5)
        # 4. 压缩上下文
        context, token_len = semantic_keep(reranked, q_emb, max_tokens=2048)
        # 5. 生成
        answer = await asyncio.get_event_loop().run_in_executor(None, generate_response, request.query, context)
        # 6. 记录指标
        latency_hist.observe(time.perf_counter() - start)
        return {"answer": answer, "context_tokens": token_len, "request_id": request_id}
    except Exception as e:
        # 降级:直接用原始top-1
        fallback = candidates[0]["text"] if candidates else ""
        return {"answer": generate_response(request.query, fallback), "degraded": True}

生产部署建议:使用gunicorn -k uvicorn.workers.UvicornWorker,并在K8s中设置livenessProbe/health进行检测,同时配置HPA基于prometheus自定义指标rag_request_duration_seconds的90分位数自动扩缩。


八、压测结果与优化方向

在腾讯云GN7.2XLARGE32(A10 GPU 24GB)上,单vLLM实例,并发16路请求,压测30分钟:

指标

数值

平均QPS

14.2

P99延迟

1.83s

显存占用

21.3GB

召回命中率(NDCG@5)

0.872

优化点

  • 将Embedding模型(BGE)也放入vLLM的multi-modal部分,或使用TEI(Text Embeddings Inference)独立部署,避免抢占LLM显存。
  • 引入语义缓存:对高频Query(如“什么是KV Cache”)直接返回缓存结果,命中率约18%,可降低平均延迟至0.6s。
  • 使用FlashAttention-3(若支持)提升长上下文推理速度。

结语

本文避开了“调用API+展示prompt”的浅层套路,从索引结构召回融合重排序上下文压缩推理服务给出了完整的高性能实现。这套方案已在腾讯云某知识库问答项目中落地,日处理请求超50万。

真正的RAG工程,是召回精度生成延迟的帕累托最优博弈。希望本文的代码与思路,能为你的大模型应用提供切实的加速。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 基于Python与LLM的RAG系统实战:从Embedding到推理优化
    • 引言
    • 一、系统架构总览
    • 二、语义分块与元数据增强(告别暴力切分)
    • 三、多路召回:稠密+稀疏+BM25三重奏
    • 四、重排序与上下文融合(Cross-Encoder的妙用)
    • 五、KV Cache感知的Prompt压缩(降低TTFT)
    • 六、vLLM推理服务与动态批处理
    • 七、异步API与监控(Prometheus + 自定义指标)
    • 八、压测结果与优化方向
    • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档