核心洞察:一个 chunk 被检索到后,LLM 看到的是孤立的片段,缺乏「这段在讲什么主题」的背景。Contextual Chunking 在每个 chunk 前面拼接其所属章节的摘要或标题链,让模型看到 chunk 时能理解它的上下文位置。
def contextual_chunking(chunks: list[dict]) -> list[dict]:
"""为每个 chunk 拼接标题前缀,形成上下文增强版本。
输入:markdown_structured_split 的输出
输出:每个 chunk 的 content 前面加上标题链
"""
result = []
for chunk in chunks:
headers = chunk.get("headers", {})
header_chain = " > ".join(
headers.get(f"Header {i}", "")
for i in range(1, 4)
if headers.get(f"Header {i}")
)
if header_chain:
enhanced_content = f"[章节: {header_chain}]\n{chunk['content']}"
else:
enhanced_content = chunk["content"]
result.append({
"content": enhanced_content,
"raw_content": chunk["content"],
"headers": headers,
})
return result
2.3 完整的文档处理流水线
将上述切分策略封装成一个统一的文档处理器。现行默认参数:chunk_size=500、overlap=0(与 [config.py](../ragbackend/config.py) / [splitter.py](../ragbackend/splitter.py) 一致)。CLI 入库入口:python -m ragbackend.main ingest <文件>,内部调用 DocumentProcessor.process_file()。
import os
import hashlib
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class DocumentChunk:
"""一个文本块的完整数据结构。"""
chunk_id: str
file_id: str
source_name: str
content: str
raw_content: str
chunk_index: int
headers: dict = field(default_factory=dict)
metadata: dict = field(default_factory=dict)
class DocumentProcessor:
"""统一的文档处理器,支持多种输入格式和切分策略。
Usage:
processor = DocumentProcessor(chunk_size=500, strategy="markdown")
chunks = processor.process_file("docs/policy.md")
"""
def __init__(
self,
chunk_size: int = 500,
overlap: int = 0,
strategy: Literal["recursive", "semantic", "markdown"] = "markdown",
embed_fn=None,
):
self.chunk_size = chunk_size
self.overlap = overlap
self.strategy = strategy
self.embed_fn = embed_fn
def process_file(self, file_path: str) -> list[DocumentChunk]:
"""处理单个文件,返回 DocumentChunk 列表。"""
text = self._read_file(file_path)
file_id = self._generate_file_id(file_path)
source_name = os.path.basename(file_path)
if self.strategy == "markdown" and file_path.endswith(".md"):
raw_chunks = markdown_structured_split(text, self.chunk_size)
enhanced_chunks = contextual_chunking(raw_chunks)
elif self.strategy == "semantic" and self.embed_fn:
texts = semantic_split(text, self.embed_fn, max_chunk_size=self.chunk_size)
enhanced_chunks = [{"content": t, "raw_content": t, "headers": {}} for t in texts]
else:
texts = recursive_split(text, self.chunk_size, self.overlap)
enhanced_chunks = [{"content": t, "raw_content": t, "headers": {}} for t in texts]
result = []
for i, chunk in enumerate(enhanced_chunks):
doc_chunk = DocumentChunk(
chunk_id=f"{file_id}_chunk_{i}",
file_id=file_id,
source_name=source_name,
content=chunk["content"],
raw_content=chunk["raw_content"],
chunk_index=i,
headers=chunk.get("headers", {}),
metadata={"chunk_index": i, "source_name": source_name},
)
result.append(doc_chunk)
return result
def _read_file(self, file_path: str) -> str:
"""读取文件内容,根据扩展名选择解析方式。"""
ext = os.path.splitext(file_path)[1].lower()
if ext == ".md":
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
elif ext == ".txt":
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
elif ext == ".html":
return self._parse_html(file_path)
elif ext == ".pdf":
return self._parse_pdf(file_path)
else:
raise ValueError(f"不支持的文件格式: {ext}")
def _parse_html(self, file_path: str) -> str:
"""简单的 HTML 解析,去除标签保留文本。"""
from html.parser import HTMLParser
class TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.text_parts = []
self.skip = False
def handle_starttag(self, tag, attrs):
if tag in ("script", "style"):
self.skip = True
def handle_endtag(self, tag):
if tag in ("script", "style"):
self.skip = False
def handle_data(self, data):
if not self.skip:
self.text_parts.append(data)
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()
parser = TextExtractor()
parser.feed(html)
return "\n".join(parser.text_parts)
def _parse_pdf(self, file_path: str) -> str:
"""PDF 解析:优先使用 pypdf 提取文本。"""
try:
from pypdf import PdfReader
reader = PdfReader(file_path)
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n".join(pages)
except ImportError:
raise ImportError("请安装 pypdf: pip install pypdf")
def _generate_file_id(self, file_path: str) -> str:
"""基于文件路径和内容生成唯一 ID。"""
with open(file_path, "rb") as f:
content_hash = hashlib.md5(f.read()).hexdigest()[:12]
return f"{os.path.basename(file_path)}_{content_hash}"
2.4 chunk_size 调参经验
| 文档类型 |
推荐 chunk_size |
理由 |
| FAQ / 短问答 |
200-300 |
单个问答对较短,精确命中更重要 |
| 制度条款 / 规章制度 |
300-500 |
条款通常段落清晰,标题密集 |
| 技术文档 / 教程 |
500-800 |
代码示例和解释需要完整上下文 |
| 产品白皮书 |
800-1200 |
论证逻辑较长,需要保留完整论点 |
| 法律合同 |
500-1000 |
条款结构清晰但单条款可能很长 |
overlap 的经验法则:
-
overlap=0 适合结构化切分(标题边界已经够清晰)
-
overlap=50-100 适合递归字符切分(减少边界处信息丢失)
-
overlap > 100 通常不推荐(增加索引体积,收益递减)
第三章 向量化与索引构建
本章目标:理解 Embedding 原理,构建稠密(向量)和稀疏(BM25)双索引,为混合检索奠定基础。
3.1 Embedding 原理:从词袋到语义空间
词袋模型的局限
传统的文本表示方法(如 TF-IDF、BM25)将文本表示为词频向量,维度等于词汇表大小(通常 10K-100K 维),向量极度稀疏,且无法捕捉语义关系——「电脑」和「计算机」在词袋模型中是完全不同的维度。
Transformer Embedding 的突破
现代 Embedding 模型(如 BERT、GTE、BGE)通过 Transformer 编码器将整个文本映射到一个低维稠密向量空间(通常 768-3072 维)。在这个空间中,语义相似的文本距离更近。
"公司差旅报销标准" → [0.12, -0.34, 0.56, ...] (1024维)
"出差费用报销规定" → [0.11, -0.31, 0.54, ...] (语义相近,向量也相近)
"今天天气真好" → [-0.89, 0.42, -0.11, ...] (语义不同,向量距离远)
3.2 Bi-Encoder vs Cross-Encoder
这是理解 RAG 检索架构的关键区分:
Bi-Encoder(双塔模型):
Query → [Encoder] → q_vec ─┐
├─ cosine_similarity(q_vec, d_vec) → score
Doc → [Encoder] → d_vec ─┘
Cross-Encoder(交叉编码器):
[Query, Doc] → [Encoder] → score(直接输出相关性分数)
| 特性 |
Bi-Encoder |
Cross-Encoder |
| 计算方式 |
分别编码,后计算相似度 |
联合编码,直接输出分数 |
| 能否预计算文档向量 |
✅ 可以 |
❌ 不可以 |
| 检索速度 |
毫秒级(ANN 搜索) |
百毫秒级(需逐对计算) |
| 精度 |
良好 |
更好(能捕捉 query-doc 交互) |
| 适用阶段 |
召回(Recall) |
精排(Reranking) |
RAG 的黄金组合:Bi-Encoder 大规模召回 + Cross-Encoder 少量候选精排。
3.3 DashScope Embedding API 实战
DashScope 提供 text-embedding-v3 模型,支持 512/1024/2048 三种维度,最大输入 8192 tokens。
源码:[ragbackend/embedding.py](../ragbackend/embedding.py)。API Key 由 [config.py](../ragbackend/config.py) 的 _load_env() 自动加载,无需手写 dashscope.api_key = ...。
重要默认参数([config.py](../ragbackend/config.py)):
| 参数 |
默认值 |
说明 |
embedding_model |
text-embedding-v3 |
— |
embedding_dimension |
1024 |
可选 512 / 1024 / 2048 |
embedding_batch_size |
10 |
DashScope 当前接口单次 batch 上限为 10(非 25) |
from ragbackend.embedding import get_embeddings, get_single_embedding
from ragbackend.config import settings
embeddings = get_embeddings(["问题 A", "问题 B"], dimension=settings.embedding_dimension)
vec = get_single_embedding("单条文本")
入库与问答时通过 get_embeddings(texts) 分批调用;失败时抛出 RuntimeError 并附带 DashScope 错误码。
3.4 向量数据库选型
| 数据库 |
特点 |
适用规模 |
部署方式 |
Python 客户端 |
| Chroma |
轻量、嵌入式、零配置 |
< 100 万条 |
本地/单机 |
chromadb |
| FAISS |
Meta 出品、极快 ANN 搜索 |
< 1000 万条 |
内存库 |
faiss-cpu |
| Milvus |
分布式、生产级 |
亿级 |
K8s 集群 |
pymilvus |
| Qdrant |
Rust 编写、高性能 |
千万级 |
单机/集群 |
qdrant-client |
| Elasticsearch |
全文检索+向量 |
千万级 |
集群 |
elasticsearch |
Demo 和小团队用 Chroma,生产环境根据规模选 Milvus/Qdrant。
3.5 Chroma 向量存储实现
import chromadb
from chromadb.config import Settings
class VectorStore:
"""基于 Chroma 的向量存储,支持批量写入和元数据过滤检索。
Usage:
store = VectorStore(collection_name="my_knowledge_base")
store.add_chunks(chunks, embeddings)
results = store.search(query_embedding, top_k=10)
"""
def __init__(self, collection_name: str = "knowledge_base", persist_dir: str = "./chroma_db"):
self.client = chromadb.PersistentClient(path=persist_dir)
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"},
)
def add_chunks(self, chunks: list[dict], embeddings: list[list[float]]) -> None:
"""批量写入文本块和对应的向量。
Args:
chunks: [{"chunk_id": str, "content": str, "metadata": dict}, ...]
embeddings: 对应的向量列表
"""
ids = [c["chunk_id"] for c in chunks]
documents = [c["content"] for c in chunks]
metadatas = []
for c in chunks:
meta = c.get("metadata", {})
clean_meta = {}
for k, v in meta.items():
if isinstance(v, (str, int, float, bool)):
clean_meta[k] = v
else:
import json
clean_meta[k] = json.dumps(v, ensure_ascii=False)
metadatas.append(clean_meta)
batch_size = 500
for i in range(0, len(ids), batch_size):
self.collection.upsert(
ids=ids[i:i + batch_size],
documents=documents[i:i + batch_size],
embeddings=embeddings[i:i + batch_size],
metadatas=metadatas[i:i + batch_size],
)
print(f"已写入 {len(ids)} 个文本块到集合 '{self.collection.name}'")
def search(
self,
query_embedding: list[float],
top_k: int = 10,
where: dict | None = None,
) -> list[dict]:
"""向量相似度搜索。
Args:
query_embedding: 查询向量
top_k: 返回数量
where: 元数据过滤条件,如 {"source_name": "policy.md"}
Returns:
[{"chunk_id": str, "content": str, "score": float, "metadata": dict}, ...]
"""
kwargs = {
"query_embeddings": [query_embedding],
"n_results": top_k,
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
results = self.collection.query(**kwargs)
hits = []
for i in range(len(results["ids"][0])):
distance = results["distances"][0][i]
similarity = 1.0 - distance
hits.append({
"chunk_id": results["ids"][0][i],
"content": results["documents"][0][i],
"score": similarity,
"metadata": results["metadatas"][0][i] if results["metadatas"] else {},
})
return hits
def delete_by_file(self, file_id: str) -> None:
"""删除指定文件的所有文本块。"""
self.collection.delete(where={"file_id": file_id})
def count(self) -> int:
"""返回集合中的文本块总数。"""
return self.collection.count()
def get_all_documents(self) -> list[dict]:
"""导出所有文档(用于重建 BM25 索引)。"""
results = self.collection.get(include=["documents", "metadatas"])
docs = []
for i in range(len(results["ids"])):
docs.append({
"chunk_id": results["ids"][i],
"content": results["documents"][i],
"metadata": results["metadatas"][i] if results["metadatas"] else {},
})
return docs
3.6 BM25 稀疏索引:关键词精确匹配
BM25(Best Matching 25)是经典的文本相关性打分算法,是 TF-IDF 的改进版本。其核心公式为:
BM25(q, d) = Σ IDF(qi) × [ f(qi, d) × (k1 + 1) ] / [ f(qi, d) + k1 × (1 - b + b × |d|/avgdl) ]
其中:
-
f(qi, d) 是词 qi 在文档 d 中的词频
-
-
-
BM25 的优势在于精确匹配:当用户搜索特定编号(如「AI-2024-03号文」)、专有名词、产品型号时,BM25 比语义搜索更准确。
import jieba
import bm25s
class BM25Index:
"""基于 bm25s 的稀疏检索索引,使用 jieba 中文分词。
Usage:
index = BM25Index()
index.build(documents)
results = index.search("差旅报销标准", top_k=10)
"""
def __init__(self, k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.retriever = None
self.documents = []
self.doc_ids = []
def tokenize(self, text: str) -> list[str]:
"""使用 jieba 分词。"""
return list(jieba.cut(text))
def build(self, documents: list[dict]) -> None:
"""从文档列表构建 BM25 索引。
Args:
documents: [{"chunk_id": str, "content": str, "metadata": dict}, ...]
"""
self.documents = documents
self.doc_ids = [d["chunk_id"] for d in documents]
corpus_tokens = [self.tokenize(d["content"]) for d in documents]
self.retriever = bm25s.BM25(k1=self.k1, b=self.b)
self.retriever.index(corpus_tokens)
print(f"BM25 索引构建完成:{len(documents)} 个文档")
def search(self, query: str, top_k: int = 10) -> list[dict]:
"""BM25 关键词搜索。
Args:
query: 查询文本
top_k: 返回数量
Returns:
[{"chunk_id": str, "content": str, "score": float, "metadata": dict}, ...]
"""
if self.retriever is None or not self.documents:
return []
query_tokens = self.tokenize(query)
results, scores = self.retriever.retrieve(
query_tokens, k=min(top_k, len(self.documents))
)
hits = []
for i in range(len(results[0])):
doc_idx = results[0][i]
score = float(scores[0][i])
doc = self.documents[doc_idx]
hits.append({
"chunk_id": doc["chunk_id"],
"content": doc["content"],
"score": score,
"metadata": doc.get("metadata", {}),
})
return hits
def rebuild_from_vector_store(self, vector_store) -> None:
"""从向量数据库导出文档重建 BM25 索引(保证双索引一致性)。"""
docs = vector_store.get_all_documents()
self.build(docs)
3.7 双索引一致性保障
混合检索的质量前提是稠密索引和稀疏索引看到同一批文档。工程上最容易踩的坑是:更新了向量库但忘了重建 BM25,导致两路检索结果不一致,融合后质量静默下降。
class DualIndexManager:
"""双索引管理器:确保 Chroma 和 BM25 始终同步。
所有写入/删除操作都通过这个管理器,保证一致性。
"""
def __init__(self, vector_store: VectorStore, bm25_index: BM25Index):
self.vector_store = vector_store
self.bm25_index = bm25_index
def ingest(self, chunks: list[dict], embed_fn) -> None:
"""完整的入库流程:向量化 → 写入 Chroma → 重建 BM25。"""
texts = [c["content"] for c in chunks]
embeddings = embed_fn(texts)
self.vector_store.add_chunks(chunks, embeddings)
self.bm25_index.rebuild_from_vector_store(self.vector_store)
print(f"双索引更新完成:Chroma {self.vector_store.count()} 条,"
f"BM25 {len(self.bm25_index.documents)} 条")
def delete_file(self, file_id: str) -> None:
"""删除文件后同步重建 BM25。"""
self.vector_store.delete_by_file(file_id)
self.bm25_index.rebuild_from_vector_store(self.vector_store)
def initialize_on_startup(self) -> None:
"""启动时:如果 Chroma 有数据,自动重建 BM25。"""
if self.vector_store.count() > 0:
print("检测到 Chroma 有历史数据,正在重建 BM25 索引...")
self.bm25_index.rebuild_from_vector_store(self.vector_store)
print("BM25 索引重建完成")
else:
print("Chroma 为空,跳过 BM25 重建")
CLI 行为:[main.py](../ragbackend/main.py) 在 ingest / ask / eval 启动时均调用 dual_manager.initialize_on_startup(),若 Chroma 已有历史数据则自动重建 BM25,避免双索引不一致。
第四章 检索策略深入:从单路到混合
本章目标:实现生产级的混合检索系统,包括并行召回、RRF 融合、交叉编码器精排和查询增强策略。
4.1 为什么必须混合检索
| 查询类型 |
示例 |
稠密检索(向量) |
稀疏检索(BM25) |
| 语义问法 |
“出差打车费怎么报?” |
✅ 能理解意图 |
❌ 缺关键词 |
| 精确编号 |
“AI-2024-03号文” |
❌ 漂移到相似段落 |
✅ 精确命中 |
| 专有名词 |
“通义千问 qwen-max” |
⚠️ 部分命中 |
✅ 精确匹配 |
| 同义改写 |
“员工差旅交通费报销规定” |
✅ 语义泛化 |
❌ 词面不匹配 |
| 混合型 |
“2024年差旅报销新标准” |
⚠️ 语义部分命中 |
⚠️ 关键词部分命中 |
单路检索永远有盲区。混合检索让两条路各自发挥优势,再通过融合算法取并集。
4.2 RRF 融合算法:不校准分数的排序融合
稠密相似度和 BM25 分数的量纲完全不同(一个是 0-1 的余弦相似度,一个是无界的 BM25 分数),直接加权求和很脆弱——权重难以调节,且不同查询下的分数分布差异很大。
Reciprocal Rank Fusion(RRF) 巧妙地绕开了分数校准问题:它只看排名,不看分数。
RRF_score(d) = Σ_i 1 / (k + rank_i(d))
其中 rank_i(d) 是文档 d 在第 i 路检索结果中的排名(从 1 开始),k 是平滑常数(默认 60)。
直觉理解:一个文档如果在两路检索中都排在前列,它的 RRF 分数就高;如果只有一路命中,分数就低。k=60 意味着排名 60 以后的文档贡献几乎可以忽略。
from typing import Any
def reciprocal_rank_fusion(
ranked_lists: list[list[dict[str, Any]]],
k: int = 60,
) -> list[dict[str, Any]]:
"""Reciprocal Rank Fusion:融合多路检索结果。
Args:
ranked_lists: 多路检索结果,每路是按相关性排序的文档列表
k: 平滑常数,默认 60
Returns:
融合后按 RRF 分数降序排列的文档列表
"""
rrf_scores: dict[str, float] = {}
doc_map: dict[str, dict[str, Any]] = {}
for rank_list in ranked_lists:
for rank, doc in enumerate(rank_list, start=1):
doc_id = doc["chunk_id"]
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + 1.0 / (k + rank)
if doc_id not in doc_map:
doc_map[doc_id] = doc.copy()
else:
existing_meta = doc_map[doc_id].get("metadata", {})
new_meta = doc.get("metadata", {})
existing_meta.update(new_meta)
sorted_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)
results = []
for doc_id in sorted_ids:
doc = doc_map[doc_id]
doc["rrf_score"] = rrf_scores[doc_id]
results.append(doc)
return results
def weighted_rrf(
ranked_lists: list[list[dict[str, Any]]],
weights: list[float],
k: int = 60,
) -> list[dict[str, Any]]:
"""加权 RRF:为不同检索路赋予不同权重。
Args:
ranked_lists: 多路检索结果
weights: 每路的权重,如 [1.0, 0.8] 表示稠密路权重更高
k: 平滑常数
"""
rrf_scores: dict[str, float] = {}
doc_map: dict[str, dict[str, Any]] = {}
for weight, rank_list in zip(weights, ranked_lists):
for rank, doc in enumerate(rank_list, start=1):
doc_id = doc["chunk_id"]
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + weight / (k + rank)
if doc_id not in doc_map:
doc_map[doc_id] = doc.copy()
sorted_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)
results = []
for doc_id in sorted_ids:
doc = doc_map[doc_id]
doc["rrf_score"] = rrf_scores[doc_id]
results.append(doc)
return results
4.3 Cross-Encoder 精排
Bi-Encoder 适合大规模召回(毫秒级),Cross-Encoder 适合对少量候选进行精细排序(百毫秒级,但更准确)。
接线说明:精排使用 本地 Cross-Encoder(sentence-transformers),模型路径由 .env 的 RERANKER_MODEL_PATH 指定;CLI 中 [main.py](../ragbackend/main.py) 的 create_reranker() 在 USE_RERANKER=true 时加载并注入检索器。
from ragbackend.reranker import CrossEncoderReranker, create_reranker
reranker = create_reranker()
retriever = HybridRetriever(..., reranker=reranker)
4.4 高级检索策略
集成状态(对照 [advanced_retrieval.py](../ragbackend/advanced_retrieval.py) 与 [retriever.py](../ragbackend/retriever.py)):
| 策略 |
代码位置 |
默认 CLI |
查询改写 rewrite |
_prepare_queries() 已接入 |
需 RetrievalConfig(query_strategy="rewrite") + llm_call_fn |
| HyDE |
hyde_retrieval() |
未接入默认 ask 路径 |
| Step-back |
step_back_retrieval() |
未接入默认 ask 路径 |
| Parent-Child |
ParentChildIndex |
未接入默认 ask 路径 |
4.4.1 查询改写(Query Rewriting)
用户的原始查询可能表述不清、过于笼统,或者遗漏了关键信息。让 LLM 改写查询,生成多个角度的查询,分别检索后融合结果。
def query_rewriting(query: str, llm_call_fn, num_rewrites: int = 3) -> list[str]:
"""让 LLM 从不同角度改写查询,生成多个检索查询。
Args:
query: 用户原始查询
llm_call_fn: LLM 调用函数,接受 prompt 返回文本
num_rewrites: 改写数量
Returns:
包含原始查询和改写查询的列表
"""
prompt = f"""你是一个查询改写助手。请将以下用户问题改写为 {num_rewrites} 个不同角度的搜索查询,
以提高在知识库中检索到相关信息的概率。
原始问题:{query}
要求:
1. 保持原始问题的核心意图
2. 每个改写使用不同的表述角度或关键词
3. 如果问题涉及专有名词,保留原名并尝试同义词
4. 每行输出一个查询,不要编号
请直接输出 {num_rewrites} 行改写后的查询:"""
response = llm_call_fn(prompt)
rewrites = [line.strip() for line in response.strip().split("\n") if line.strip()]
return [query] + rewrites[:num_rewrites]
4.4.2 HyDE:假设性文档嵌入
HyDE(Hypothetical Document Embeddings)是一种巧妙的检索增强策略:
-
先让 LLM 生成一个「假设性的答案」(不需要准确,只需要格式和语义上接近真实答案)
-
将这个假设答案作为查询去检索(因为假设答案和真实文档的语义更接近)
-
用检索到的真实文档替换假设答案,送给 LLM 生成最终回答
def hyde_retrieval(query: str, llm_call_fn, embed_fn, vector_store,
top_k: int = 10) -> list[dict]:
"""HyDE 检索:先生成假设答案,再用假设答案的向量去检索。
原理:用户问题是疑问句,知识库文档是陈述句,二者语义空间有差距。
假设答案是陈述句,与知识库文档的语义更接近,检索效果更好。
"""
hyde_prompt = f"""请回答以下问题。如果你不确定答案,请根据你的一般知识给出最合理的推测。
不需要标注不确定性,直接陈述即可。
问题:{query}
回答:"""
hypothetical_answer = llm_call_fn(hyde_prompt)
hyde_embedding = embed_fn([hypothetical_answer])[0]
results = vector_store.search(hyde_embedding, top_k=top_k)
for r in results:
r["retrieval_method"] = "hyde"
r["hypothetical_answer"] = hypothetical_answer
return results
4.4.3 Step-back Prompting
当用户的问题非常具体时,先让 LLM 抽象出一个更宏观的问题,检索宏观层面的背景信息,再结合具体问题的检索结果一起生成答案。
def step_back_retrieval(query: str, llm_call_fn, retrieve_fn) -> list[dict]:
"""Step-back 检索:同时检索原始问题和抽象化问题。
例:原始问题 "2024年Q3差旅报销新标准中打车费上限是多少"
Step-back: "公司差旅报销政策的一般规定和标准"
"""
step_back_prompt = f"""请将以下具体问题抽象为一个更宏观的背景问题。
宏观问题应该能帮助检索到相关的背景信息和政策框架。
具体问题:{query}
请输出一个宏观背景问题(一行):"""
abstract_query = llm_call_fn(step_back_prompt).strip()
specific_results = retrieve_fn(query, top_k=5)
abstract_results = retrieve_fn(abstract_query, top_k=5)
for r in specific_results:
r["retrieval_method"] = "specific"
for r in abstract_results:
r["retrieval_method"] = "step_back"
return reciprocal_rank_fusion([specific_results, abstract_results])
4.4.4 Parent-Child Retrieval
核心思想:用小块(child)做精确检索,但送进 LLM 的是包含该小块的更大段落(parent),保留完整上下文。
class ParentChildIndex:
"""Parent-Child 索引:小块检索,大块送生成。
构建时:
1. 将文档切分为大块(parent,如 1500 字)
2. 将每个大块再切分为小块(child,如 300 字)
3. 只对小块建向量索引
4. 维护 child → parent 的映射关系
检索时:
1. 在小块索引中搜索 Top-K
2. 找到命中的小块对应的父块
3. 返回父块内容(去重)
"""
def __init__(self, vector_store: VectorStore):
self.vector_store = vector_store
self.parent_map: dict[str, dict] = {}
def build(self, documents: list[str], embed_fn,
parent_size: int = 1500, child_size: int = 300) -> None:
"""构建 Parent-Child 索引。"""
child_chunks = []
for doc_idx, doc_text in enumerate(documents):
parent_chunks = recursive_split(doc_text, chunk_size=parent_size, overlap=0)
for p_idx, parent_text in enumerate(parent_chunks):
parent_id = f"doc_{doc_idx}_parent_{p_idx}"
parent_doc = {
"parent_id": parent_id,
"content": parent_text,
}
children = recursive_split(parent_text, chunk_size=child_size, overlap=0)
for c_idx, child_text in enumerate(children):
child_id = f"{parent_id}_child_{c_idx}"
self.parent_map[child_id] = parent_doc
child_chunks.append({
"chunk_id": child_id,
"content": child_text,
"metadata": {
"parent_id": parent_id,
"child_index": c_idx,
},
})
texts = [c["content"] for c in child_chunks]
embeddings = embed_fn(texts)
self.vector_store.add_chunks(child_chunks, embeddings)
print(f"Parent-Child 索引构建完成:{len(self.parent_map)} 个 child 块")
def search(self, query_embedding: list[float], top_k: int = 5) -> list[dict]:
"""检索:child 命中 → 返回 parent 内容(去重)。"""
child_results = self.vector_store.search(query_embedding, top_k=top_k * 3)
seen_parents = set()
parent_results = []
for child in child_results:
parent = self.parent_map.get(child["chunk_id"])
if parent and parent["parent_id"] not in seen_parents:
seen_parents.add(parent["parent_id"])
parent_results.append({
"chunk_id": parent["parent_id"],
"content": parent["content"],
"score": child["score"],
"matched_child": child["content"][:100] + "...",
})
if len(parent_results) >= top_k:
break
return parent_results
4.5 完整的混合检索器
将以上策略整合为统一检索器。完整实现见 [ragbackend/retriever.py](../ragbackend/retriever.py)。
与文档旧版差异(以源码为准):
| 参数 |
旧文档 |
现行默认 |
recall_k |
20 |
5([config.py](../ragbackend/config.py)) |
query_strategy |
四种均可用 |
仅 original / rewrite 在 _prepare_queries() 中实现 |
query_strategy 默认 |
original |
original(RetrievalConfig dataclass 默认) |
llm_call_fn |
无 |
通过 make_llm_call_fn() 注入(查询改写) |
| 调试输出 |
无 |
_log_results() 打印各阶段命中数与预览 |
from ragbackend.retriever import HybridRetriever, RetrievalConfig
from ragbackend.llm import make_llm_call_fn
from ragbackend.config import settings
config = RetrievalConfig(
recall_k=settings.recall_k,
top_k=settings.top_k,
use_reranker=settings.use_reranker,
query_strategy="rewrite",
)
retriever = HybridRetriever(
vector_store=vector_store,
bm25_index=bm25_index,
config=config,
embed_fn=lambda texts: get_embeddings(texts),
llm_call_fn=make_llm_call_fn(settings.llm_model),
)
results = retriever.retrieve("差旅报销标准")
timing = retriever.get_timing()
检索流程:_prepare_queries → 并行稠密/稀疏召回 → RRF 融合 → 可选精排 → 返回 Top-K。超时或单路失败时自动降级为可用一路的结果。
第五章 生成与接地策略
本章目标:将检索到的上下文安全、准确地送入 LLM,实现带引用的流式生成,并设计健壮的拒答机制。
5.1 Prompt 工程:接地约束是核心
RAG 生成阶段最关键的不是模型有多强,而是 Prompt 是否足够严格地约束模型只基于检索上下文作答。一个松散的 Prompt 会让模型在上下文不足时自由发挥,产生幻觉。
RAG_SYSTEM_PROMPT = """你是一个专业的知识问答助手。你必须严格遵守以下规则:
## 回答规则
1. **仅依据提供的「参考资料」回答问题**,不要使用你自己的知识
2. 如果参考资料中没有足够的信息来回答问题,请直接回复:
"根据知识库中的资料,暂时无法回答此问题。"
3. **绝对禁止编造**信息、数据、日期或来源
4. 回答时请引用资料来源,格式为 [来源: 文件名]
## 引用格式
- 在回答的关键事实后面标注引用,例如:报销上限为 500 元 [来源: 差旅报销制度.md]
- 如果多个来源支持同一结论,标注所有来源
## 回答风格
- 简洁、准确、结构化
- 优先使用列表和表格呈现结构化信息
- 不确定的内容明确标注"""
def build_rag_prompt(query: str, contexts: list[dict]) -> tuple[str, str]:
"""构建 RAG 的 system prompt 和 user prompt。
Args:
query: 用户问题
contexts: 检索到的文档列表
Returns:
(system_prompt, user_prompt) 元组
"""
if not contexts:
return RAG_SYSTEM_PROMPT, query
context_parts = []
for i, ctx in enumerate(contexts, 1):
source = ctx.get("metadata", {}).get("source_name", "未知来源")
content = ctx["content"]
context_parts.append(f"### 参考资料 {i}\n来源: {source}\n内容: {content}")
context_block = "\n\n".join(context_parts)
user_prompt = f"""## 参考资料
{context_block}
---
## 用户问题
{query}
请基于以上参考资料回答用户问题。如果参考资料中没有相关信息,请明确说明。"""
return RAG_SYSTEM_PROMPT, user_prompt
5.2 拒答策略:宁可不说,不可乱说
RAG 系统最常见的错误不是「答错了」,而是「在不知道的情况下编造了一个听起来合理的答案」。健壮的拒答策略是 RAG 系统上线的安全底线。
class RefusalPolicy:
"""RAG 拒答策略:在多种情况下拒绝回答,防止幻觉。"""
def __init__(
self,
min_contexts: int = 1,
min_avg_score: float = 0.3,
min_max_score: float = 0.5,
):
self.min_contexts = min_contexts
self.min_avg_score = min_avg_score
self.min_max_score = min_max_score
def should_refuse(self, contexts: list[dict]) -> tuple[bool, str]:
"""判断是否应该拒答。
Returns:
(should_refuse: bool, reason: str)
"""
if not contexts:
return True, "未检索到相关资料"
if len(contexts) < self.min_contexts:
return True, "检索到的资料不足以回答问题"
scores = [c.get("score", c.get("rrf_score", 0)) for c in contexts]
max_score = max(scores) if scores else 0
if max_score < self.min_max_score:
return True, f"检索结果相关性不足 (最高分: {max_score:.2f})"
avg_score = sum(scores) / len(scores) if scores else 0
if avg_score < self.min_avg_score:
return True, f"检索结果平均相关性偏低 (平均分: {avg_score:.2f})"
return False, ""
REFUSAL_MESSAGE = "根据知识库中的资料,暂时无法回答此问题。建议尝试换一种方式提问,或联系管理员确认知识库是否包含相关内容。"
5.2.5 统一 LLM 层(llm.py)
生成、查询改写、HyDE、Agent 与 RAGAS Judge 共用 [ragbackend/llm.py](../ragbackend/llm.py) 的模型工厂,避免各处硬编码 DashScope / DeepSeek 分支。
| 函数 |
用途 |
create_chat_llm(model, streaming=...) |
返回 LangChain Chat 模型:deepseek-* → ChatDeepSeek,其它 → ChatTongyi |
make_llm_call_fn(model) |
构造 (prompt: str) -> str 回调,供查询改写 / HyDE 等 |
is_deepseek_model(model) |
判断模型名是否走 DeepSeek API |
环境变量([config.py](../ragbackend/config.py) 的 Settings.__post_init__ 读取):
| 变量 |
说明 |
llm_model 或 LLM_MODEL |
默认 qwen3.7-max,可设为 deepseek-chat 等 |
DASHSCOPE_API_KEY |
通义 / Embedding / RAGAS 裁判 |
DEEPSEEK_API_KEY |
当 llm_model 为 deepseek-* 时必需 |
from ragbackend.llm import create_chat_llm, make_llm_call_fn
llm = create_chat_llm("qwen3.7-max", streaming=False)
llm_call_fn = make_llm_call_fn()
5.3 流式生成与 Citation
生成器通过 LangChain Chat 模型调用,不再直接使用 dashscope.Generation.call。完整实现见 [ragbackend/generator.py](../ragbackend/generator.py)。
-
默认模型:
qwen3.7-max(可通过 .env 的 llm_model 覆盖)
-
非流式:
llm.invoke(SystemMessage + HumanMessage),返回 timing.llm_total_ms
-
流式:
llm.stream(...),SSE 事件形态不变
SSE 事件类型:
| type |
含义 |
content |
增量文本 token |
timing |
{"ttft_ms": ...} 首 token 延迟 |
citation |
引用来源 metadata |
refusal |
拒答(检索质量不足) |
done |
结束信号 |
error |
LLM 调用异常 |
[main.py](../ragbackend/main.py) 的 cmd_ask 在直连模式下会打印:检索 {retrieve_total_ms}ms | LLM {llm_total_ms}ms | 合计。
from langchain_core.messages import HumanMessage, SystemMessage
from ragbackend.generator import RAGGenerator
from ragbackend.llm import create_chat_llm
from ragbackend.prompts import build_rag_prompt
class RAGGenerator:
def __init__(self, model: str | None = None, max_tokens: int | None = None):
self.model = model or settings.llm_model
self.max_tokens = max_tokens or settings.llm_max_tokens
def generate(self, query: str, contexts: list[dict]) -> dict:
should_refuse, reason = self.refusal_policy.should_refuse(contexts)
if should_refuse:
return {"answer": REFUSAL_MESSAGE, "refused": True, "timing": {}}
system_prompt, user_prompt = build_rag_prompt(query, contexts)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
start = time.time()
llm = create_chat_llm(self.model, streaming=False, max_tokens=self.max_tokens)
response = llm.invoke(messages)
answer = response.content if hasattr(response, "content") else str(response)
return {
"answer": answer,
"citations": self._extract_citations(contexts),
"refused": False,
"timing": {"llm_total_ms": (time.time() - start) * 1000},
"llm_model": self.model,
}
def stream_generate(self, query: str, contexts: list[dict]):
...
5.4 Token 预算管理
LLM 有上下文长度限制(如 qwen3.7-max 支持长上下文,实际仍受 max_context_tokens 预算约束)。检索到的多个 chunk 拼接后可能超出预算。需要合理分配上下文窗口。
def estimate_tokens(text: str) -> int:
"""粗略估算中文文本的 token 数。
经验法则:中文 1 个字 ≈ 1-2 个 token,英文 1 个单词 ≈ 1-1.5 个 token。
这里取保守估计。
"""
return int(len(text) * 0.7)
def budget_contexts(
contexts: list[dict],
max_context_tokens: int = 4000,
reserved_for_answer: int = 2000,
model_max_tokens: int = 8192,
) -> list[dict]:
"""在 token 预算内选择最优的上下文组合。
策略:按相关性从高到低逐个加入,直到预算耗尽。
Args:
contexts: 按相关性排序的检索结果
max_context_tokens: 上下文最大 token 数
reserved_for_answer: 为模型回答预留的 token 数
model_max_tokens: 模型最大上下文
"""
system_tokens = 200
available = min(max_context_tokens, model_max_tokens - system_tokens - reserved_for_answer)
selected = []
used_tokens = 0
for ctx in contexts:
ctx_tokens = estimate_tokens(ctx["content"])
if used_tokens + ctx_tokens <= available:
selected.append(ctx)
used_tokens += ctx_tokens
else:
remaining = available - used_tokens
if remaining > 100:
truncated_content = ctx["content"][:int(remaining / 0.7)]
selected.append({**ctx, "content": truncated_content, "truncated": True})
break
return selected
5.5 端到端 RAG Pipeline
将检索和生成组合为完整的 RAG 流水线:
class RAGPipeline:
"""端到端 RAG 流水线:检索 + 上下文预算 + 生成。
Usage:
pipeline = RAGPipeline(retriever, generator)
# 非流式
result = pipeline.ask("差旅报销标准是什么?")
print(result["answer"])
# 流式
for event in pipeline.ask_stream("差旅报销标准是什么?"):
if event["type"] == "content":
print(event["content"], end="", flush=True)
"""
def __init__(
self,
retriever: HybridRetriever,
generator: RAGGenerator,
max_context_tokens: int = 4000,
):
self.retriever = retriever
self.generator = generator
self.max_context_tokens = max_context_tokens
def ask(self, query: str) -> dict:
"""非流式问答。"""
contexts = self.retriever.retrieve(query)
contexts = budget_contexts(contexts, self.max_context_tokens)
result = self.generator.generate(query, contexts)
result["retrieval_timing"] = self.retriever.get_timing()
result["contexts_used"] = len(contexts)
return result
def ask_stream(self, query: str):
"""流式问答。"""
contexts = self.retriever.retrieve(query)
contexts = budget_contexts(contexts, self.max_context_tokens)
yield {
"type": "retrieval_info",
"data": {
"timing": self.retriever.get_timing(),
"contexts_count": len(contexts),
},
}
yield from self.generator.stream_generate(query, contexts)
编程接入示例见 附录 B 与 [ragbackend/main.py](../ragbackend/main.py)。
第六章 Agentic RAG:让模型自主决策检索
本章目标:将检索能力封装为 Agent 的工具,让 LLM 自主决定是否检索、检索什么、是否需要二次检索。
6.1 Direct RAG vs Agentic RAG
Direct RAG(标准两步式)
用户提问 → [强制检索] → [单轮生成] → 回答
-
-
缺点:每次提问都检索(简单寒暄也检索);只能检索一次;无法处理多跳问题
Agentic RAG(智能体式)
用户提问 → [LLM 推理] → 是否需要检索?
├─ 是 → [调用检索工具] → [LLM 评估结果] → 够了吗?
│ ├─ 够 → [生成回答]
│ └─ 不够 → [改写查询 / 换工具] → 再次检索 → ...
└─ 否 → [直接回答 / 追问]
-
-
缺点:多次 LLM 调用增加延迟和成本、行为更难预测
| 场景 |
Direct RAG |
Agentic RAG |
| 简单事实问答 |
✅ 一次检索足够 |
❌ 多此一举 |
| 多跳推理 |
❌ 一次检索不够 |
✅ 逐步检索 |
| 对比分析 |
⚠️ 需调大 top_k |
✅ 分步检索对比 |
| 需要计算/查表 |
❌ 无此能力 |
✅ 调用计算/查表工具 |
| 简单寒暄 |
⚠️ 白检索一次 |
✅ 跳过检索 |
运行切换([main.py](../ragbackend/main.py) + [config.py](../ragbackend/config.py)):
RAG_USE_AGENT |
路径 |
说明 |
false(默认) |
pipeline.ask() |
直连:检索 → 单轮生成,打印检索/LLM 耗时 |
true |
agentic_ask() |
按需 import [agent.py](../ragbackend/agent.py),Agent tool-calling |
RAG_USE_AGENT=false
python -m ragbackend.main ask "出差住宿费上限是多少?"
6.2 基于 LangChain 1.x 的 Agentic RAG 实现
LangChain 1.3+ 使用 create_agent(非旧版 AgentExecutor / create_react_agent)。完整实现见 [ragbackend/agent.py](../ragbackend/agent.py)。
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.messages import AIMessage, ToolMessage
from ragbackend.llm import create_chat_llm
from ragbackend.agent import (
build_agent_tools, create_rag_agent, agentic_ask, _parse_agent_output,
)
build_agent_tools(retriever, vector_store, embed_fn)
agent = create_rag_agent(llm_model=settings.llm_model)
result = agent.invoke({
"messages": [{"role": "user", "content": "出差住宿标准和报销流程分别是什么?"}]
})
parsed = _parse_agent_output(result)
result = agentic_ask("2024年差旅报销新制度和旧制度有什么区别?")
print(result["answer"])
for step in result.get("steps", []):
print(step["tool"], step["input"])
与 Direct RAG 的差异:Agent 可多次调用 search_knowledge_base / search_with_filters,简单寒暄可跳过检索;代价是多次 LLM 调用与更高延迟。
6.3 自反思检索(Self-Reflective Retrieval)
集成状态:以下 self_reflective_retrieval 为 [advanced_retrieval.py](../ragbackend/advanced_retrieval.py) 中的参考实现,未接入默认 ask / agentic_ask 路径。生产接入需自行编排检索 → 评估 → 改写重试循环。
更高级的 Agentic 模式:Agent 检索后不直接回答,而是先评估检索结果的质量,如果不够好则自动改写查询重新检索。
def self_reflective_retrieval(
query: str,
retriever: HybridRetriever,
llm_call_fn,
max_retries: int = 2,
) -> dict:
"""自反思检索:检索 → 评估 → 必要时改写重试。
Returns:
{"answer": str, "contexts": list, "attempts": int, "queries_used": list}
"""
current_query = query
queries_used = [query]
best_contexts = []
best_score = 0
for attempt in range(max_retries + 1):
contexts = retriever.retrieve(current_query)
if contexts:
avg_score = sum(c.get("rrf_score", c.get("score", 0)) for c in contexts) / len(contexts)
else:
avg_score = 0
if avg_score > best_score:
best_score = avg_score
best_contexts = contexts
if avg_score > 0.5 and len(contexts) >= 3:
return {
"contexts": contexts,
"attempts": attempt + 1,
"queries_used": queries_used,
"quality": "good",
}
if attempt < max_retries:
rewrite_prompt = f"""我在知识库中搜索以下问题,但结果不够理想:
原始问题:{query}
已尝试的搜索:{', '.join(queries_used)}
当前检索质量评分:{avg_score:.2f}(满分 1.0)
请从不同角度改写搜索查询,帮助找到更相关的信息。
直接输出改写后的查询(一行):"""
current_query = llm_call_fn(rewrite_prompt).strip()
queries_used.append(current_query)
return {
"contexts": best_contexts,
"attempts": max_retries + 1,
"queries_used": queries_used,
"quality": "limited",
}
第七章 评估体系:用数据证明 RAG 有效
本章目标:建立系统化的 RAG 评估方法,用 RAGAS 框架量化检索和生成质量,支撑持续优化。
7.1 为什么「体感测试」不够
许多团队在 RAG 上线后只靠「随机问几个问题看看回答是否合理」来评估质量。这种方法的问题:
-
-
覆盖面窄:人的直觉只能覆盖常见场景,边界 case 容易遗漏
-
-
回归风险:改了切分策略后,某些问题变好了,另一些可能变差了,但你不知道
7.2 RAGAS 四大核心指标
RAGAS(Retrieval-Augmented Generation Assessment)是目前最广泛使用的 RAG 评估框架,核心指标分为检索质量和生成质量两个维度:
检索质量指标
| 指标 |
定义 |
计算方式 |
解读 |
| Context Precision |
检索到的上下文中,有多少是真相关的 |
相关 chunk 排名靠前的程度 |
高 = 检索结果噪声少 |
| Context Recall |
标准答案需要的信息,是否都被检索到了 |
标准答案中的关键信息在上下文中的覆盖率 |
高 = 没有漏掉重要信息 |
生成质量指标
| 指标 |
定义 |
计算方式 |
解读 |
| Faithfulness |
回答是否忠实于检索到的上下文 |
回答中的每个陈述是否都能在上下文中找到支撑 |
高 = 没有幻觉 |
| Answer Relevancy |
回答是否切题 |
回答与原始问题的语义相关度 |
高 = 没有答非所问 |
对比对比验证用户问题检索上下文 Contexts生成回答 Answer标准答案Context Precision检索的有多少是真相关的Context Recall需要的信息检索到了吗Faithfulness回答是否忠实于上下文Answer Relevancy回答是否切题
7.3 构建评测集
评测集是评估的基础。一个好的评测集应覆盖:常见问题、边界 case、多跳问题、否定问题(知识库里没有答案的问题)。
import json
from dataclasses import dataclass, asdict
@dataclass
class EvalSample:
"""评测样本。"""
question: str
ground_truth: str
context: str
difficulty: str
category: str
metadata: dict = None
def build_eval_dataset() -> list[EvalSample]:
"""构建示例评测集。
实际项目中应包含 50-200 条样本,覆盖:
1. 简单事实问答(easy)
2. 需要综合多个 chunk 的问题(medium)
3. 多跳推理问题(hard)
4. 知识库中不存在答案的问题(negative)
"""
samples = [
EvalSample(
question="出差住宿费上限是多少?",
ground_truth="国内出差住宿费上限为每天 500 元。",
context="公司差旅报销制度规定,国内出差住宿费上限为每天 500 元。",
difficulty="easy",
category="事实查询",
),
EvalSample(
question="报销流程需要多久?",
ground_truth="报销审批流程约 3-5 个工作日。",
context="报销审批流程:提交人 → 直属领导 → 财务部门 → 打款。约 3-5 个工作日。",
difficulty="easy",
category="事实查询",
),
EvalSample(
question="2024 年的餐补标准是多少?",
ground_truth="2024 年新版制度取消了原有餐补标准,改为每天 150 元的综合补贴。",
context="2024 年新版报销制度取消了原有的餐补标准,改为每天 150 元的综合补贴。",
difficulty="medium",
category="变更查询",
),
EvalSample(
question="打车费报销需要哪些材料?流程是怎样的?",
ground_truth="打车费属于交通费,需要附上打车发票和行程单。报销流程为提交人→直属领导→财务部门→打款,约 3-5 个工作日。",
context="出差打车费用属于交通费范畴,需要附上打车发票和行程单。报销审批流程:提交人→直属领导→财务部门→打款,约3-5个工作日。",
difficulty="hard",
category="多跳推理",
),
EvalSample(
question="公司年假有多少天?",
ground_truth="根据知识库中的资料,暂时无法回答此问题。",
context="",
difficulty="easy",
category="否定测试",
),
]
return samples
def save_eval_dataset(samples: list[EvalSample], path: str = "eval_dataset.json"):
"""保存评测集到 JSON 文件。"""
data = [asdict(s) for s in samples]
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"评测集已保存到 {path},共 {len(samples)} 条")
现行 CLI 行为([evaluator.py](../ragbackend/evaluator.py) + [main.py](../ragbackend/main.py)):
-
python -m ragbackend.main eval 调用内置 build_eval_dataset(),固定 5 条样本(easy / medium / hard / 否定测试各覆盖)。
-
[data/eval/eval_dataset.json](../ragbackend/data/eval/eval_dataset.json) 存在但 不会自动加载;扩展评测集需自行调用 save_eval_dataset() 或修改 cmd_eval。
-
RAGAS 限制:
pipeline.ask() 返回 contexts_used(数量)而非 contexts(全文),因此 evaluate_rag_pipeline 中 retrieved_contexts 常为空,context_precision / context_recall 等指标可能偏低或无效——Faithfulness / Answer Relevancy 仍可参考。若需完整 Context 指标,需在 pipeline 中额外返回检索全文(后续可单独 PR 修复)。
7.4 RAGAS 评估流水线
与 ragas 0.4.3 API 对齐,Judge LLM 与 Embedding 均走 DashScope,避免默认 OpenAI。完整实现见 [ragbackend/evaluator.py](../ragbackend/evaluator.py)。
from ragas import EvaluationDataset, SingleTurnSample, evaluate
from ragas.metrics import context_precision, context_recall, faithfulness, answer_relevancy
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
from langchain_community.embeddings import DashScopeEmbeddings
from ragbackend.llm import create_chat_llm
from ragbackend.config import settings
def _evaluate_with_ragas(results: list[dict], llm_model: str) -> dict:
dataset = EvaluationDataset(samples=[
SingleTurnSample(
user_input=r["question"],
response=r["answer"],
retrieved_contexts=r["retrieved_contexts"],
reference=r["ground_truth"],
)
for r in results
])
eval_llm = LangchainLLMWrapper(create_chat_llm(llm_model, streaming=False))
eval_embeddings = LangchainEmbeddingsWrapper(DashScopeEmbeddings(
model=settings.embedding_model,
dashscope_api_key=settings.dashscope_api_key,
))
scores = evaluate(
dataset=dataset,
metrics=[context_precision, context_recall, faithfulness, answer_relevancy],
llm=eval_llm,
embeddings=eval_embeddings,
raise_exceptions=False,
)
def _metric(name: str):
try:
return float(scores[name])
except Exception:
if hasattr(scores, "to_pandas"):
df = scores.to_pandas()
if name in df.columns:
return float(df[name].mean())
return None
return {
"context_precision": _metric("context_precision"),
"context_recall": _metric("context_recall"),
"faithfulness": _metric("faithfulness"),
"answer_relevancy": _metric("answer_relevancy"),
"details": results,
}
未安装 ragas 时自动降级为 _simple_evaluate()(拒答准确率、回答非空率、平均长度)。
A/B 对比实验(调 chunk_size 等参数时,每次只改一个变量):
def ab_test_chunk_sizes(documents, eval_samples, chunk_sizes=(200, 500, 800, 1200)):
for size in chunk_sizes:
processor = DocumentProcessor(chunk_size=size)
...
7.5 评估的工程纪律
-
每次只改一个变量:切分大小、Embedding 模型、top_k、是否重排、Prompt 模板——不要同时改两个以上
-
评估前冻结评测集:评测集不能在调参过程中修改,否则等于过拟合测试集
-
保留基线:每次改动前记录当前版本的指标,改完后对比
-
关注回归:某个指标提升但另一个下降时,要分析 trade-off 是否合理
-
接入 CI:核心指标跌破阈值(如 Faithfulness 下降 5%)时阻断发布
第八章 生产工程化:从 Demo 到上线
本章目标:将一个能跑的 RAG Demo 硬化为可监控、可降级、可扩展的生产系统。
说明:[production.py](../ragbackend/production.py) 为参考架构(缓存、熔断、ObservableRAGPipeline 等),默认 python -m ragbackend.main ask 未启用 ObservableRAGPipeline;现行直连模式仅通过 retriever.timing / generator.timing 与 stdout 打印耗时。
8.1 延迟分析与优化
RAG 系统的端到端延迟可以分解为:
TTFT (Time to First Token) = 查询向量化 + 检索延迟 + LLM Prefill
Total Latency = TTFT + 生成耗时(逐 token)
各阶段典型耗时参考:
| 阶段 |
本地/低延迟 |
远程 API |
优化手段 |
| 查询向量化 |
5-20ms |
50-200ms |
缓存、批量化 |
| 向量搜索 Top-5 |
5-50ms |
10-100ms |
ANN 索引优化(默认 recall_k=5) |
| BM25 搜索 Top-5 |
1-10ms |
– |
内存索引 |
| RRF 融合 |
< 1ms |
– |
— |
| Cross-Encoder Rerank |
200-1000ms |
100-500ms |
限时降级 |
| LLM Prefill |
200-800ms |
300-1500ms |
上下文裁剪 |
| LLM 生成(per token) |
20-50ms |
30-80ms |
— |
关键优化策略:
import functools
import hashlib
import time
class QueryCache:
"""查询缓存:相同查询在短时间内直接返回缓存结果。
生产系统中,大量查询是重复或相似的(如热门 FAQ)。
"""
def __init__(self, ttl_seconds: int = 300, max_size: int = 1000):
self.ttl = ttl_seconds
self.max_size = max_size
self.cache: dict[str, tuple[float, any]] = {}
def get(self, query: str) -> any | None:
"""查询缓存,未命中或过期返回 None。"""
key = self._make_key(query)
if key in self.cache:
ts, result = self.cache[key]
if time.time() - ts < self.ttl:
return result
else:
del self.cache[key]
return None
def put(self, query: str, result: any) -> None:
"""写入缓存。"""
if len(self.cache) >= self.max_size:
oldest_key = min(self.cache, key=lambda k: self.cache[k][0])
del self.cache[oldest_key]
key = self._make_key(query)
self.cache[key] = (time.time(), result)
def _make_key(self, query: str) -> str:
return hashlib.md5(query.strip().lower().encode()).hexdigest()
def timing_decorator(name: str):
"""分阶段耗时追踪装饰器。"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed_ms = (time.time() - start) * 1000
print(f"[Timing] {name}: {elapsed_ms:.1f}ms")
return result
return wrapper
return decorator
@timing_decorator("vector_search")
def cached_vector_search(query: str, cache: QueryCache, vector_store, embed_fn, top_k=10):
cached = cache.get(f"vector:{query}")
if cached is not None:
return cached
embedding = embed_fn([query])[0]
results = vector_store.search(embedding, top_k=top_k)
cache.put(f"vector:{query}", results)
return results
8.2 降级策略矩阵
生产系统必须为每个组件设计降级方案:
class DegradationManager:
"""降级管理器:集中管理各组件的降级策略。"""
def __init__(self):
self.component_status = {}
self.failure_counts = {}
self.circuit_breaker_threshold = 3
def record_success(self, component: str):
"""记录组件成功。"""
self.failure_counts[component] = 0
self.component_status[component] = "healthy"
def record_failure(self, component: str):
"""记录组件失败。"""
self.failure_counts[component] = self.failure_counts.get(component, 0) + 1
if self.failure_counts[component] >= self.circuit_breaker_threshold:
self.component_status[component] = "circuit_open"
print(f"[Circuit Breaker] {component} 熔断!连续失败 "
f"{self.failure_counts[component]} 次")
def is_available(self, component: str) -> bool:
"""检查组件是否可用。"""
return self.component_status.get(component, "healthy") != "circuit_open"
def reset(self, component: str):
"""手动重置熔断器。"""
self.failure_counts[component] = 0
self.component_status[component] = "healthy"
DEGRADATION_MATRIX = {
"vector_search": {
"failure": "使用 BM25 单路结果",
"timeout": "使用已返回的部分结果",
"circuit_open": "临时禁用向量检索,仅使用 BM25",
},
"bm25_search": {
"failure": "使用向量单路结果",
"timeout": "使用已返回的部分结果",
"circuit_open": "临时禁用 BM25,仅使用向量检索",
},
"reranker": {
"failure": "使用 RRF 排序结果",
"timeout": "使用 RRF 排序结果",
"circuit_open": "跳过精排阶段",
},
"llm_generation": {
"failure": "返回固定话术:'系统繁忙,请稍后重试'",
"timeout": "返回已生成的部分文本 + '(回答被截断)'",
"circuit_open": "切换到备用模型或返回缓存答案",
},
"embedding_api": {
"failure": "使用本地轻量 Embedding 模型",
"timeout": "重试一次后降级",
"circuit_open": "切换到备用 Embedding 服务",
},
}
8.3 可观测性:分阶段耗时追踪
现行 ragbackend 已在检索器与生成器中内置 timing 字典;cmd_ask 打印检索 + LLM 合计耗时。与 [production.py](../ragbackend/production.py) 中 RequestTrace 字段对照:
| 来源 |
字段 |
含义 |
retriever.get_timing() |
vector_ms / bm25_ms |
并行稠密/稀疏召回耗时 |
|
retrieve_parallel_ms |
并行检索总耗时 |
|
rrf_ms |
RRF 融合 |
|
rerank_ms |
精排(未启用时为 0) |
|
retrieve_total_ms |
检索端到端 |
generator.generate() → timing |
llm_total_ms |
LLM 调用总耗时 |
流式 stream_generate |
timing.ttft_ms |
首 token 延迟(SSE 事件) |
Prometheus / Grafana 接入为演进目标;当前以 stdout 与返回 dict 为主。
import time
from contextlib import contextmanager
class RequestTrace:
"""请求级追踪:记录一次 RAG 请求中各阶段的耗时和关键数据。"""
def __init__(self, request_id: str = ""):
self.request_id = request_id or str(int(time.time() * 1000))
self.spans: dict[str, float] = {}
self.metadata: dict = {}
self._start_times: dict[str, float] = {}
@contextmanager
def span(self, name: str):
"""上下文管理器:自动记录某个阶段的耗时。
Usage:
with trace.span("vector_search"):
results = vector_store.search(...)
"""
start = time.time()
try:
yield
finally:
elapsed_ms = (time.time() - start) * 1000
self.spans[name] = elapsed_ms
def set_metadata(self, key: str, value):
"""记录元数据(如检索数量、模型选择等)。"""
self.metadata[key] = value
def summary(self) -> dict:
"""输出追踪摘要。"""
return {
"request_id": self.request_id,
"spans_ms": self.spans,
"metadata": self.metadata,
"total_ms": sum(self.spans.values()),
}
def log(self):
"""打印追踪日志。"""
s = self.summary()
parts = [f"request={s['request_id']}"]
for name, ms in s["spans_ms"].items():
parts.append(f"{name}={ms:.0f}ms")
parts.append(f"total={s['total_ms']:.0f}ms")
print(f"[Trace] {' | '.join(parts)}")
class ObservableRAGPipeline(RAGPipeline):
"""可观测的 RAG Pipeline:每个阶段自动记录耗时。"""
def ask(self, query: str) -> dict:
trace = RequestTrace()
with trace.span("retrieve"):
with trace.span("dense_search"):
pass
with trace.span("sparse_search"):
pass
contexts = self.retriever.retrieve(query)
trace.set_metadata("contexts_count", len(contexts))
with trace.span("budget_filter"):
contexts = budget_contexts(contexts, self.max_context_tokens)
trace.set_metadata("contexts_after_budget", len(contexts))
with trace.span("generate"):
result = self.generator.generate(query, contexts)
trace.set_metadata("refused", result.get("refused", False))
trace.set_metadata("answer_length", len(result.get("answer", "")))
result["trace"] = trace.summary()
trace.log()
return result
8.4 多租户与权限隔离
在企业场景中,不同部门/团队的知识库内容应该互相隔离。检索阶段必须按调用者身份过滤。
class TenantAwareRetriever:
"""租户感知的检索器:在检索阶段按租户过滤。
关键原则:权限过滤必须在检索阶段完成,
绝不能把越权文档送进 LLM 的上下文。
"""
def __init__(self, retriever: HybridRetriever):
self.retriever = retriever
def retrieve_for_tenant(
self,
query: str,
tenant_id: str,
user_permissions: list[str] | None = None,
) -> list[dict]:
"""为指定租户检索,自动过滤无权限文档。
Args:
query: 用户查询
tenant_id: 租户 ID
user_permissions: 用户有权限访问的文件列表
"""
where_filter = {"tenant_id": tenant_id}
if user_permissions is not None:
where_filter["$and"] = [
{"tenant_id": tenant_id},
{"file_id": {"$in": user_permissions}},
]
original_search = self.retriever.vector_store.search
def filtered_search(embedding, top_k, where=None):
return original_search(embedding, top_k, where=where_filter)
self.retriever.vector_store.search = filtered_search
try:
results = self.retriever.retrieve(query)
finally:
self.retriever.vector_store.search = original_search
return results
8.5 增量更新策略
| 策略 |
适用场景 |
实现复杂度 |
一致性 |
| 全量重建 |
文档基本静态、更新频率低 |
低 |
强一致 |
| 增量插入 + 全量重建 BM25 |
持续添加新文档 |
中 |
最终一致 |
| 增量插入 + 增量 BM25 |
大规模高频更新 |
高 |
最终一致 |
| CDC(变更数据捕获) |
对接 CMS/数据库 |
高 |
近实时 |
class IncrementalIngestion:
"""增量入库:只处理新增/修改的文档,减少重建开销。"""
def __init__(self, dual_manager: DualIndexManager):
self.dual_manager = dual_manager
self.file_registry: dict[str, dict] = {}
def ingest_if_changed(self, file_path: str) -> bool:
"""只在文件内容变化时才重新入库。
Returns:
True 如果文件被重新入库,False 如果无变化
"""
import hashlib
with open(file_path, "rb") as f:
content_hash = hashlib.md5(f.read()).hexdigest()
file_id = os.path.basename(file_path)
if file_id in self.file_registry:
if self.file_registry[file_id]["hash"] == content_hash:
return False
if file_id in self.file_registry:
self.dual_manager.delete_file(self.file_registry[file_id]["chunk_id_prefix"])
processor = DocumentProcessor(chunk_size=500)
chunks = processor.process_file(file_path)
chunk_dicts = [
{"chunk_id": c.chunk_id, "content": c.content, "metadata": c.metadata}
for c in chunks
]
self.dual_manager.ingest(chunk_dicts, lambda texts: get_embeddings(texts))
self.file_registry[file_id] = {
"hash": content_hash,
"chunk_id_prefix": chunks[0].file_id if chunks else file_id,
"timestamp": time.time(),
"chunk_count": len(chunks),
}
return True
第九章 总结与演进路线
9.1 RAG 落地路线图
Phase 1MVPPhase 2HybridPhase 3AgenticPhase 4GraphRAG单路向量检索基础切分直接生成BM25+向量混合RRF 融合结构化切分CitationAgent 自主决策多步检索查询改写自反思知识图谱实体关系多跳推理跨文档关联
9.2 各阶段目标与里程碑
| 阶段 |
核心能力 |
质量目标 |
延迟目标 |
工程投入 |
| MVP |
能跑通:上传 → 检索 → 回答 |
Faithfulness > 0.7 |
TTFT < 5s |
1-2 周 |
| Hybrid |
能上线:混合检索 + Citation + 拒答 |
Faithfulness > 0.85 |
TTFT < 3s |
2-4 周 |
| Agentic |
能处理复杂问题:多步推理 + 自反思 |
Context Recall > 0.8 |
TTFT < 5s(多步) |
4-8 周 |
| GraphRAG |
能处理关系推理:实体图谱 + 多跳 |
关系准确率 > 0.8 |
按场景 |
8-16 周 |
9.3 十条核心工程原则
-
切分优先于模型:再好的 Embedding 也救不了糟糕的切分
-
混合优于单路:BM25 + 向量 + RRF 覆盖 90% 的场景
-
-
-
测量优于直觉:用 RAGAS 数据说话,不要靠体感
-
-
-
缓存热门查询:FAQ 场景下缓存能省 80% 的 API 调用
-
-
9.4 技术选型速查表
| 需求 |
推荐方案 |
| 快速验证 RAG 可行性 |
Chroma + DashScope Embedding(batch=10)+ 递归切分 + python -m ragbackend.main demo |
| 企业知识库上线 |
Chroma/Milvus + Hybrid(recall_k=5)+ RRF + Citation + 拒答 |
| 复杂研究问答 |
LangChain 1.x create_agent + 多工具检索 |
| 跨文档关系推理 |
GraphRAG(Neo4j + LLM 实体抽取) |
| 评估 RAG 质量 |
RAGAS 0.4.3 + DashScope Judge/Embedding + 人工评测集 |
| 生产监控 |
现行 stdout timing;Prometheus + Grafana 为演进目标 |
附录 A:环境搭建与依赖安装
pip install -r ragbackend/requirements.txt
export DASHSCOPE_API_KEY="your-api-key"
export DEEPSEEK_API_KEY="your-deepseek-key"
常用环境变量([config.py](../ragbackend/config.py)):
| 变量 |
默认 / 说明 |
DASHSCOPE_API_KEY |
通义 LLM、Embedding、RAGAS |
DEEPSEEK_API_KEY |
llm_model=deepseek-chat 等 |
llm_model / LLM_MODEL |
默认 qwen3.7-max |
RAG_USE_AGENT |
false 直连 / true Agent |
USE_RERANKER |
false;true 时加载 RERANKER_MODEL_PATH 本地 CrossEncoder |
RERANKER_MODEL_PATH |
本地 Qwen3-Reranker 等权重目录 |
HF_ENDPOINT |
可选,国内 HuggingFace 镜像 |