Appearance
练手项目 · RAG 知识库
这是第二阶段的核心练手项目。跑通问答只是起点,更重要的是让你真正理解检索质量、引用机制和拒答逻辑在一个 AI 应用里是怎么协作的。
做完这个项目,你会比"只看 RAG 原理"的人多出一个关键认知:系统能跑不等于系统有用。RAG 的调优是从检索开始的,不是从 prompt 开始的。
这个项目在第二阶段的位置
第一阶段做完聊天助手,你已经能让模型说话了。但聊天助手的模型靠的是训练时的记忆,没有外部知识,也很难约束它"只基于资料回答"。
这个项目要解决:让模型基于你上传的资料回答,而不是靠推测或记忆回答。
做完这个项目之后,你就具备了进入第三阶段 Agent 的基础——因为 Agent 里的"工具调用"和"检索 + 回答"原理上是同一类问题。
前置章节
- Embedding 与向量检索:理解 chunk、向量检索、top-k、rerank
- RAG 原理:理解完整 RAG 链路、引用机制、拒答逻辑
建议先读完这两章再开始动手,否则遇到问题时会很难定位是哪一层出了问题。
项目目标
跑通以下最小闭环:
文档导入 → 清洗 → 切块 → Embedding → 入库 → 提问 → 检索 → 基于资料回答 → 展示引用并且实现"资料不足时明确拒答",而不是让模型强行推测。
这个项目真正要验证的,不是“能问答”
很多人做第一个 RAG 项目时,很容易把目标理解成:"上传文档之后,系统能回答几个问题。" 这当然是表面成果,但不是这个项目最该验证的核心。
这个项目真正要验证的是:你能不能把"证据如何进入模型"这件事做清楚。
也就是说,你要开始真正面对这些问题:
- 文档进系统前怎么清洗
- chunk 切成什么样才像证据
- 检索到底有没有把对的材料拿回来
- 模型回答时有没有真正在依据这些材料
- 没证据时系统能不能老实拒答
所以这不是一个"问答功能"项目,而是一个证据链项目。
推荐最小范围
- 文档类型先支持
txt或md之一(PDF 可以在第二版加入) - 知识库规模先控制在 5 到 20 份文档,不追求大而全
- 先把命中质量、引用和拒答机制做好,再考虑扩展功能
这个范围控制很重要。RAG 项目最容易犯的错,就是一开始就想接 PDF、大量文档、复杂元数据、后台任务、权限控制,结果还没搞清楚最基本的检索质量,系统复杂度已经先爆了。
第一版必须完成的功能
1. 文档导入与建库
- 上传或指定本地文档
- 清洗文本(去掉页眉、页脚、目录、空白行等噪音)
- 按策略切块(先从固定长度开始,比如每块 300 到 500 字,重叠 50 字)
- 为每个 chunk 生成 embedding,存入向量库
2. 知识库问答
- 接收用户提问
- 对问题生成 embedding,在向量库里检索最相关的 chunk(建议先用 top-3 到 top-5)
- 把命中片段拼接到 prompt,要求模型"只基于以下资料回答"
- 返回回答
3. 引用展示
- 回答页面展示答案来自哪份文档(文档名 + 大致位置)
- 最好能展示用于支撑回答的原始 chunk 片段
4. 拒答机制
- 当检索相似度低于阈值,或命中片段和问题不匹配时,明确回复"资料中未找到相关内容"
- 不让模型在没有资料依据时靠推测强答
这四块功能放在一起,其实是在验证一条完整链路:资料进入系统后,能不能稳定地被转成可检索证据,再被检索层拿回来,再被生成层使用,最后通过引用和拒答把边界展示给用户。
第二版建议补充
在第一版跑通之后,以下是值得扩展的方向:
- 支持 metadata 过滤(按文档类型、日期、标签过滤检索范围)
- 支持简单 rerank(对 top-k 结果做二次排序,提升最终送入 prompt 的内容质量)
- 支持 top-k 调参(在页面上可以调整 k 值,对比效果)
- 支持 PDF 格式文档
- 记录每次问答命中了什么 chunk、相似度是多少,方便排查
- 记录回答耗时和 token 消耗
第二版里的这些能力,本质上都在往同一个方向走:让系统从"能跑"变成"能观察、能调、能迭代"。
最低验收标准
功能验收:
- 用户能上传资料并完成一次建库
- 对资料里有明确答案的问题,能命中相关片段并给出回答
- 回答能展示来源文档名和对应片段
- 当资料里没有答案时,系统不会强答,而是明确提示
检索层验收(独立于生成层检查):
对你的测试问题集,把每次检索的 top-k chunk 打印出来,逐条确认:
- 正确答案对应的 chunk 是否出现在 top-k 里?
- 命中的 chunk 是不是一个完整的意思单元,还是被切断的半句话?
- 相似度分数大致在什么范围?无关内容的分数比有关内容低多少?
检索层通过之后,才开始检查生成层的输出质量。不要两层混在一起判断。
为什么这个项目最关键的不是 Prompt,而是分层判断
这正是 RAG 项目和普通聊天项目最不一样的地方。
普通聊天项目里,问题很多时候还能大致归因到 Prompt 或模型;RAG 里不行。因为一旦资料接入了系统,错误至少可能出在三处:
- 文档处理错了
- 检索没召回
- 生成越界发挥
如果你没有"先看证据,再看回答"的习惯,这个项目会很容易陷进一种无效循环:回答不对 -> 改 Prompt -> 还是不对 -> 再改 Prompt。真正的问题可能从头到尾都在检索层。
常见"能跑但效果很差"的问题
这些问题在第一次运行成功之后经常遇到,是第二阶段最重要的调优经验:
检索层问题:
- chunk 切太碎,每条命中只有半句话,根本读不懂
- chunk 太大,命中片段里混了太多无关内容
- 文档没有清洗,页眉页脚和乱码被当作正文切进去
- top-k 设置太小,关键片段被漏掉;设置太大,噪音太多
生成层问题:
- prompt 约束不够强,模型即使有资料也容易"顺着说"超出资料范围
- 命中片段质量差,模型被迫在不够的资料上推测
- 引用没有和回答绑定,用户看不出答案的依据
诊断建议:
遇到回答质量差时,先不要动 prompt,先查"命中片段是什么"。如果命中片段本身就不够回答这个问题,那是检索问题,不是生成问题。
为什么引用和拒答在这个项目里不是附加功能
很多人会把引用展示和拒答机制当成第二优先级,觉得先把回答做出来再说。这个顺序在 RAG 项目里往往会让系统失真。
因为只要没有引用,你就失去了判断"答案到底依据了什么"的抓手;只要没有拒答,你就失去了系统承认边界的能力。没有这两样,系统哪怕答得很流畅,也很难真正建立信任。
你会真正学到什么
- chunk 策略为什么直接影响最终效果
- 为什么检索质量经常比 prompt 调优更关键
- 为什么引用和拒答机制是系统可信度的核心
- 为什么 RAG 调优必须把检索层和生成层分开看
建议输出物
完成这个项目后,建议同步整理以下产出:
- 一个可运行的知识库问答页面
- 一份学习总结(写清楚你遇到了哪些问题,如何排查的)
- 一个最小评测集(见下面的说明)
骨架代码参考
这份骨架用命令行完成第一版 RAG:把 data/ 里的 .txt 文档切块入库,然后提问,输出答案和引用。它不做前端,也不处理 PDF,先把"证据进来、证据取回、基于证据回答"这条链路跑清楚。
技术选型
- 文档格式先用
.txt,避免 PDF 解析把第一版复杂度拉高。 - 向量库用 Chroma 本地持久化模式,不需要额外启动服务。
- Embedding 用
text-embedding-3-small,成本低,足够做第一版验证。 - 生成仍然用 Chat Completions,这样和前面的聊天项目保持一致。
目录结构
text
rag-kb/
├── config.py
├── ingest.py
├── query.py
├── chroma_db/
└── data/
├── refund.txt
└── shipping.txtconfig.py
python
CHROMA_PATH = "chroma_db"
COLLECTION_NAME = "local_kb"
DATA_DIR = "data"
EMBEDDING_MODEL = "text-embedding-3-small"
CHAT_MODEL = "gpt-4o-mini"
# 第一版用字符切块就够了。中文文档可以先从 500 字左右开始试。
CHUNK_SIZE = 500
CHUNK_OVERLAP = 80
TOP_K = 4
MIN_SCORE = 0.25ingest.py
python
from pathlib import Path
import chromadb
from openai import OpenAI
from config import (
CHROMA_PATH,
CHUNK_OVERLAP,
CHUNK_SIZE,
COLLECTION_NAME,
DATA_DIR,
EMBEDDING_MODEL,
)
client = OpenAI()
chroma = chromadb.PersistentClient(path=CHROMA_PATH)
collection = chroma.get_or_create_collection(COLLECTION_NAME)
def split_text(text: str) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + CHUNK_SIZE
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start += CHUNK_SIZE - CHUNK_OVERLAP
return chunks
def embed(text: str) -> list[float]:
response = client.embeddings.create(
model=EMBEDDING_MODEL,
input=text,
)
return response.data[0].embedding
def ingest_file(path: Path) -> None:
text = path.read_text(encoding="utf-8")
chunks = split_text(text)
ids, documents, embeddings, metadatas = [], [], [], []
for index, chunk in enumerate(chunks):
ids.append(f"{path.stem}-{index}")
documents.append(chunk)
embeddings.append(embed(chunk))
metadatas.append({"source": path.name, "chunk": index})
if ids:
collection.upsert(
ids=ids,
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
)
print(f"已导入 {path.name}: {len(ids)} 个 chunk")
def main() -> None:
for path in Path(DATA_DIR).glob("*.txt"):
ingest_file(path)
if __name__ == "__main__":
main()这里用 upsert,是为了同一个文件重复导入时能覆盖旧 chunk。真实项目里还要处理删除文件、文档版本和增量更新,第一版先不展开。
query.py
python
import chromadb
from openai import OpenAI
from config import (
CHAT_MODEL,
CHROMA_PATH,
COLLECTION_NAME,
EMBEDDING_MODEL,
MIN_SCORE,
TOP_K,
)
client = OpenAI()
chroma = chromadb.PersistentClient(path=CHROMA_PATH)
collection = chroma.get_collection(COLLECTION_NAME)
def embed(text: str) -> list[float]:
response = client.embeddings.create(
model=EMBEDDING_MODEL,
input=text,
)
return response.data[0].embedding
def search(question: str) -> list[dict]:
results = collection.query(
query_embeddings=[embed(question)],
n_results=TOP_K,
)
hits = []
for doc, meta, distance in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
):
score = 1 - distance
hits.append({"text": doc, "meta": meta, "score": score})
return hits
def answer(question: str, hits: list[dict]) -> str:
context = "\n\n".join(
f"[{i}] 来源:{hit['meta']['source']} 第 {hit['meta']['chunk']} 段\n{hit['text']}"
for i, hit in enumerate(hits, start=1)
)
response = client.chat.completions.create(
model=CHAT_MODEL,
messages=[
{
"role": "system",
"content": "你只能基于给定资料回答。资料不足时,直接说资料中未找到相关内容。",
},
{"role": "user", "content": f"资料:\n{context}\n\n问题:{question}"},
],
)
return response.choices[0].message.content or ""
def main() -> None:
question = input("请输入问题:").strip()
hits = search(question)
if not hits or hits[0]["score"] < MIN_SCORE:
print("资料中未找到相关内容。")
return
print("\n答案:")
print(answer(question, hits))
print("\n引用来源:")
for hit in hits:
meta = hit["meta"]
print(f"- {meta['source']} 第 {meta['chunk']} 段,score={hit['score']:.3f}")
if __name__ == "__main__":
main()拒答不是只靠 prompt。query.py 里先看检索分数,如果最高分低于阈值,直接拒答,不把问题交给模型自由发挥。这个阈值需要你用自己的测试问题慢慢调,不同文档集会有差异。
运行方式
安装依赖:
bash
pip install openai chromadb设置环境变量:
bash
export OPENAI_API_KEY="你的 API Key"
# 使用兼容接口时再设置:
# export OPENAI_BASE_URL="https://your-provider.example.com/v1"准备两份示例文档:
text
data/refund.txt
退款会在审核通过后的 7 个工作日内原路退回。超过 7 个工作日仍未到账,可以联系客服查询流水号。text
data/shipping.txt
普通商品默认 48 小时内发货。预售商品以页面标注时间为准,不参与普通发货时效承诺。先建库,再提问:
bash
python ingest.py
python query.py一次理想输出大概是:
text
请输入问题:退款多久到账?
答案:
退款会在审核通过后的 7 个工作日内原路退回。
引用来源:
- refund.txt 第 0 段,score=0.612这份骨架的局限
第一版只支持 .txt,切块也只是按字符长度切,不会理解标题、段落和表格。Chroma 本地模式适合学习和原型,不等于生产部署方案。后续要升级,可以先加日志,把每次命中的 chunk、分数和最终回答记录下来,再考虑 PDF、metadata 过滤和 rerank。
最小评测建议
至少准备三类问题各 2 到 3 个,在第一版跑完之后对照验收:
- 资料里明确有答案的问题:系统能命中并给出准确答案吗?
- 资料里相关但需要综合多段的问题:系统能整合还是只给了一段?
- 资料里根本没有答案的问题:系统真的拒答了,还是强行推测了?
这三类测试题之所以重要,不是因为它们能覆盖所有情况,而是因为它们正好对应 RAG 最核心的三种状态:
- 有证据,应该答
- 证据分散,应该整合
- 没证据,应该拒答
把这三类分清,你的系统就已经有了最基本的判断骨架。
完成后的复盘问题
- 你的失败更多是"检索没命中",还是"命中了但模型还是答错了"?
- 哪类 chunk 最容易命中但没法真正回答问题?
- 你是否能通过日志清楚看到每次问答命中了什么资料、相似度是多少?
- 你的拒答阈值设在哪里?是否有过拒答得太保守或太宽松的情况?
下一步
完成这个项目后,你已经具备了"会聊天 + 会接知识"的能力组合。
继续阅读 Agent 基础原理,进入第三阶段:从问答系统升级到能多步执行任务的 Agent。
回头看:几个容易忽略的判断
RAG 项目最该先验证的是“证据有没有进来”,而不是“模型答得顺不顺”。很多人第一反应是调 prompt,但如果证据本身就没到位,prompt 怎么调都没用。
文档处理、检索、生成是三层。把它们当成一个整体去调试,出了问题很难定位。拆开看、分层验证,才是 RAG 调优的基本功。
引用和拒答决定了系统值不值得信任。小规模、可观察的数据集比一上来就接一堆复杂文档更有价值——先把链路跑明白,再扩展规模。
做完这个项目的人,后面做 Agent 时会更容易分辨一个问题:到底是思考环节出了错,还是证据本身就不够。