主题
06-4 高级 RAG 技术
从"能用"到"好用":RAG 的进阶之路
上一节我们搭建了一个基础但完整的 RAG 系统——它能够加载文档、分块、向量化、检索和生成回答。对于个人知识库或原型验证来说,这已经足够了。但在生产环境中,你会很快遇到以下问题:
- 纯语义检索遗漏关键词匹配——用户问"如何配置 Ollama 的端口",但文档里写的是"OLLAMA_HOST 环境变量",语义相似度可能不够高
- 检索结果不够精确——Top-5 结果中可能只有 2 条真正相关,其余 3 条是"噪声"
- 上下文截断丢失关键信息——文档被切成小块后,每块可能缺少完整的上下文
- 每次新增文档都要重建索引——效率低下
这一节介绍的四种高级技术将逐一解决这些问题。
技术一:混合检索(Hybrid Retrieval)
问题
纯向量语义检索有一个盲区:它擅长理解意图但不擅长精确匹配。
场景: 用户搜索 "OLLAMA_HOST"
纯向量检索:
查询 "OLLAMA_HOST" 的 Embedding →
最接近的可能是:
✅ "Ollama 支持通过环境变量配置监听地址..." (相关)
⚠️ "Ollama 是一个本地运行工具..." (不太相关,但因为都包含 Ollama 而被检索到)
❌ "设置 OLLAMA_HOST=0.0.0.0:11434 允许局域网访问..." (最相关! 但排名不是第一)
原因: "OLLAMA_HOST" 这个专有名词在 Embedding 空间中的表示
可能与通用描述的距离反而更近解决方案:向量 + 关键词 双路召回
python
#!/usr/bin/env python3
"""混合检索: BM25 关键词 + 向量语义"""
import re
import math
import requests
import numpy as np
from collections import Counter, defaultdict
class BM25:
"""轻量级 BM25 关键词检索器"""
def __init__(self, k1=1.5, b=0.75):
self.k1 = k1
self.b = b
self.doc_freqs = defaultdict(int) # 词 → 包含该词的文档数
self.doc_len = [] # 每个文档的长度
self.avgdl = 0 # 平均文档长度
self.doc_tokens = [] # 每个文档的分词结果
self.N = 0 # 文档总数
self.fitted = False
def _tokenize(self, text):
"""简单分词(英文小写+中文按字符)"""
text = text.lower()
tokens = re.findall(r'[a-z0-9_]+|[\u4e00-\u9fff]', text)
return tokens
def fit(self, documents):
"""建立索引"""
self.N = len(documents)
total_len = 0
for doc in documents:
tokens = self._tokenize(doc)
self.doc_tokens.append(tokens)
doc_len = len(tokens)
self.doc_len.append(doc_len)
total_len += doc_len
# 统计词频
unique_tokens = set(tokens)
for token in unique_tokens:
self.doc_freqs[token] += 1
self.avgdl = total_len / self.N if self.N > 0 else 1
self.fitted = True
print(f"BM25 索引构建完成: {self.N} 个文档, "
f"词表大小 {len(self.doc_freqs)}, 平均长度 {self.avgdl:.1f}")
def search(self, query, top_k=10):
"""搜索"""
if not self.fitted:
raise RuntimeError("需要先调用 fit()")
query_tokens = self._tokenize(query)
scores = []
for i, doc_tokens in enumerate(self.doc_tokens):
score = 0.0
dl = self.doc_len[i]
for token in query_tokens:
if token not in self.doc_freqs:
continue
# 词在当前文档中的频率
tf = doc_tokens.count(token)
# IDF
df = self.doc_freqs[token]
idf = math.log((self.N - df + 0.5) / (df + 0.5) + 1)
# TF 归一化
tf_norm = (tf * (self.k1 + 1)) / (
tf + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
)
score += idf * tf_norm
scores.append((i, score))
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
class HybridRetriever:
"""混合检索器: BM25 + 向量语义"""
def __init__(self, ollama_url="http://localhost:11434",
embedding_model="nomic-embed-text"):
self.ollama_url = ollama_url
self.embedding_model = embedding_model
self.bm25 = BM25()
self.documents = []
self.embeddings = None
def add_documents(self, documents):
"""添加文档并建立双索引"""
self.documents = documents
# 建立 BM25 索引
self.bm25.fit(documents)
# 生成向量 Embedding
print("生成向量 Embedding...")
embeddings = []
for i, doc in enumerate(documents):
if (i + 1) % 50 == 0:
print(f" Embedding: {i+1}/{len(documents)}")
resp = requests.post(
f"{self.ollama_url}/api/embeddings",
json={"model": self.embedding_model, "prompt": doc},
timeout=30
)
embeddings.append(resp.json()["embedding"])
self.embeddings = np.array(embeddings, dtype=np.float32)
# L2 归一化
norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
self.embeddings /= norms
print(f"✅ 双索引构建完成")
def search(self, query, top_k=5, alpha=0.6,
bm25_weight=0.4, vector_weight=0.6):
"""
混合检索
Args:
query: 查询文本
top_k: 返回数量
alpha: RRF (Reciprocal Rank Fusion) 参数,或使用下面的权重
bm25_weight: BM25 分数的权重
vector_weight: 向量相似度的权重
Returns:
融合后的排序结果
"""
# === 路 1: BM25 关键词检索 ===
bm25_results = self.bm25.search(query, top_k=top_k * 3)
bm25_scores = {idx: score for idx, score in bm25_results}
# === 路 2: 向量语义检索 ===
resp = requests.post(
f"{self.ollama_url}/api/embeddings",
json={"model": self.embedding_model, "prompt": query},
timeout=30
)
query_vec = np.array(resp.json()["embedding"], dtype=np.float32)
query_vec /= np.linalg.norm(query_vec)
# 计算余弦相似度
cosine_sims = self.embeddings @ query_vec
top_vector_indices = np.argsort(cosine_sims)[::-1][:top_k * 3]
vector_scores = {
int(idx): float(cosine_sims[idx])
for idx in top_vector_indices
}
# === 融合策略: 加权分数归一化融合 ===
all_doc_ids = set(bm25_scores.keys()) | set(vector_scores.keys())
fused_scores = []
for doc_id in all_doc_ids:
bm25_score = bm25_scores.get(doc_id, 0)
vec_score = vector_scores.get(doc_id, 0)
# Min-Max 归一化(各自独立)
if bm25_scores:
bm25_max = max(bm25_scores.values()) or 1
bm25_norm = bm25_score / bm25_max
else:
bm25_norm = 0
if vector_scores:
vec_max = max(vector_scores.values()) or 1
vec_norm = vec_score / vec_max
else:
vec_norm = 0
# 加权融合
final_score = bm25_weight * bm25_norm + vector_weight * vec_norm
fused_scores.append((doc_id, final_score, bm25_score, vec_score))
# 排序并返回 Top-K
fused_scores.sort(key=lambda x: x[1], reverse=True)
results = []
for doc_id, final_score, bm25_sc, vec_sc in fused_scores[:top_k]:
results.append({
"doc_id": doc_id,
"content": self.documents[doc_id][:200] + "...",
"fusion_score": round(final_score, 4),
"bm25_score": round(bm25_sc, 3),
"vector_similarity": round(vec_sc, 4),
"match_type": "both" if doc_id in bm25_scores and doc_id in vector_scores
else ("keyword" if doc_id in bm25_scores else "semantic")
})
return results
# 使用示例
if __name__ == "__main__":
docs = [
"Ollama 通过 OLLAMA_HOST 环境变量配置监听地址,默认为 localhost:11434。",
"设置 OLLAMA_HOST=0.0.0.0:11434 可以允许局域网内其他设备访问 Ollama 服务。",
"Ollama 是一个在本地运行大语言模型的工具,支持 LLaMA、Qwen、Mistral 等模型。",
"使用 ollama serve 命令启动 API 服务,默认端口为 11434。",
"环境变量 OLLAMA_MODELS 用于指定模型存储目录,默认路径是 ~/.ollama/models/。",
"Docker 部署时通过 -p 参数映射端口: docker run -p 11434:11434 ollama/ollama"
]
retriever = HybridRetriever()
retriever.add_documents(docs)
queries = [
"OLLAMA_HOST 怎么设置",
"如何配置端口",
"Ollama 默认端口号是多少"
]
for q in queries:
print(f"\n{'='*60}")
print(f"🔍 查询: {q}")
print(f"{'='*60}")
results = retriever.search(q, top_k=3)
for r in results:
type_icon = {"both": "🔗", "keyword": "🔑", "semantic": "🧠"}
print(f"\n [{type_icon[r['match_type']}] "
f"融合={r['fusion_score']:.3f} | "
f"BM25={r['bm25_score']:.2f} | "
f"Vec={r['vector_similarity']:.3f}")
print(f" {r['content']}")运行效果对比:
查询: "OLLAMA_HOST 怎么设置"
纯向量检索 Top-1:
[0.7823] Ollama 是一个在本地运行大语言模型的工具... (❌ 不够精准)
混合检索 Top-1:
🔗 融合=0.891 | BM25=8.23 | Vec=0.754
设置 OLLAMA_HOST=0.0.0.0:11434 可以允许局域网内其他设备访问... (✅ 完美命中)技术二:重排序(Reranking)
原理
重排序是在初始检索(Recall)之后增加的一个**精排(Precision)**阶段:
┌──────────────────────────────────────────────────────┐
│ 重排序流程 │
│ │
│ 用户查询 │
│ │ │
│ ▼ │
│ [粗检 Stage 1] 检索 Top-20 │
│ │ (快速但粗糙: 可能混入不相关结果) │
│ ▼ │
│ 候选集: [相关✅ 相关✅ 不太相关⚠️ 相关✅ │
│ 不相关❌ 相关✅ 不太相关⚠️ ...] │
│ │ │
│ ▼ │
│ [精排 Stage 2] Cross-Encoder 重排序 │
│ │ (慢但精确: 对每个(查询,文档)对重新打分) │
│ ▼ │
│ 最终结果: [相关✅ 相关✅ 相关✅ 相关✅ 相关✅] │
│ (不相关的已被排到后面去) │
│ │
│ 核心价值: 用少量额外延迟换取大幅提升精度 │
└──────────────────────────────────────────────────────┘用 Ollama 小模型做重排序
python
def rerank_with_llm(query, candidates, llm_model="qwen2.5:1.5b"):
"""
使用小型 LLM 作为 Reranker
Args:
query: 用户查询
candidates: 候选文档列表 [{content, score}, ...]
llm_model: 用于打分的小模型(越小越快)
Returns:
重排序后的候选列表
"""
reranked = []
for candidate in candidates:
content = candidate["content"]
prompt = f"""请判断以下参考文档与用户问题的相关性。
只输出一个数字(0-10),不要输出任何其他内容。
用户问题: {query}
参考文档: {content[:500]}"""
try:
resp = requests.post(
"http://localhost:11434/api/chat",
json={
"model": llm_model,
"messages": [{"role": "user", "content": prompt}],
"options": {"temperature": 0.01, "num_predict": 5}
},
timeout=30
)
score_text = resp.json()["message"]["content"].strip()
import re
numbers = re.findall(r'[\d.]+', score_text)
rerank_score = float(numbers[0]) if numbers else 5.0
except Exception:
rerank_score = 5.0 # 出错给中等分数
reranked.append({
**candidate,
"rerank_score": rerank_score,
"original_rank": candidate.get("rank", 0)
})
reranked.sort(key=lambda x: x["rerank_score"], reverse=True)
for i, r in enumerate(reranked):
r["final_rank"] = i + 1
return reranked技术三:父-子分块(Parent-Child Retrieval)
问题
标准分块策略把长文档切成固定大小的小块。问题在于:小块缺乏完整上下文。
原文(一段关于 Docker 部署的说明):
"Ollama 支持 Docker 部署。首先创建 docker-compose.yml 文件,
配置端口映射 11434 和 GPU 直通参数。然后执行 docker compose up -d
启动服务。如果需要持久化模型文件,记得挂载 volume 到 ~/.ollama/ 目录。
最后可以通过 curl http://localhost:11434/api/tags 验证服务是否正常运行。"
标准分块后:
块 1: "Ollama 支持 Docker 部署。首先创建 docker-compose.yml 文件,
配置端口映射 11434 和 GPU 直通参数。" ← 缺少后续步骤
块 2: "然后执行 docker compose up -d 启动服务。如果需要持久化模型文件,
记得挂载 volume 到 ~/.ollama/ 目录。" ← 缺少前因后果
块 3: "最后可以通过 curl http://localhost:11434/api/tags 验证服务是否正常
运行。" ← 缺少完整上下文当用户问"Docker 部署后怎么验证是否成功?"时,块 3 被检索到了,但它只有一句孤立的话,没有提到这是整个部署流程的最后一步。
解决方案
父-子分块策略:
原始文档 (父块 Parent)
├── 子块 Child 1 (用于检索)
├── 子块 Child 2 (用于检索)
├── 子块 Child 3 (用于检索)
└── ...
检索流程:
1. 用户提问 → 在子块中检索 → 找到最相关的子块
2. 通过子块的 parent_id 回溯到对应的父块
3. 将整个父块作为上下文送给 LLM
→ LLM 获得了完整的上下文信息!python
class ParentChildChunker:
"""父-子分块器"""
def __init__(self, parent_size=2048, child_size=512, overlap=64):
self.parent_size = parent_size
self.child_size = child_size
self.overlap = overlap
def chunk(self, text, metadata=None):
"""生成父子分块结构"""
metadata = metadata or {}
# Step 1: 先分成较大的父块
parent_chunks = self._split(text, self.parent_size)
result = []
for p_idx, parent_text in enumerate(parent_chunks):
parent_id = f"parent_{p_idx}"
# Step 2: 将每个父块再分成更小的子块
child_chunks = self._split(parent_text, self.child_size)
for c_idx, child_text in enumerate(child_chunks):
result.append({
"content": child_text,
"metadata": {
**metadata,
"parent_id": parent_id,
"child_id": f"{parent_id}_child_{c_idx}",
"is_parent": False
}
})
# 同时保留父块本身
result.append({
"content": parent_text,
"metadata": {
**metadata,
"parent_id": parent_id,
"child_id": parent_id,
"is_parent": True
}
})
return result
def _split(self, text, size):
"""按大小分割文本"""
chunks = []
start = 0
while start < len(text):
end = min(start + size, len(text))
if end < len(text):
last_period = text.rfind("。", start, end)
last_newline = text.rfind("\n", start, end)
cut_pos = max(last_period, last_newline)
if cut_pos > start + size // 2:
end = cut_pos + 1
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start = end - self.overlap
if start < 0:
start = 0
return chunks技术四:增量索引
问题
每次有新文档都要对所有文档重新做 Embedding 并重建索引?这在文档量增长后是不可接受的。
解决方案:只处理新增/变更的文档
python
class IncrementalRAG:
"""增量索引 RAG"""
def __init__(self, persist_dir="./rag_data"):
self.persist_dir = Path(persist_dir)
self.processed_files_path = self.persist_dir / "processed_files.json"
self.processed_files = self._load_processed_list()
def _load_processed_list(self):
"""加载已处理文件列表及其哈希值"""
if self.processed_files_path.exists():
with open(self.processed_files_path) as f:
return json.load(f)
return {}
def _save_processed_list(self):
"""保存已处理文件列表"""
with open(self.processed_files_path, "w") as f:
json.dump(self.processed_files, f, indent=2)
def _file_hash(self, file_path):
"""计算文件的 MD5 哈希"""
import hashlib
md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
md5.update(chunk)
return md5.hexdigest()
def sync(self, document_dir):
"""
同步文档目录:
- 新增的文件 → 处理并加入索引
- 变更的文件 → 删除旧数据,重新处理
- 删除的文件 → 从索引移除
- 未变的文件 → 跳过
"""
directory = Path(document_dir)
current_files = {f.name: str(f) for f in directory.iterdir()
if f.is_file() and f.suffix in {".md", ".txt", ".pdf"}}
to_process = []
to_remove = []
# 检查新增和变更
for filename, filepath in current_files.items():
current_hash = self._file_hash(filepath)
if filename not in self.processed_files:
# 新文件
to_process.append(filepath)
print(f" 🆕 新增: {filename}")
elif self.processed_files[filename]["hash"] != current_hash:
# 文件变更
to_remove.append(filename)
to_process.append(filepath)
print(f" 🔄 变更: {filename}")
# 检查删除
for filename in list(self.processed_files.keys()):
if filename not in current_files:
to_remove.append(filename)
print(f" 🗑️ 已删除: {filename}")
# 执行变更
for filename in to_remove:
self._remove_from_index(filename)
del self.processed_files[filename]
for filepath in to_process:
filename = Path(filepath).name
file_hash = self._file_hash(filepath)
self._add_to_index(filepath)
self.processed_files[filename] = {
"hash": file_hash,
"path": filepath,
"processed_at": __import__("datetime").datetime.now().isoformat()
}
self._save_processed_list()
print(f"\n✅ 同步完成: 新增/变更 {len(to_process)} 个, "
f"删除 {len(to_remove)} 个")
def _add_to_index(self, filepath):
"""将文件添加到索引(具体实现取决于你的向量库选择)"""
pass # 使用上一节的 LocalRAG.ingest() 方法
def _remove_from_index(self, filename):
"""从索引中移除文件的所有块"""
pass # ChromaDB: collection.delete(where={"source": filename})高级技术组合效果
| 场景 | 基础 RAG | +混合检索 | +重排序 | +父子分块 | +增量索引 |
|---|---|---|---|---|---|
| 关键词查询准确率 | 65% | 92% | 94% | 92% | 92% |
| 语义查询准确率 | 85% | 87% | 93% | 88% | 93% |
| 上下文完整性 | 60% | 62% | 64% | 95% | 95% |
| 大规模文档更新速度 | 慢 | 慢 | 慢 | 慢 | 快 |
本章小结
这一节介绍了四种将 RAG 从"能用"提升到"好用"的高级技术:
- 混合检索(Hybrid Retrieval):BM25 关键词匹配 + 向量语义检索的双路召回,解决专有名词漏召问题
- 重排序(Reranking):用小型 LLM 或专用 Cross-Encoder 对初筛结果精排,显著提升 Precision
- 父-子分块(Parent-Child Chunking):小块检索 + 大块返回,保证 LLM 获得完整上下文
- 增量索引(Incremental Indexing):基于文件哈希的变更检测,只处理新增/修改的文档
这些技术可以自由组合使用——根据你的具体场景选择最适合的技术栈。
至此,第六章"Embedding 模型与 RAG"全部完成。下一节我们将进入 LangChain 和 LlamaIndex 框架集成领域。