
检索增强生成(Retrieval-Augmented Generation,RAG)已成为大语言模型落地企业应用的事实标准范式。然而,从 Demo 到生产环境,RAG 系统面临召回精度、延迟、上下文窗口利用、多源异构数据融合等多重挑战。本文不介绍基础概念,而是从系统架构出发,深入拆解各模块的设计权衡与优化策略,并附可运行的代码片段,帮助你在实际项目中构建稳定、高效的 RAG 流水线。
一个生产级 RAG 系统通常包含 离线索引管道 与 在线查询管道 两大流程:
┌─────────────────────────────────────────────────────────────┐
│ 离线索引管道 │
│ 原始文档 → 文档解析/清洗 → 分块(Chunking) → 向量化 → 索引写入 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 在线查询管道 │
│ 用户Query → 查询改写/扩展 → 向量检索+关键词检索 → 重排序 │
│ → 上下文压缩/精选 → 构造Prompt → LLM生成 → 输出 │
└─────────────────────────────────────────────────────────────┘关键设计决策点:
分块粒度直接影响召回率和生成质量。过小导致上下文断裂,过大则引入噪声且超出 embedding 模型最大长度(通常 512 或 768)。
LangChain 的实现通过按层级分隔符递归切割,优先保持段落/句子完整性:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64, # 重叠避免边界信息丢失
separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
length_function=len,
)
chunks = splitter.split_text(long_document)基于句子嵌入的相似度变化来动态切分,更适合主题边界明显的文档:
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
def semantic_chunk(sentences, emb_model, threshold=0.3):
embs = emb_model.encode(sentences)
diffs = [cosine_similarity([embs[i]], [embs[i+1]])[0][0]
for i in range(len(embs)-1)]
breakpoints = [i+1 for i, d in enumerate(diffs) if d < threshold]
# 按断点合并句子
chunks = []
start = 0
for bp in breakpoints:
chunks.append("".join(sentences[start:bp]))
start = bp
chunks.append("".join(sentences[start:]))
return chunks生产建议:针对技术文档(含代码块),使用 MarkdownHeaderTextSplitter 保留标题层级元数据,便于后续按章节过滤。
数据库 | 索引类型 | 过滤能力 | 分布式 | 适用场景 |
|---|---|---|---|---|
Qdrant | HNSW + IVF | 强(Payload索引) | 原生 | 高QPS、复杂过滤 |
Milvus | IVF-PQ/HNSW | 标量字段索引 | 成熟 | 亿级向量 |
Weaviate | HNSW | 支持 | 支持 | 多模态检索 |
PGVector | IVFFlat/HNSW | 弱 | 需扩展 | 小规模、PostgreSQL生态 |
{
"indexes": [{
"type": "hnsw",
"options": {
"m": 32, // 每层最大连接数,越大召回率↑但内存↑
"ef_construct": 200, // 构建时动态候选列表大小
"ef_search": 150, // 查询时搜索广度,可运行时调整
"full_scan_threshold": 10000 // 低于该值直接暴力搜索
}
}]
}关键权衡:
m 从 16 提升到 32 召回率提升约 2~3%,内存增加 50%ef_search 每增加 50,延迟增加约 10ms,召回率提升约 1%quantization(标量量化)可减少 60% 内存,但对 recall 损失约 1%纯向量检索对专有名词、缩写、精确 ID 召回很差。混合检索(Hybrid Search)融合 BM25 和 Dense 向量,是工业界标配。
def reciprocal_rank_fusion(results_list, k=60):
"""results_list: list of list of (doc_id, score) 按相关性降序"""
scores = {}
for rank_list in results_list:
for rank, (doc_id, _) in enumerate(rank_list, 1):
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (rank + k)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)向量检索阶段取 Top-100,再用 Cross-Encoder 精排 Top-10,显著提升首条准确率。
使用 sentence-transformers 的 Cross-Encoder 或专用模型如 BAAI/bge-reranker-v2-m3:
from sentence_transformers import CrossEncoder
model = CrossEncoder('BAAI/bge-reranker-v2-m3', max_length=512)
pairs = [[query, doc_text] for doc_text in top100_docs]
scores = model.predict(pairs) # 返回相关性分数 (0~1)
# 按分数重排
reranked = sorted(zip(top100_docs, scores), key=lambda x: x[1], reverse=True)[:10]生产优化:将 Cross-Encoder 部署为 Triton Inference Server 或 vLLM,支持 batch 推理,降低延迟。
即使检索到相关文档,直接拼接所有片段到 Prompt 会造成:
微软开源的 LLMLingua 通过小模型(如 Phi-2)评估 token 重要性,压缩率可达 5x 且保持大部分信息:
from llmlingua import PromptCompressor
compressor = PromptCompressor(model_name="microsoft/llmlingua-2-bert-base-multilingual-cased")
compressed_prompt = compressor.compress_prompt(
messages=[{"role": "user", "content": user_query}],
context=retrieved_docs,
rate=0.5, # 压缩至50%
use_sentence_level=True,
)优先保留与 Query 相似度最高的段落,并确保总 token 数不超过模型 max_tokens 的 70%(留空间给生成):
def truncate_context(docs, max_tokens=3000, tokenizer=None):
sorted_docs = sorted(docs, key=lambda x: x['score'], reverse=True)
accumulated = []
total_tokens = 0
for doc in sorted_docs:
tokens = tokenizer.encode(doc['text'])
if total_tokens + len(tokens) > max_tokens:
# 尝试截断单个文档
remain = max_tokens - total_tokens
if remain > 50:
accumulated.append(tokenizer.decode(tokens[:remain]))
break
accumulated.append(doc['text'])
total_tokens += len(tokens)
return "\n\n".join(accumulated)使用明确的 XML/标记分隔不同文档,并强制模型引用来源:text
<context>
<doc id="1">...</doc>
<doc id="2">...</doc>
</context>
<instruction>基于以上文档回答用户问题。如果文档中没有相关信息,请明确告知。回答中请引用文档编号。</instruction>
<question>{user_query}</question>
<answer>用户原始 Query 可能模糊或多义,需进行预处理:
让 LLM 生成一个假设性答案,再将假设答案作为检索 Query,提高召回
def hyde_rewrite(query, llm):
prompt = f"请根据以下问题,生成一段可能包含答案的虚构文档片段(仅内容,不要回答):{query}"
hypo_doc = llm.generate(prompt, max_tokens=200)
return hypo_doc # 作为检索向量使用 LLM 生成多个同义问题,分别检索后合并结果:
def expand_queries(query, llm):
prompt = f"生成与'{query}'语义相似但表述不同的3个检索查询,每行一个。"
resp = llm.generate(prompt)
return resp.strip().split('\n')RAGAS(Faithfulness, Answer Relevance, Context Relevance)使用 RAGAS 快速评估:
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_relevancy
result = evaluate(
dataset=test_dataset, # 包含 question, answer, contexts, ground_truth
metrics=[faithfulness, answer_relevancy, context_relevancy]
)
print(result)tenant_id 过滤,并建立联合索引(tenant_id + vector)。以下是一个整合了上述组件的 FastAPI 服务核心代码:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import qdrant_client
from sentence_transformers import SentenceTransformer, CrossEncoder
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
app = FastAPI()
# 初始化组件
embed_model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
reranker = CrossEncoder('BAAI/bge-reranker-v2-m3')
qdrant = qdrant_client.QdrantClient(host='localhost', port=6333)
tokenizer = AutoTokenizer.from_pretrained('Qwen/Qwen2-7B-Instruct')
llm = AutoModelForCausalLM.from_pretrained('Qwen/Qwen2-7B-Instruct', device_map='auto')
class QueryRequest(BaseModel):
query: str
top_k: int = 5
use_rerank: bool = True
@app.post("/rag/query")
def rag_query(req: QueryRequest):
# 1. 向量检索
q_vec = embed_model.encode(req.query).tolist()
search_result = qdrant.search(
collection_name="docs",
query_vector=q_vec,
limit=20, # 粗排取20
)
doc_ids = [hit.id for hit in search_result]
doc_texts = [hit.payload['text'] for hit in search_result]
scores = [hit.score for hit in search_result]
# 2. 重排序(可选)
if req.use_rerank and len(doc_texts) > 1:
pairs = [[req.query, t] for t in doc_texts]
rerank_scores = reranker.predict(pairs)
combined = sorted(zip(doc_texts, rerank_scores), key=lambda x: x[1], reverse=True)
final_docs = [text for text, _ in combined[:req.top_k]]
else:
final_docs = doc_texts[:req.top_k]
# 3. 上下文压缩(截断到2000 token)
context = "\n\n".join(final_docs)
tokens = tokenizer.encode(context)
if len(tokens) > 2000:
context = tokenizer.decode(tokens[:2000])
# 4. 生成
prompt = f"<context>{context}</context>\n<question>{req.query}</question>\n<answer>"
inputs = tokenizer(prompt, return_tensors="pt").to(llm.device)
outputs = llm.generate(**inputs, max_new_tokens=512, temperature=0.1)
answer = tokenizer.decode(outputs[0], skip_special_tokens=True)
return {"answer": answer, "sources": final_docs}构建生产级 RAG 系统绝不是“向量数据库 + LLM”的简单拼装,而是需要在检索精度、延迟、上下文利用、鲁棒性之间做精细权衡。本文梳理了从分块到部署的全链路工程实践,希望能为你的项目提供可落地的参考。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。