不重复第6章:只讲“怎么检索”以及如何把检索做准、做稳、做快
第6章你已经完成了 RAG 的“离线准备”(加载、分割、向量化、入库)。本章直接从查询阶段切入:
给定一个已存在的向量库(Chroma 的 collection),如何把检索这一步做得更相关、更可控、更可解释。
collection.query(...))RAG 可以拆成两段: 离线入库(第6章)与在线问答(本章 + 下一章)。 你现在已经完成了离线入库,所以本章重点是:在线阶段的“检索”如何做对。
在线阶段不要把它想成“检索一下就结束”。一条高质量链路通常长这样: 改写 → 召回 → 过滤 → 重排序 → 组装上下文 → 生成。
很多人学 RAG 会卡在一句话:“检索 + 生成”。这没错,但太粗。 你可以把 RAG 的能力分成 5 代:从“拿到相关片段”到“系统能自己做计划、自己用工具、自己纠错”。 下面我用图 + 生活例子把每一代讲清楚。
核心特征:TopK 相似度检索拿到若干 chunk,直接拼到 prompt 里让模型回答。 优点是简单;缺点是“能跑但经常不准”,尤其遇到:口径不一致、chunk 冗余、召回噪声。
核心特征:不是只做 TopK,而是把“检索前/检索中/检索后”都变成可控环节。 目标是:更准(相关性)、更稳(可控)、更快(少走弯路)。
核心特征:把 RAG 拆成一组可替换模块,每个模块单测、可观测、可灰度。 你不再“写死一条链”,而是“按任务拼装链”。
核心特征:不仅“找相似文本”,还要“沿着实体与关系找证据”。 适合:人物关系、业务流程、因果链、依赖关系、跨文档引用。
核心特征:系统不只“执行一条固定检索链”,而是会先做计划, 再选择工具与检索策略,必要时多轮迭代,最后做自检/引用校验。
检索(Retrieval)的目标不是“把所有相关内容都拿出来”,而是: 在有限的上下文窗口内,挑出最可能帮助模型回答问题的一小撮证据片段。
Baseline 做法:把 query 向量化,然后在向量库中找 Top-K 最近邻。 这一步通常没问题,问题往往出在:Top-K 里信息重复、覆盖不全、或混入噪声。
import chromadb
# 目标:本节重点讲“query 怎么查”。embedding 用 Chroma 默认的就够了(更合理也更简单)。
# 注意:默认 embedding 可能会在第一次运行时下载模型/依赖,所以首次执行可能较慢。
# ✅ 关键改动:用持久化库 + 只初始化一次数据
# 否则你后面的 3️⃣/4️⃣/… 节再复用 demo_retrieval 时,经常会因为“数据只在内存里”而查到空。
DB_PATH = "./chroma_demo_retrieval"
def ensure_demo_data(collection):
"""只在 collection 为空时写入演示数据(可重复运行、可跨脚本复用)"""
try:
if collection.count() > 0:
return
except Exception:
pass
documents = [
# account
"重置密码:进入设置-账号安全-选择重置方式",
"忘记密码:通过手机号验证码或邮箱验证码找回",
"登录邮箱修改:先验证原邮箱,再绑定新邮箱",
"手机号变更:需要人脸/短信二次验证,防止盗号",
"账号异常登录:先检查异地登录记录,再修改密码",
# security
"开启两步验证:在账号安全中打开 2FA,提高安全性",
"安全建议:使用强密码,避免重复使用同一个密码",
# refund
"退款流程:提交申请-审核-原路退回",
"退款到账时间:通常 1-3 个工作日,视支付渠道而定",
"退款失败:检查支付账号状态,或联系人工客服处理",
# order/billing
"订单查询:在订单中心按时间筛选,支持导出账单",
"发票开具:填写抬头与税号,电子发票发送到邮箱",
"扣费异常:先核对账单明细,再提交工单核查",
"订阅取消:到订阅管理里取消,取消后本周期仍可使用",
# errors
"错误码 401:通常是 token 过期或未登录",
"错误码 403:通常是权限不足或鉴权失败",
"错误码 429:请求过快被限流,建议退避重试",
"网络超时:检查代理/网络,或提高 timeout 并重试",
# misc
"上传失败:检查文件大小限制与格式要求",
"账号注销:注销前请确认余额与未完成订单",
]
metadatas = [
{"doc_id": "acc_reset_pwd", "category": "account"},
{"doc_id": "acc_forgot_pwd", "category": "account"},
{"doc_id": "acc_change_email", "category": "account"},
{"doc_id": "acc_change_phone", "category": "account"},
{"doc_id": "acc_risk_login", "category": "account"},
{"doc_id": "sec_2fa", "category": "security"},
{"doc_id": "sec_password", "category": "security"},
{"doc_id": "refund_process", "category": "refund"},
{"doc_id": "refund_time", "category": "refund"},
{"doc_id": "refund_fail", "category": "refund"},
{"doc_id": "order_query", "category": "order"},
{"doc_id": "billing_invoice", "category": "billing"},
{"doc_id": "billing_charge", "category": "billing"},
{"doc_id": "sub_cancel", "category": "billing"},
{"doc_id": "err_401", "category": "error"},
{"doc_id": "err_403", "category": "error"},
{"doc_id": "err_429", "category": "error"},
{"doc_id": "err_timeout", "category": "error"},
{"doc_id": "upload_fail", "category": "misc"},
{"doc_id": "acc_close", "category": "account"},
]
ids = [str(i) for i in range(1, len(documents) + 1)]
collection.add(documents=documents, metadatas=metadatas, ids=ids)
# 1) 初始化:创建持久化 client/collection,并确保有演示数据
client = chromadb.PersistentClient(path=DB_PATH)
collection = client.get_or_create_collection("demo_retrieval")
ensure_demo_data(collection)
# 2) 核心:Top-K 检索(n_results 就是 K)
query = "用户如何重置密码?"
res = collection.query(
query_texts=[query],
n_results=3,
include=["documents", "metadatas", "distances"],
)
# 3) 返回结构是“按 query 分组”的二维列表,所以这里用 [0]
docs = res["documents"][0]
metas = res["metadatas"][0]
dists = res["distances"][0]
for i, (text, meta, dist) in enumerate(zip(docs, metas, dists), start=1):
score = 1 - dist # Chroma 默认距离越小越相关,转为相似度分数(0~1)
print(i, meta, "distance=", dist, "score=", f"{score:.3f}")
print(text)include=[...] 还能返回哪些字段?
documents:返回命中的文本内容(你要喂给大模型的“证据”)metadatas:返回每条文本的元数据(例如 doc_id/category/version,用于过滤、追溯来源)distances:(仅 query 支持)返回距离/相似度分数(不同库/配置方向不同;Chroma 常见是 distance 越小越相关)embeddings:返回向量本身(通常很大、很耗带宽/内存,除非你要做调试/可视化/离线分析,否则一般不需要)embeddings(因为太大)。你需要时再在 include 里显式加上。
query_texts 可以一次传多个问题,所以返回值按 query 分组,像 res["documents"][0] 表示“第 1 个 query 的命中文本列表”。
ids 通常会作为结果的一部分返回(不需要放进 include)。你用 include 主要是在控制“额外返回哪些大字段”。
很多 RAG 失败的原因并不是“检索不到”,而是“检索到了不相关的内容也照样塞给模型”。 解决办法:设置阈值(score threshold)与 fallback。
import chromadb
DB_PATH = "./chroma_demo_retrieval"
def ensure_demo_data(collection):
try:
if collection.count() > 0:
return
except Exception:
pass
raise RuntimeError("demo_retrieval 为空:请先运行 2️⃣ Baseline 的初始化示例(会写入演示数据)")
client = chromadb.PersistentClient(path=DB_PATH)
# 承接上一节:复用同一个持久化库里的 demo_retrieval 数据
collection = client.get_or_create_collection("demo_retrieval")
ensure_demo_data(collection)
# 现实情况:Top-K 返回的是“最像的 K 条”,但后几条经常匹配度不够(也会被塞进 prompt)
query = "如何申请退款?"
res = collection.query(
query_texts=[query],
n_results=4,
include=["documents", "metadatas", "distances"],
)
docs = res["documents"][0]
metas = res["metadatas"][0]
dists = res["distances"][0]
print("=== 原始 Top-K(里面可能混入低匹配内容)===")
for t, m, d in zip(docs, metas, dists):
print(m, "distance=", d)
print(t)
# 核心:对返回结果做阈值过滤(distance 越小越相关)
threshold_dist = 0.45 # 示例阈值:需要你根据真实数据调
filtered = [(t, m, d) for t, m, d in zip(docs, metas, dists) if d is not None and d <= threshold_dist]
print("\n=== 阈值过滤后(只保留足够相关的证据)===")
if not filtered:
print("证据不足:触发澄清/转人工/或直接声明‘知识库证据不足’")
else:
for t, m, d in filtered:
print(m, "distance=", d)
print(t)过滤(Filter)是企业 RAG 必须具备的能力: 同一个向量库里可能有不同业务线、不同版本、不同权限的数据。 如果不做过滤,检索结果就会“混”。
import chromadb
DB_PATH = "./chroma_demo_retrieval"
def ensure_demo_data(collection):
try:
if collection.count() > 0:
return
except Exception:
pass
raise RuntimeError("demo_retrieval 为空:请先运行 2️⃣ Baseline 的初始化示例(会写入演示数据)")
client = chromadb.PersistentClient(path=DB_PATH)
# 承接上面:复用同一个持久化库里的 demo_retrieval 数据
collection = client.get_or_create_collection("demo_retrieval")
ensure_demo_data(collection)
# 核心:where 只让“满足条件”的数据参与检索(先过滤再相似度排序)
query = "如何修改登录邮箱?"
res = collection.query(
query_texts=[query],
n_results=3,
where={"category": "account"},
include=["documents", "metadatas"],
)
print(res["documents"][0])
print(res["metadatas"][0])用户的问题可能:
解决办法:在检索前用 LLM 做 query rewrite / multi-query。
doc_id 去重;同一个 doc_id 命中多次时保留最小 distance你是“知识库检索查询改写器”。
目标:把用户问题改写成适合检索的查询语句。
要求:
1) 只输出 JSON 数组,不要解释。
2) 输出 3 条以内,每条不超过 25 个字。
3) 每条尽量包含:关键实体/错误提示/目标动作。
4) 不要编造不存在的产品名/错误码。
用户问题:{user_question}
输出示例:
["账号异常 登录失败 如何处理", "登录提示账号异常 排查步骤", "错误码/提示语 账号异常 解决"]import chromadb
import json
import os
import dashscope
from dashscope import Generation
# 目标:把“口语问题”改写成更像“搜索词”的 query,再用多 query 检索扩大召回覆盖
DB_PATH = "./chroma_demo_retrieval"
# 通义千问(DashScope)
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY")
if not DASHSCOPE_API_KEY:
raise RuntimeError("环境变量 DASHSCOPE_API_KEY 未设置")
dashscope.api_key = DASHSCOPE_API_KEY
def ensure_demo_data(collection):
try:
if collection.count() > 0:
return
except Exception:
pass
raise RuntimeError("demo_retrieval 为空:请先运行 2️⃣ Baseline 的初始化示例(会写入演示数据)")
def rewrite_to_queries(user_question: str, max_queries: int = 3):
"""用 LLM 输出 JSON 数组(多查询),失败则降级为原问题"""
prompt = f"""你是“知识库检索查询改写器”。
目标:把用户问题改写成适合检索的查询语句。
要求:
1) 只输出 JSON 数组,不要解释。
2) 输出 {max_queries} 条以内,每条不超过 25 个字。
3) 每条尽量包含:关键实体/错误提示/目标动作。
4) 不要编造不存在的产品名/错误码。
用户问题:{user_question}
输出示例:
["账号异常 登录失败 如何处理", "登录提示账号异常 排查步骤", "错误码/提示语 账号异常 解决"]
"""
try:
response = Generation.call(
model='qwen-turbo',
messages=[
{'role': 'system', 'content': '你是一个helpful助手'},
{'role': 'user', 'content': prompt}
],
temperature=0.7,
max_tokens=1000
)
content = (response.output.text or "").strip()
queries = json.loads(content)
if isinstance(queries, list) and queries:
queries = [q.strip() for q in queries if isinstance(q, str) and q.strip()]
return queries[:max_queries] if queries else [user_question]
return [user_question]
except Exception:
return [user_question]
def multi_query_search(collection, queries, top_k_each: int = 4):
"""对每个 query 检索 Top-K,再按 doc_id 去重(保留最小 distance)"""
best_by_doc_id = {} # doc_id -> (doc, meta, dist, hit_query)
for q in queries:
res = collection.query(
query_texts=[q],
n_results=top_k_each,
include=["documents", "metadatas", "distances"],
)
docs = res.get("documents", [[]])[0]
metas = res.get("metadatas", [[]])[0]
dists = res.get("distances", [[]])[0]
for doc, meta, dist in zip(docs, metas, dists):
doc_id = (meta or {}).get("doc_id") or ""
if not doc_id:
continue
prev = best_by_doc_id.get(doc_id)
if prev is None or (dist is not None and dist < prev[2]):
best_by_doc_id[doc_id] = (doc, meta, dist, q)
merged = list(best_by_doc_id.values())
merged.sort(key=lambda x: (x[2] is None, x[2]))
return merged
# 1) 连接持久化库(承接第 2 节)
client = chromadb.PersistentClient(path=DB_PATH)
collection = client.get_or_create_collection("demo_retrieval")
ensure_demo_data(collection)
# 2) 原始口语问题
user_question = "我登不上去了,提示账号异常"
# 3) Rewrite → 多查询
queries = rewrite_to_queries(user_question, max_queries=3)
print("=== Rewrite 结果(多查询)===")
for i, q in enumerate(queries, start=1):
print(i, q)
# 4) 多查询检索 → 合并去重
hits = multi_query_search(collection, queries, top_k_each=4)
print("\n=== 合并去重后(按 doc_id)===")
for i, (doc, meta, dist, hit_q) in enumerate(hits, start=1):
score = None if dist is None else (1 - dist)
if score is None:
print(i, meta, f"hit_query={hit_q}")
else:
print(i, meta, f"distance={dist:.3f}", f"score={score:.3f}", f"hit_query={hit_q}")
print(doc)向量相似度检索有个根本问题:语义相似 ≠ 能回答问题。 Rerank 就是在初步检索结果基础上,用更精准的方式重新排序,把真正能回答问题的内容排到前面。
很多时候你并不需要额外安装重模型:用“关键词/中文 bigram 片段重合度”做一个轻量打分,就能把更像答案的片段排到前面。
query 拆成 token 后得到的集合(去重后)。doc 拆成 token 后得到的集合(去重后)。
# ✅ 可直接运行的 Rerank(纯 Python 版本)
# 说明:不依赖 sentence-transformers / numpy,复制粘贴即可跑
def _simple_overlap_score(query: str, doc: str) -> float:
"""一个“够用且可解释”的轻量相关性分数(0~1)。
思路:把文本拆成 token 集合,然后算集合的 Jaccard 相似度:
score = |A∩B| / |A∪B|
为了同时兼顾中文/英文:
- 英文:按空白切分
- 中文:按字符 bigram(连续 2 个字)切分
"""
def _normalize(s: str) -> str:
return (s or "").strip().lower()
def _tokens_en(s: str):
# 英文/数字:简单按空白切分即可
return [t for t in s.split() if t]
def _tokens_zh_bigrams(s: str):
# 中文:用字符 bigram,让短句也能产生足够区分度
s_no_space = "".join(s.split())
if len(s_no_space) == 0:
return []
if len(s_no_space) == 1:
return [s_no_space]
return [s_no_space[i:i + 2] for i in range(len(s_no_space) - 1)]
q = _normalize(query)
d = _normalize(doc)
if not q or not d:
return 0.0
q_tokens = set(_tokens_en(q) + _tokens_zh_bigrams(q))
d_tokens = set(_tokens_en(d) + _tokens_zh_bigrams(d))
if not q_tokens or not d_tokens:
return 0.0
inter = q_tokens & d_tokens
union = q_tokens | d_tokens
return len(inter) / len(union)
def rerank_documents(query, documents, top_k=5):
"""
使用“字符 bigram + 词 overlap”的轻量打分,对候选文档重排序
Args:
query: 用户问题
documents: 候选文档列表
top_k: 返回前 k 个结果
Returns:
重新排序后的文档列表(按相关性从高到低)
"""
# 计算相关性分数(越大越相关)
scores = [_simple_overlap_score(query, d) for d in documents]
# 4) 按分数排序并返回 top_k
scored_docs = list(zip(documents, scores))
scored_docs.sort(key=lambda x: x[1], reverse=True)
return scored_docs[:top_k]
# 使用示例
query = "如何重置用户密码?"
candidate_docs = [
# 故意把“正确答案”放在后面,方便你观察重排序是否真的改变了顺序
"退款流程:提交申请-审核-原路退回",
"登录邮箱修改:先验证原邮箱,再绑定新邮箱",
"密码安全建议:使用强密码并定期更换",
"重置密码:进入设置-账号安全-选择重置方式",
]
# 5) 执行重排序
scored = rerank_documents(query, candidate_docs, top_k=4)
print("=== 原始顺序 ===")
for i, doc in enumerate(candidate_docs, 1):
print(f"{i}. {doc}")
print("\n=== 重排序后(含分数)===")
for i, (doc, score) in enumerate(scored, 1):
print(f"{i}. score={float(score):.4f} | {doc}")使用大语言模型直接评估每个文档与问题的匹配度,适合复杂场景。
def llm_rerank(query, documents, top_k=3):
"""
使用 LLM 对文档进行重排序
评估标准:
- 1-10分,文档是否能回答问题
- 优先选择直接、完整的答案
"""
import re
rerank_prompt = f"""
请为以下每个文档评分,评估它们对问题的回答能力。
问题:{query}
文档列表:
"""
for i, doc in enumerate(documents):
rerank_prompt += f"\n{i+1}. {doc}"
rerank_prompt += """
评分规则:
- 1-3分:与问题无关或相关性很低
- 4-6分:部分相关,但不能完整回答
- 7-8分:相关且能回答问题的大部分内容
- 9-10分:直接、完整地回答了问题
请按以下格式返回,每行一个:
文档编号:分数:简要理由
"""
# 调用 LLM(这里用伪代码,实际根据你的 LLM API 调整)
from dashscope import Generation
response = Generation.call(
model="qwen-plus",
prompt=rerank_prompt,
max_tokens=1000
)
# 解析评分结果
scores = []
lines = response.output.text.strip().split('\n')
for line in lines:
line = (line or "").strip()
if not line:
continue
parts = re.split(r"[::]", line, maxsplit=2)
if len(parts) < 2:
continue
idx_part = parts[0].strip()
score_part = parts[1].strip()
reason = parts[2].strip() if len(parts) > 2 else ""
m_idx = re.search(r"(\d+)", idx_part)
if not m_idx:
continue
doc_idx = int(m_idx.group(1)) - 1
if doc_idx < 0 or doc_idx >= len(documents):
continue
m_score = re.search(r"(\d+(?:\.\d+)?)", score_part)
if not m_score:
continue
score = float(m_score.group(1))
scores.append((doc_idx, score, reason))
# 按分数排序
scores.sort(key=lambda x: x[1], reverse=True)
# 返回 top_k 文档
if not scores:
return documents[:top_k]
return [documents[idx] for idx, _, _ in scores[:top_k]]
# 使用示例
query = "用户忘记密码怎么办?"
docs = [
"重置密码流程:1.点击忘记密码 2.输入邮箱 3.查收验证码 4.设置新密码",
"密码安全策略:要求8位以上,包含大小写字母和数字",
"账号锁定处理:连续输错5次密码会锁定30分钟",
"修改密码:登录后在个人设置中修改"
]
reranked = llm_rerank(query, docs)
print("LLM 重排序结果:")
for i, doc in enumerate(reranked, 1):
print(f"{i}. {doc}")结合多种方法,兼顾效果和效率。
def hybrid_rerank(query, documents, vector_scores=None, top_k=5):
"""
混合重排序:结合向量相似度 + 轻量文本匹配(bigram/overlap)+ 规则
"""
# 1) 如果有向量分数,作为基础分数
if vector_scores:
base_scores = vector_scores
else:
base_scores = [0.5] * len(documents) # 默认中等分数
# 2) 轻量文本匹配重排序分数(纯 Python,可直接运行)
# 说明:这里用上文的 _simple_overlap_score(query, doc) 作为 rerank 分数(0~1,越大越相关)
rerank_scores = [_simple_overlap_score(query, doc) for doc in documents]
# 3) 混合评分(可调整权重)
final_scores = []
for i, (base_score, rerank_score) in enumerate(zip(base_scores, rerank_scores)):
# 向量分数归一化 + 重排序分数
hybrid_score = 0.3 * (1 - base_score) + 0.7 * rerank_score
# 4) 规则调整
doc = documents[i]
# 长度惩罚:太短或太长的文档降分
if len(doc) < 20:
hybrid_score *= 0.8
elif len(doc) > 500:
hybrid_score *= 0.9
# 关键词加分:包含问题关键词的文档加分
query_words = set(query.lower().split())
doc_words = set(doc.lower().split())
if query_words & doc_words: # 有交集
hybrid_score *= 1.1
final_scores.append((i, hybrid_score))
# 5) 排序并返回结果
final_scores.sort(key=lambda x: x[1], reverse=True)
return [documents[idx] for idx, _ in final_scores[:top_k]]
# 完整的检索 + Rerank 流程(纯 Python,不依赖 sentence-transformers / numpy)
import chromadb
def _simple_overlap_score(query: str, doc: str) -> float:
"""轻量相关性分数(0~1),用于 Rerank(纯 Python,可直接运行)。"""
def _normalize(s: str) -> str:
return (s or "").strip().lower()
def _tokens_en(s: str):
return [t for t in s.split() if t]
def _tokens_zh_bigrams(s: str):
s_no_space = "".join(s.split())
if len(s_no_space) == 0:
return []
if len(s_no_space) == 1:
return [s_no_space]
return [s_no_space[i:i + 2] for i in range(len(s_no_space) - 1)]
q = _normalize(query)
d = _normalize(doc)
if not q or not d:
return 0.0
q_tokens = set(_tokens_en(q) + _tokens_zh_bigrams(q))
d_tokens = set(_tokens_en(d) + _tokens_zh_bigrams(d))
if not q_tokens or not d_tokens:
return 0.0
return len(q_tokens & d_tokens) / len(q_tokens | d_tokens)
def enhanced_retrieval(query, collection, top_k=20, final_k=5):
"""
完整的检索流程:向量检索 + Rerank
"""
# 1) 向量检索(召回更多候选)
results = collection.query(
query_texts=[query],
n_results=top_k,
include=["documents", "metadatas", "distances"]
)
documents = results["documents"][0]
metadatas = results["metadatas"][0]
distances = results["distances"][0]
# 2) 基础过滤(阈值、权限等)
filtered_docs = []
filtered_scores = []
threshold = 1.1 # 相似度阈值
for doc, meta, dist in zip(documents, metadatas, distances):
if dist <= threshold: # 距离阈值过滤
filtered_docs.append(doc)
filtered_scores.append(dist)
if not filtered_docs:
return []
# 3) Rerank 重排序
final_docs = hybrid_rerank(query, filtered_docs, filtered_scores, final_k)
return final_docs
# 使用示例
if __name__ == "__main__":
query = "如何修改绑定邮箱?"
DB_PATH = "./chroma_demo_retrieval"
def ensure_demo_data(col):
try:
if col.count() > 0:
return
except Exception:
pass
documents = [
"重置密码:进入设置-账号安全-选择重置方式",
"忘记密码:通过手机号验证码或邮箱验证码找回",
"登录邮箱修改:先验证原邮箱,再绑定新邮箱",
"手机号变更:需要人脸/短信二次验证,防止盗号",
"退款流程:提交申请-审核-原路退回",
"错误码 403:通常是权限不足或鉴权失败",
]
metadatas = [
{"doc_id": "acc_reset_pwd", "category": "account"},
{"doc_id": "acc_forgot_pwd", "category": "account"},
{"doc_id": "acc_change_email", "category": "account"},
{"doc_id": "acc_change_phone", "category": "account"},
{"doc_id": "refund_process", "category": "refund"},
{"doc_id": "err_403", "category": "error"},
]
ids = [str(i) for i in range(1, len(documents) + 1)]
col.add(documents=documents, metadatas=metadatas, ids=ids)
client = chromadb.PersistentClient(path=DB_PATH)
collection = client.get_or_create_collection("demo_retrieval")
ensure_demo_data(collection)
final_results = enhanced_retrieval(query, collection)
print("最终检索结果:")
for i, doc in enumerate(final_results, 1):
print(f"{i}. {doc}")下面补充一些“更像真实项目”的用法:同一次请求里做批量查询、用 where 做更复杂的元数据过滤、 用 where_document 做“文档内容过滤”,以及用 get/update/upsert/delete 做数据维护。 所有示例都仍然只用 Chroma 原生 API(不引入 LangChain)。
import chromadb
client = chromadb.Client()
try:
client.delete_collection("demo_batch")
except Exception:
pass
collection = client.get_or_create_collection("demo_batch")
collection.add(
documents=[
"退款流程:提交申请-审核-原路退回",
"退款时效:不同支付渠道到账时间不同",
"重置密码:进入设置-账号安全-选择重置方式",
"修改邮箱:先验证原邮箱,再绑定新邮箱",
],
metadatas=[
{"doc_id": "refund_1", "category": "refund"},
{"doc_id": "refund_2", "category": "refund"},
{"doc_id": "acc_1", "category": "account"},
{"doc_id": "acc_2", "category": "account"},
],
ids=["1", "2", "3", "4"],
)
queries = ["怎么退款?", "怎么重置密码?"]
res = collection.query(
query_texts=queries,
n_results=2,
include=["documents", "metadatas", "distances"],
)
# 返回是“按 query 分组”的二维列表:res["documents"][i] 对应第 i 个 query
for i, q in enumerate(queries):
print("\n=== query:", q, "===")
for text, meta, dist in zip(res["documents"][i], res["metadatas"][i], res["distances"][i]):
print(meta, "distance=", dist)
print(text)import chromadb
client = chromadb.Client()
try:
client.delete_collection("demo_where")
except Exception:
pass
collection = client.get_or_create_collection("demo_where")
collection.add(
documents=[
"退款流程:提交申请-审核-原路退回",
"退款时效:不同支付渠道到账时间不同",
"重置密码:进入设置-账号安全-选择重置方式",
"修改邮箱:先验证原邮箱,再绑定新邮箱",
],
metadatas=[
{"doc_id": "refund_1", "category": "refund", "version": 2},
{"doc_id": "refund_2", "category": "refund", "version": 1},
{"doc_id": "acc_1", "category": "account", "version": 2},
{"doc_id": "acc_2", "category": "account", "version": 1},
],
ids=["1", "2", "3", "4"],
)
query = "怎么退款?"
# 示例:只检索 version>=2 且 category 在 refund/account 里的文档
where = {
"$and": [
{"version": {"$gte": 2}},
{"category": {"$in": ["refund", "account"]}},
]
}
res = collection.query(
query_texts=[query],
n_results=5,
where=where,
include=["documents", "metadatas", "distances"],
)
print(res["metadatas"][0])
print(res["documents"][0])import chromadb
client = chromadb.Client()
try:
client.delete_collection("demo_where_doc")
except Exception:
pass
collection = client.get_or_create_collection("demo_where_doc")
collection.add(
documents=[
"错误码 403:通常是权限不足或鉴权失败",
"错误码 404:资源不存在,检查 URL 或路由",
"退款流程:提交申请-审核-原路退回",
],
metadatas=[
{"doc_id": "err_403"},
{"doc_id": "err_404"},
{"doc_id": "refund_1"},
],
ids=["1", "2", "3"],
)
# 先用语义检索召回,再用 where_document 做“包含某关键词”的过滤
res = collection.query(
query_texts=["怎么处理错误码?"],
n_results=5,
where_document={"$contains": "错误码"},
include=["documents", "metadatas"],
)
print(res["metadatas"][0])
print(res["documents"][0])import chromadb
client = chromadb.Client()
try:
client.delete_collection("demo_maintain")
except Exception:
pass
collection = client.get_or_create_collection("demo_maintain")
collection.add(
documents=["退款流程:提交申请-审核-原路退回"],
metadatas=[{"doc_id": "refund_1", "category": "refund", "version": 1}],
ids=["refund_1"],
)
# 1) get:按条件取出数据(用于排错/回放/追溯)
rows = collection.get(where={"category": "refund"}, include=["documents", "metadatas"])
print(rows["ids"], rows["metadatas"], rows["documents"])
# 2) upsert:有就更新,没有就新增(适合“增量入库/修订版本”)
collection.upsert(
ids=["refund_1", "refund_2"],
documents=[
"退款流程:提交申请-审核-原路退回(v2 修订版)",
"退款时效:不同支付渠道到账时间不同",
],
metadatas=[
{"doc_id": "refund_1", "category": "refund", "version": 2},
{"doc_id": "refund_2", "category": "refund", "version": 1},
],
)
# 3) delete:删除过期/违规/无权限的数据(可按 ids 或 where 条件)
collection.delete(where={"version": {"$lte": 1}})企业级 RAG 不是只追求“最准”,还要追求“最稳”。你必须预设: 没有检索到证据、检索很慢、重排超时、证据互相矛盾等情况怎么处理。
第6章你已经完成了“把文档写入向量库”。这里把“在线查询端”补齐: 加载已有向量库 → 检索证据 → 组装提示词 → 调用大模型生成答案 → 输出来源。
rag_demo/
├── ingest.py # 第6章:离线入库(把第6章“存储实战”的代码保存为这个文件)
├── query.py # 第7章:在线检索 + 生成
├── my_rag_db/ # 第6章示例使用的持久化目录(db_path="./my_rag_db")
└── requirements.txtdashscope
chromadb
numpyexport DASHSCOPE_API_KEY="你的key"import os
import chromadb
import dashscope
from dashscope import TextEmbedding, Generation
dashscope.api_key = os.getenv("DASHSCOPE_API_KEY")
def format_context(docs, max_chars=2400):
chunks = []
used = 0
for i, d in enumerate(docs, start=1):
piece = f"[证据{i}]\n{d}\n"
if used + len(piece) > max_chars:
break
chunks.append(piece)
used += len(piece)
return "\n".join(chunks)
def ask(question: str, db_path: str = "./my_rag_db", collection_name: str = "python_docs", top_k: int = 5):
dashscope.api_key = os.getenv("DASHSCOPE_API_KEY", "")
if not dashscope.api_key:
raise RuntimeError("请先设置环境变量 DASHSCOPE_API_KEY,然后再运行")
def embed_query(text: str):
resp = TextEmbedding.call(model='text-embedding-v2', input=text)
return resp.output['embeddings'][0]['embedding']
client = chromadb.PersistentClient(path=db_path)
collection = client.get_collection(collection_name)
# 与第6章保持一致:第6章用 DashScope 的 text-embedding-v2 写入向量。
# 这里显式计算 query embedding,并用 query_embeddings 检索,避免“embedding_function 不一致”导致 query_texts 不能用。
q_emb = embed_query(question)
results = collection.query(
query_embeddings=[q_emb],
n_results=top_k,
include=["documents", "metadatas", "distances"],
)
docs = results.get("documents", [[]])[0]
metadatas = results.get("metadatas", [[]])[0]
distances = results.get("distances", [[]])[0]
# distance 越小越相关(阈值需要你根据数据调)
threshold = 1.1
filtered = [(d, m, dist) for d, m, dist in zip(docs, metadatas, distances) if dist is not None and dist <= threshold]
if not filtered:
return {
"answer": "我在知识库中没有检索到足够相关的证据。你可以补充关键词或描述更具体的问题。",
"sources": []
}
context = format_context([d for d, _, _ in filtered])
prompt = f"""你是一个严谨的知识库问答助手。请仅基于【证据】回答问题;
如果证据不足以回答,请明确说“证据不足”。
【证据】
{context}
【问题】
{question}
【回答】
"""
resp = Generation.call(model="qwen-plus", prompt=prompt)
answer = resp.output["text"] if resp and resp.output else "生成失败"
sources = [{"metadata": m, "distance": dist} for _, m, dist in filtered]
return {"answer": answer, "sources": sources}
if __name__ == "__main__":
q = input("请输入问题:").strip()
result = ask(q)
print("\n=== 回答 ===\n")
print(result["answer"])
print("\n=== 证据来源(metadata/distance)===\n")
for s in result["sources"]:
print(s)./my_rag_db(取决于你在第6章运行时传入的 db_path)python_docs(取决于你在第6章运行时传入的 collection_name)python ingest.py
python query.py请完成这些练习,巩固“检索阶段”的核心能力(Top-K / 阈值 / 过滤 / Rewrite / 合并去重)
⚠️ 提示:下面很多题都是“检索工程”面试高频点(尤其是阈值、权限过滤、Rewrite、多查询合并)
1. Top-K 检索到底返回了什么?为什么返回结构是二维列表?
请用自己的话解释:documents/metadatas/distances 分别是什么,以及为什么要写 res["documents"][0]。
2. 为什么“Top-K 结果里会混入不相关内容”?你如何用阈值解决?
请说明:阈值应该用在什么字段上(distance/similarity),以及“无证据”时你会怎么降级(澄清/转人工/声明证据不足)。
3. 元数据过滤(where)解决了什么“工程问题”?
举例说明:category/version/permission 三类字段分别解决什么问题。哪些过滤必须发生在检索阶段?为什么不能交给 LLM?
4. Query Rewrite / Multi-Query 的区别是什么?各自适合哪些问题?
请给出 1 个口语化问题示例(比如“我登不上去了”),写出你会生成的 2~3 条检索 query(尽量包含实体/错误提示/动作)。
5. 多查询检索之后,为什么必须“合并去重”?你会用什么 key 去重?
说明:按 doc_id 去重的原因;当同一个 doc_id 命中多次时,你会怎么挑“更可信”的一条(比如保留最小 distance)。
6. 设计一个“权限过滤”方案(必须在检索阶段完成)
假设每个 chunk 有 permission_level 或 tenant_id,用户有自己的权限与租户。请写出 where 过滤条件,并说明为什么这是安全边界。
7. 你会如何调阈值(threshold)?
请给出一个“可操作”的方法:采样 50~100 个 query,记录命中结果的 distance 分布,用什么策略选阈值?阈值过高/过低分别会发生什么?
8. 当检索很慢时你怎么做“超时与降级”?
请列出:超时点(向量检索/额外增强步骤/LLM rewrite),以及各自的降级策略(减少 query 数、减少 k、跳过增强、直接澄清)。
1.【概念题】为什么“检索质量决定生成质量”?检索在 RAG 里承担什么职责边界?
回答时请覆盖:证据选择、幻觉控制、安全边界(权限过滤)三个点。
2.【场景题】“用户问题很口语,检索经常找不到证据”,你会怎么改?
请从:Query Rewrite、多查询、阈值与澄清、元数据过滤、日志回放五个角度给出排查/改进路径。
3.【系统设计】设计一个企业级检索模块的接口与日志字段
至少包含:输入 query、rewrite 结果、where 条件、topK、distance、阈值、命中 doc_id、耗时、是否降级。
4.【工程题】知识库内容更新后,如何保证检索结果“不过期/可追溯”?
请说明:version 策略、upsert/软删除、检索时过滤最新版本、以及答案里如何输出来源。
🎯 编程挑战:实现一个可用的检索函数(工程化版本)
实现 retrieve_evidence(question, user_ctx),要求:
doc_id)并保留最小 distanceuser_ctx,必须在检索阶段完成)