Skip to content

练手项目 · RAG 知识库

这是第二阶段的核心练手项目。跑通问答只是起点,更重要的是让你真正理解检索质量、引用机制和拒答逻辑在一个 AI 应用里是怎么协作的。

做完这个项目,你会比"只看 RAG 原理"的人多出一个关键认知:系统能跑不等于系统有用。RAG 的调优是从检索开始的,不是从 prompt 开始的。

这个项目在第二阶段的位置

第一阶段做完聊天助手,你已经能让模型说话了。但聊天助手的模型靠的是训练时的记忆,没有外部知识,也很难约束它"只基于资料回答"。

这个项目要解决:让模型基于你上传的资料回答,而不是靠推测或记忆回答。

做完这个项目之后,你就具备了进入第三阶段 Agent 的基础——因为 Agent 里的"工具调用"和"检索 + 回答"原理上是同一类问题。

前置章节

建议先读完这两章再开始动手,否则遇到问题时会很难定位是哪一层出了问题。

项目目标

跑通以下最小闭环:

文档导入 → 清洗 → 切块 → Embedding → 入库 → 提问 → 检索 → 基于资料回答 → 展示引用

并且实现"资料不足时明确拒答",而不是让模型强行推测。

这个项目真正要验证的,不是“能问答”

很多人做第一个 RAG 项目时,很容易把目标理解成:"上传文档之后,系统能回答几个问题。" 这当然是表面成果,但不是这个项目最该验证的核心。

这个项目真正要验证的是:你能不能把"证据如何进入模型"这件事做清楚。

也就是说,你要开始真正面对这些问题:

  • 文档进系统前怎么清洗
  • chunk 切成什么样才像证据
  • 检索到底有没有把对的材料拿回来
  • 模型回答时有没有真正在依据这些材料
  • 没证据时系统能不能老实拒答

所以这不是一个"问答功能"项目,而是一个证据链项目。

推荐最小范围

  • 文档类型先支持 txtmd 之一(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 打印出来,逐条确认:

  1. 正确答案对应的 chunk 是否出现在 top-k 里?
  2. 命中的 chunk 是不是一个完整的意思单元,还是被切断的半句话?
  3. 相似度分数大致在什么范围?无关内容的分数比有关内容低多少?

检索层通过之后,才开始检查生成层的输出质量。不要两层混在一起判断。

为什么这个项目最关键的不是 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.txt

config.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.25

ingest.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 最核心的三种状态:

  • 有证据,应该答
  • 证据分散,应该整合
  • 没证据,应该拒答

把这三类分清,你的系统就已经有了最基本的判断骨架。

完成后的复盘问题

  1. 你的失败更多是"检索没命中",还是"命中了但模型还是答错了"?
  2. 哪类 chunk 最容易命中但没法真正回答问题?
  3. 你是否能通过日志清楚看到每次问答命中了什么资料、相似度是多少?
  4. 你的拒答阈值设在哪里?是否有过拒答得太保守或太宽松的情况?

下一步

完成这个项目后,你已经具备了"会聊天 + 会接知识"的能力组合。

继续阅读 Agent 基础原理,进入第三阶段:从问答系统升级到能多步执行任务的 Agent。

回头看:几个容易忽略的判断

RAG 项目最该先验证的是“证据有没有进来”,而不是“模型答得顺不顺”。很多人第一反应是调 prompt,但如果证据本身就没到位,prompt 怎么调都没用。

文档处理、检索、生成是三层。把它们当成一个整体去调试,出了问题很难定位。拆开看、分层验证,才是 RAG 调优的基本功。

引用和拒答决定了系统值不值得信任。小规模、可观察的数据集比一上来就接一堆复杂文档更有价值——先把链路跑明白,再扩展规模。

做完这个项目的人,后面做 Agent 时会更容易分辨一个问题:到底是思考环节出了错,还是证据本身就不够。

面向开发者系统学习 AI 应用开发、RAG、Agent 与 Vibe Coding。