07 Chroma_RAG流水线:从Demo到生产级架构
💡 一句话核心概念
RAG(检索增强生成)= Chroma 负责"找到答案的线索" + Qwen 负责"把线索变成人话"。一个 30 行的 Demo 谁都会写,但生产级的 RAG 需要处理分块策略、检索质量、幻觉防控------差一个环节,你的 AI 就从"聪明助手"变成"自信的胡说八道机器"。
🧩 关键实操
1. 从零搭建 RAG 最小可行原型(MVP)
bash
# 先装依赖
uv add chromadb openai tiktoken
python
# 07_rag_mvp.py ------ 30 行代码跑通 RAG
from chromadb import Client
from openai import OpenAI
import os
# ===== 0. 准备:Qwen 客户端(Embedding + 生成都用它) =====
qwen = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
# ===== 1. 知识库:把文档塞进 Chroma =====
from chromadb.utils.embedding_functions import OpenAIClientEmbeddingFunction
# ↑ Chroma 0.5.x 内置了对 OpenAI 兼容 API 的 embedding 支持!
embed_fn = OpenAIClientEmbeddingFunction(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model_name="text-embedding-v4",
)
client = Client()
collection = client.get_or_create_collection(
name="rag_demo",
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"},
)
# 知识库文档------假装你有一堆技术文档
knowledge = [
"Chroma 的 HNSW 索引参数:M=16 控制每层连接数,ef_construction=100 控制构建时的搜索深度。",
"Qwen2.5-7B-Instruct 支持 32K 上下文窗口,做 RAG 时建议检索 top-5 文档拼接。",
"RAG 流水线的核心瓶颈在 Embedding 速度和 LLM 推理延迟,不在向量检索本身。",
"分块(Chunking)策略:代码类 500-1000 tokens,文档类 1000-1500 tokens,QA 类 300-500 tokens。",
]
collection.add(documents=knowledge, ids=[f"doc_{i}" for i in range(len(knowledge))])
# ===== 2. 检索:用户问题 → 找相关文档 =====
def retrieve(query: str, top_k: int = 3) -> list[str]:
results = collection.query(query_texts=[query], n_results=top_k)
return results["documents"][0]
# ===== 3. 生成:拼接 Prompt → Qwen 生成答案 =====
def generate(query: str, context_docs: list[str]) -> str:
context = "\n\n".join(f"【参考资料 {i+1}】\n{doc}" for i, doc in enumerate(context_docs))
prompt = f"""你是一个技术助手。请严格基于以下参考资料回答问题。如果资料中没有相关信息,请直接说"我无法从现有资料中找到答案"。
{context}
【用户问题】
{query}
【回答】"""
response = qwen.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=[{"role": "user", "content": prompt}],
temperature=0.3, # RAG 场景温度要低,减少幻觉
max_tokens=500,
)
return response.choices[0].message.content
# ===== 4. 测试 =====
question = "Chroma 的 HNSW 参数怎么调?"
docs = retrieve(question)
answer = generate(question, docs)
print(f"❓ 问题:{question}\n")
print(f"📚 检索到 {len(docs)} 篇相关文档:")
for i, doc in enumerate(docs):
print(f" [{i+1}] {doc[:80]}...")
print(f"\n🤖 回答:\n{answer}")
bash
uv run python 07_rag_mvp.py
恭喜,你已经跑通了 RAG 的完整链路!但这只是 Demo------下面才是真正能上生产的架构。
2. 生产级 RAG:分块策略 + 重排序 + 幻觉防控
python
# 07_rag_production.py ------ 从 Demo 到生产的关键三步
from chromadb import PersistentClient
from chromadb.utils.embedding_functions import OpenAIClientEmbeddingFunction
from openai import OpenAI
import os, re
qwen = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
embed_fn = OpenAIClientEmbeddingFunction(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model_name="text-embedding-v4",
)
# ===== 生产级配置:PersistentClient + 合理命名 =====
client = PersistentClient(path="./rag_production_data")
collection = client.get_or_create_collection(
name="production_rag",
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"},
)
# ===== 第一步:智能分块(Chunking) =====
def chunk_document(text: str, chunk_size: int = 800, overlap: int = 150) -> list[dict]:
"""
分块三原则:
1. 按自然段落边界切分(别在句子中间切断)
2. overlap 保证上下文连贯性
3. 每块带上位置信息(追溯原文)
"""
paragraphs = text.split("\n\n")
chunks = []
current_chunk = ""
chunk_idx = 0
for para in paragraphs:
para = para.strip()
if not para:
continue
# 当前块 + 新段落不超过上限就直接拼
if len(current_chunk) + len(para) <= chunk_size:
current_chunk += para + "\n\n"
else:
# 当前块满了,保存
if current_chunk:
chunks.append({
"text": current_chunk.strip(),
"chunk_index": chunk_idx,
"char_start": sum(len(c["text"]) for c in chunks), # 大概位置
})
chunk_idx += 1
# 新块带 overlap:从上一块的结尾取 overlap 个字符
overlap_text = current_chunk[-overlap:] if len(current_chunk) > overlap else ""
current_chunk = overlap_text + para + "\n\n"
if current_chunk.strip():
chunks.append({"text": current_chunk.strip(), "chunk_index": chunk_idx})
return chunks
# 模拟一篇长文档
long_doc = """
# Chroma 性能优化指南
## 1. HNSW 索引参数调优
HNSW(Hierarchical Navigable Small World)是 Chroma 使用的向量索引算法。它的核心参数有两个:M 和 ef_construction。
### M 参数
M 控制 HNSW 图中每个节点的最大连接数。默认值是 16。增大 M 会提高召回率但增加内存消耗。对于 100 万条以内的向量数据,M=32 是一个不错的平衡点。超过 500 万条时建议 M=16 以控制内存。
### ef_construction 参数
ef_construction 控制索引构建时的搜索深度。默认值是 100。增大这个值会让索引构建变慢但提高查询质量。生产环境建议 200-400。
## 2. 批量写入优化
单条 add 和批量 add 的性能差距是指数级的。Chroma 内部每次 add 都会触发 HNSW 索引的部分重建,批量 add 可以减少重建次数。
建议每 500-1000 条文档批量提交一次。超过 10000 条建议分批 + 进度条。
## 3. 查询优化
query 的 n_results 参数不要设太大。大多数 RAG 场景 top-5 就够了,设 20 只会拖慢速度且把噪音带进 LLM。
"""
# 分块并写入 Chroma
chunks = chunk_document(long_doc, chunk_size=600, overlap=100)
for i, chunk in enumerate(chunks):
collection.add(
documents=[chunk["text"]],
metadatas=[{"chunk_index": chunk["chunk_index"], "content_type": "technical_doc"}],
ids=[f"perf_guide_chunk_{i}"],
)
print(f"✅ 长文档被切成 {len(chunks)} 块,已写入 Chroma")
# ===== 第二步:检索 + 重排序(Re-ranking) =====
def retrieve_with_rerank(query: str, top_k: int = 10, final_k: int = 5) -> list[str]:
"""
两阶段检索:
Stage 1: 向量检索召回到 top_k 个候选
Stage 2: 用 LLM 做 Cross-encoder 重排序(或用专门的 reranker 模型)
"""
# Stage 1:粗筛
results = collection.query(query_texts=[query], n_results=top_k)
candidates = list(zip(results["documents"][0], results["distances"][0]))
# Stage 2:精排------用 Qwen 打分
scored = []
for doc, dist in candidates:
score_prompt = f"""请评估以下文档与问题的相关性,只返回 0-100 的分数,不要解释。
问题:{query}
文档:{doc[:500]}
相关性分数(0-100):"""
response = qwen.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=[{"role": "user", "content": score_prompt}],
temperature=0,
max_tokens=10,
)
try:
score = int(response.choices[0].message.content.strip())
except ValueError:
score = 50 # 解析失败给个及格分
scored.append((doc, dist, score))
# 按 LLM 评分排序,取 final_k 个
scored.sort(key=lambda x: x[2], reverse=True)
return [doc for doc, _, _ in scored[:final_k]]
# ===== 第三步:幻觉防控生成 =====
def generate_with_guardrails(query: str, context_docs: list[str]) -> dict:
"""
幻觉防控三板斧:
1. 强制引用来源
2. 明确"不知道"的边界
3. 返回置信度标记
"""
context = "\n\n".join(
f"[来源{i+1}] {doc}" for i, doc in enumerate(context_docs)
)
prompt = f"""你是一个严谨的技术助手。请严格遵循以下规则:
规则1:只能使用下方【参考资料】中的信息回答问题。
规则2:如果资料不足以回答问题,请回答:"⚠️ 现有资料不足以回答此问题。建议补充相关文档。"
规则3:回答时必须在关键信息处标注来源编号,如[来源1]。
【参考资料】
{context}
【用户问题】{query}
【回答(请标注来源)】"""
response = qwen.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=800,
)
return {
"answer": response.choices[0].message.content,
"sources": [doc[:100] + "..." for doc in context_docs],
"model": "qwen2.5-7b-instruct",
}
# ===== 测试完整流水线 =====
question = "HNSW 的 ef_construction 参数在生产环境应该设多少?"
docs = retrieve_with_rerank(question)
result = generate_with_guardrails(question, docs)
print(f"\n{'='*50}")
print(f"❓ {question}")
print(f"\n📚 检索+重排后 Top-3 文档:")
for i, doc in enumerate(docs[:3]):
print(f" [{i+1}] {doc[:120]}...")
print(f"\n🤖 最终回答:\n{result['answer']}")
bash
uv run python 07_rag_production.py
3. RAG 流水线架构全景图
┌─────────────────────────────────────────────────────────┐
│ RAG 生产级架构 │
├───────────┬───────────┬───────────┬─────────────────────┤
│ 文档摄入 │ 向量化 │ 检索 │ 生成 │
├───────────┼───────────┼───────────┼─────────────────────┤
│ 原始文档 │ Qwen Emb │ 用户问题 │ Prompt 模板 │
│ ↓ │ ↓ │ ↓ │ ↓ │
│ 智能分块 │ 批量 API │ Qwen Emb │ 拼接上下文 │
│ (overlap) │ (25条/次) │ ↓ │ ↓ │
│ ↓ │ ↓ │ Chroma检索 │ Qwen生成 │
│ 元数据提取 │ 写入Chroma│ ↓ │ ↓ │
│ (来源/时间)│ │ 重排序 │ 后处理(去幻觉/引用) │
│ ↓ │ │ (Rerank) │ ↓ │
│ 去重/清洗 │ │ ↓ │ 返回给用户 │
│ │ │ Top-K文档 │ │
└───────────┴───────────┴───────────┴─────────────────────┘
🚧 避坑指南
| 坑 | 现象 | 解法 |
|---|---|---|
| 分块太大 | 检索召回的内容包含大量无关信息,LLM 生成"跑偏" | 文本类 800-1200 tokens,对话类 300-500 tokens。块越大检索精度越低,越小上下文越碎片化 |
| 忘了去重 | 同一个知识点被不同文档反复检索到,Prompt 里一堆重复内容 | 写入前做文档去重(MinHash + 向量相似度双重检测),检索后按相似度去重 |
| 温度太高 | LLM 开始"创作"而不是"引用",幻觉满天飞 | RAG 的 temperature 设为 0.0-0.3,不要超过 0.5。你不需要它发挥创意,你只需要它忠实复述 |
| 检索结果太多 | n_results=20,Prompt 长度爆炸,LLM 注意了分散 | 绝大多数场景 top-3 到 top-5 就够了。先检索再多不如检索少而精 + Rerank |
🎤 Chroma 面试题与通关答案
Q1:RAG 系统中,为什么"检索"的质量直接决定了最终回答的质量?如果检索到的文档都不相关怎么办?
考点拆解: RAG 系统的核心瓶颈分析,Garbage In Garbage Out 在 LLM 时代的体现。
通关答案:
RAG 的本质是"让 LLM 开卷考试"------卷子给错了,再聪明的学生也答不对。
用户问题 → Embedding → Chroma 检索 → Top-K 文档 → LLM 生成 → 回答
↑
这里是瓶颈!!!
为什么检索是瓶颈?
- LLM 本身已经很强了: Qwen2.5-7B 在大多数基础问题上的回答已经很准确。RAG 的价值在于给它提供"它不知道的外部知识"。
- LLM 无法判断检索质量: LLM 默认信任你给它的上下文。如果你检索到了不相干的文档,LLM 大概率会基于它们"强行编造"一个答案------这就是 RAG 幻觉的根源。
- 检索错误不可恢复: 生成环节没有"质疑上下文"的能力,你喂什么它吃什么。
防御策略:
python
# 策略1:召回率优先,精排在后
candidates = collection.query(query_texts=[q], n_results=20) # 粗筛多拿
top_docs = rerank_with_llm(candidates, q, final_k=5) # 精排筛选
# 策略2:相关性阈值------不相关的直接拦截
if min_distance > 0.5: # cosine 距离阈值
return "⚠️ 未在知识库中找到相关信息"
# 策略3:多路召回(Hybrid Retrieval)
# 向量检索 + 关键词检索(BM25)的结果合并去重,互补优势
一句话总结: RAG 的质量天花板在你把文档塞进 Chroma 的那一刻就决定了。检索是瓶颈,生成是放大器------好检索配弱模型还能用,烂检索配强模型只会造出更逼真的幻觉。
Q2:什么是 RAG 的分块(Chunking)策略?固定大小分块和语义分块有什么区别?
考点拆解: 知识库构建的前置处理能力,考察对文本预处理工程的理解。
通关答案:
分块 = 把长文档切成"语义完整的小段",每段单独向量化。 不分块直接塞整本《三体》进去,检索时会因为向量被稀释到 20 万个 token 里而完全失效。
三种主流分块策略:
| 策略 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定大小 | 每 N 个字符/token 一刀切 | 简单、快 | 频繁在句子中间切断,语义破碎 | 原型验证 |
| 递归分割 | 优先按段落→句子→词组拆,尽量在自然边界切 | 语义完整性好 | 块大小不均匀 | 通用推荐 |
| 语义分块 | 用 Embedding 模型检测语义转折点然后切 | 块内语义最连贯 | 慢、依赖 Embedding 质量 | 高质量知识库 |
实战参数建议:
代码文档: 500-800 tokens, overlap=100
技术文档: 800-1200 tokens, overlap=150
对话记录: 300-500 tokens, overlap=50
长篇小说: 1500-2000 tokens, overlap=200
为什么需要 overlap?
假设一段知识恰好被切成两半:"HNSW 的核心参数是 M 和"(块A)"ef_construction"(块B)。没有 overlap 的话,搜"HNSW 参数"只能命中一块,另一半知识就丢了。overlap 保证跨越切分边界的信息不丢失。
python
# 简单但有效的递归分块器
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n\n", "\n", "。", ".", " ", ""], # 按优先级尝试切分
)
chunks = splitter.split_text(long_document)
一句话总结: 分块是 RAG 最被低估的环节。切太大检索不准,切太小上下文破碎------800 tokens + 150 overlap 是社区验证的黄金起点。
Q3:RAG 系统中如何防止 LLM "编造事实"(幻觉)?有哪些工程手段?
考点拆解: LLM 应用的可靠性工程,面试官想看你有没有"AI 安全"意识。
通关答案:
幻觉防控 = Prompt 约束 + 检索质量 + 后处理校验,三管齐下。
第一层:Prompt 硬约束
python
# 在 Prompt 中设置"护栏"
system_prompt = """你是一个只能基于参考资料回答的助手。
规则:
1. 每个论断必须引用 [来源X]
2. 如果资料不足以回答,必须说"我无法回答"
3. 不要添加任何超出参考资料范围的知识
"""
# ⚠️ 但是!Prompt 约束不是 100% 可靠,它只是"请"LLM 别编造
# LLM 仍然可能在自信地胡说八道的同时完美遵守 Prompt 格式
第二层:检索质量保证
python
# 相关性阈值 + 多样性去重
def robust_retrieve(query, threshold=0.4):
results = collection.query(query_texts=[query], n_results=10)
# 过滤低相关文档
filtered = [
(doc, dist) for doc, dist in zip(results["documents"][0], results["distances"][0])
if dist < threshold
]
if not filtered:
return [] # 返回空列表 → 触发"无法回答"
return [doc for doc, _ in filtered[:5]]
第三层:后处理校验(最硬核)
python
# 用 NLI(自然语言推理)模型校验"回答是否被上下文支持"
# 简单版:让 LLM 自检
def self_check(answer: str, context_docs: list[str]):
check_prompt = f"""对于以下回答,逐句检查是否被参考资料支持。
对于每一句,返回 [支持/部分支持/不支持]。
回答:{answer}
参考资料:{context_docs}
逐句分析:"""
response = qwen.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=[{"role": "user", "content": check_prompt}],
temperature=0,
)
return response.choices[0].message.content
工程实践总结:
幻觉防控优先级(由低到高):
Prompt 约束 ← 成本最低,效果最弱(必须做,但不能只做这个)
→ 检索质量提升 ← 治本之策,检索对了幻觉减半
→ 后处理校验 ← 兜底方案,成本最高但最可靠
→ 人工审核 ← 关键场景的最后防线
一句话总结: 防止幻觉不能靠"相信 LLM 听话"------Prompt 约束 + 检索阈值 + 后处理自检,三道防线缺一道都可能在关键时刻翻车。