用户问"怎么退货",程序从知识库中找到相关文档,结合 LLM 生成回答:
You: 怎么退货?
[检索到 2 篇相关文档]
AI: 我们支持7天无理由退货。您可以在"我的订单"中申请退货,
填写原因后寄回商品,退款将在收到退货后3-5个工作日内返还。
all_knowledge = "支持7天无理由退货...全国配送3-5个工作日..."
reply = call_llm([
{"role": "system", "content": f"以下是知识库:\n{all_knowledge}"},
{"role": "user", "content": "怎么退货?"},
])
三个问题:
问题 1:Token 限制。 知识库有 100 篇文档,每篇 200 字,总字数 20000。而 LLM 的上下文窗口通常只有 4K-8K tokens(约 3000-6000 中文字)。塞不进去。
问题 2:迷失在长文本中。 即使上下文窗口够大(比如 128K tokens),LLM 在超长文本中依然会"走神"——它可能忽略掉关键信息。这叫"Lost in the Middle"现象。
问题 3:知识更新要改代码。 FAQ 加了新内容,你要重新部署整个系统。
"少即是多"。把全部知识库塞给 LLM,和把最相关的 3 篇文档塞给它,效果反而是后者更好——因为 LLM 不需要从噪音中寻找信号。
RAG = Retrieval-Augmented Generation。
用户问题 → 检索最相关的文档 → 文档 + 问题 → LLM 生成回答
核心是检索这一步:怎么找出和用户问题最相关的文档?
把用户问题和文档都拆成"词的集合",计算交集占并集的比例。
query = "怎么退款" → {怎, 么, 退, 款}
doc = "支持7天无理由退货" → {支, 持, 天, 无, 理, 由, 退, 货, ...}
Jaccard = 交集{退} / 并集{怎,么,退,款,支,持,天,...} ≈ 很低
"退款"和"退货"共享一个"退"字,Jaccard 得分极低,尽管它们在语义上高度相关。
把文本转换成向量(数字序列),语义相近的文本向量在空间中也相近。
"怎么退款" → [0.23, 0.87, -0.12, ...] (768 维向量)
"支持7天无理由退货" → [0.25, 0.82, -0.10, ...] (语义相近,向量也相近)
余弦相似度 ≈ 0.85
Embedding 模型 负责"文本 → 向量"的转换。我们用的是 sentence-transformers 的 paraphrase-multilingual-MiniLM-L12-v2——支持中英文的小模型。
Chroma 负责存储向量和做相似度搜索。它就像 MySQL 但存的是向量。
| 需求 | 自己实现 | 用 Chroma |
|---|---|---|
| 存储向量 | 自己写文件格式 | 自动持久化到磁盘 |
| 相似度搜索 | 暴力遍历 O(n) | 自动建索引,速度快 |
| 增删文档 | 手动管理 | add/delete/update API |
| 服务重启 | 向量要重新算 | 重启后数据还在 |
如果知识库只有 5 篇文档,自己算也行。但一旦到了 50 篇、500 篇,你就需要 Chroma 了。
import chromadb
from chromadb.utils import embedding_functions
ef = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="paraphrase-multilingual-MiniLM-L12-v2"
)
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.create_collection(
name="knowledge_base",
embedding_function=ef,
)
PersistentClient 把数据存在磁盘上。服务重启后数据还在。
collection.add(
ids=["faq_01", "faq_02"],
documents=["支持7天无理由退货...", "全国配送3-5个工作日..."],
metadatas=[{"source": "退货政策"}, {"source": "配送说明"}],
)
add 会自动调用 embedding 函数把 documents 转成向量再存起来。
results = collection.query(
query_texts=["怎么退款"],
n_results=3,
)
query 会把"怎么退款"转成向量,然后找到向量空间中最近的 3 个文档。
def answer_with_knowledge(user_input):
# Step 1: 检索
results = get_collection().query(query_texts=[user_input], n_results=3)
documents = results["documents"][0]
metadatas = results["metadatas"][0]
# Step 2: 拼上下文
context = ""
for i, doc in enumerate(documents):
source = metadatas[i]["source"]
context += f"[{source}] {doc}\n\n"
# Step 3: LLM 生成
reply = call_llm([
{"role": "system", "content": "根据知识库文档回答用户问题。不够时诚实告知。"},
{"role": "user", "content": f"文档:{context}\n\n问题:{user_input}"},
])
return reply
从检索到生成,只有三步。 这就是 RAG 的全部。
一个简单的测试方法:
# 测试1:同义词
print(answer_with_knowledge("怎么退款"))
# 应该匹配到"退货政策"而不是"配送说明"
# 测试2:模糊描述
print(answer_with_knowledge("听歌的耳机多少钱"))
# 应该匹配到"智能蓝牙耳机 Pro"而不是"无线充电板"
# 测试3:不存在的内容
print(answer_with_knowledge("你们在纽约有店吗"))
# 应该诚实回答:"我找不到相关信息"
测试 1 能跑通,说明向量检索比关键词匹配好。测试 3 能跑通,说明 system prompt 的"不够时诚实告知"起到了作用。
| 概念 | 你做了什么 | 为什么 |
|---|---|---|
| RAG | 检索 → 生成 | 不让 LLM 凭空回答,给它参考依据 |
| 向量检索 | Chroma + embedding | 语义匹配,"退款"能匹配"退货" |
| Chunking | 按文档组织(未分块) | 为后续长文档分块做铺垫 |
| Embedding | sentence-transformers | 把文本变成语义向量 |
seed_knowledge.py 中加一篇 500 字的长文档,观察检索时能不能精确命中特定段落all-MiniLM-L6-v2(纯英文),问中文问题看看效果有什么变化"RAG 的检索质量对最终回答质量有多大影响?"
检索决定了 LLM 能看到什么。如果检索阶段就没找到相关文档,LLM 再强也答不对。所以 RAG 系统的瓶颈通常在检索,不在生成。
"为什么用 Chroma 而不是自己写向量搜索?"
持久化、索引加速、增删 API 都是你自己实现很麻烦的事情。Chroma 把这些封装好了。5 篇文档可以不选,50 篇以上建议选。