Files
zhuangang/plan/rag-project.md
T

20 KiB
Raw Blame History

转岗计划 · 第 7~8 周:RAG 知识库问答项目(项目二)

所属阶段:第二段“Python 与 AI 应用”。总纲见同目录 README.md,上周计划 week06-python-basics.md,第 9~10 周见总纲第 6 节。

目标

做一个“上传文档 → 基于文档问答 → 回答带出处”的知识库问答系统。招聘里 AI Agent 岗位最常见的要求之一就是 RAG 和向量数据库,这个项目专门对上它。

做完后要能演示和讲清:

  1. 上传 Markdown / TXT / PDF,自动切分、向量化、入库,能删除和重建。
  2. 提问后流式输出回答,每个结论标注出处 [1]、[2],点开能看到原文片段;文档里没有的内容明确回答“文档中没有找到”。
  3. 有一个小评测集,能用数字(命中率等)说明改了切分或检索方式之后效果是变好还是变差。
  4. 支持多轮追问(短期记忆)。
  5. Redis 做缓存和调用限流,Docker Compose 一键部署到服务器。

项目名:rag-qa,放在 zhuangang/rag-qa/。

文档素材用公开资料或自己的学习笔记(比如本计划的文件、Go / PostgreSQL 官方中文文档的若干页),不要用公司文档。

技术选型

用途 选择 说明
语言 / 依赖 Python 3.12 + uv
Web 服务 FastAPI 招聘里 FastAPI、Flask 都常见
向量数据库 Chroma(默认) 招聘里点名的主要是 Milvus 和 Chroma。Chroma 上手最快,可以嵌入式运行,也可以跑成独立服务
其他向量库 Milvus(了解 + 可选实现)、pgvector(可选) 通过 VectorStore 协议隔离,换库只写一个新实现。第 8 周第 7 天对比说明
大模型 / 向量化 OpenAI 兼容接口(对话 + embeddings) 国内很多模型服务都提供兼容接口;也可以选本地向量模型。测试用假实现
关键词检索 rank-bm25 + jieba 分词 第 8 周做混合检索
框架 第 7 周手写主流程;第 8 周用 LangChain 重写一遍做对照 招聘要求 LangChain 实际经验,但先手写才知道它替你做了什么
缓存 / 限流 / 会话 Redis 第 5 周学过
前端 你熟悉的 Vue 或 React 体现前端功底
部署 Docker Compose

最终项目结构

rag-qa/
├── README.md
├── pyproject.toml
├── .env.example
├── docker-compose.yml                 # api、chroma、redis、frontend
├── Dockerfile
├── data/                              # 运行时数据(不提交)
├── samples/                           # 演示用的公开文档
├── src/rag_qa/
│   ├── __init__.py
│   ├── config.py
│   ├── errors.py                      # 错误码
│   ├── llm/
│   │   ├── chat.py                    # 对话接口:普通 + 流式
│   │   ├── embeddings.py              # 向量化接口:批量、重试
│   │   └── fake.py                    # 测试用假实现
│   ├── ingest/
│   │   ├── loaders.py                 # txt / md / pdf → 文本 + 元数据
│   │   ├── chunker.py                 # 按标题和长度切分,带重叠
│   │   └── pipeline.py                # 加载 → 切分 → 去重 → 向量化 → 入库
│   ├── vectorstore/
│   │   ├── base.py                    # VectorStore 协议
│   │   ├── chroma_store.py
│   │   └── milvus_store.py            # 可选
│   ├── retrieval/
│   │   ├── vector.py                  # 向量检索
│   │   ├── bm25.py                    # 关键词检索
│   │   └── hybrid.py                  # 融合排序(RRF)
│   ├── qa/
│   │   ├── prompts.py                 # 提示词
│   │   ├── answer.py                  # 检索 + 生成 + 引用
│   │   └── rewrite.py                 # 多轮追问时改写问题
│   ├── memory/
│   │   └── session.py                 # 会话短期记忆(Redis)
│   ├── cache.py                       # 向量缓存、答案缓存
│   ├── ratelimit.py
│   ├── lc_pipeline.py                 # LangChain 版对照实现
│   ├── api/
│   │   ├── app.py
│   │   ├── routes_documents.py
│   │   └── routes_ask.py              # 含 SSE
│   └── cli.py                         # rag-qa ingest / ask / eval
├── eval/
│   ├── questions.jsonl                # 评测集
│   ├── run_eval.py
│   └── results.md                     # 每次实验的结果记录
├── frontend/                          # Vue / React
└── tests/
    ├── test_chunker.py
    ├── test_pipeline.py
    ├── test_retrieval.py
    ├── test_answer.py
    └── test_api.py

第 7 周:手写 RAG 主流程

第 1 天:概念和项目初始化

学习内容

  • RAG 是什么:先从资料里检索相关片段,再让模型基于这些片段生成回答。解决模型不知道私有资料、容易编造的问题。
  • 两条流水线:入库(加载 → 切分 → 向量化 → 存储)和问答(问题向量化 → 检索 → 拼提示词 → 生成)。
  • 向量(embedding)是什么:把一段文字变成一串数字,意思相近的文字数字也相近;相似度一般用余弦相似度。
  • 选好要用的模型服务:确认它的对话接口和 embeddings 接口、向量维度、价格和限流规则。

要建的文件

  • uv init --package rag-qa;uv add fastapi uvicorn httpx pydantic pydantic-settings chromadb pypdf;uv add --dev pytest pytest-asyncio ruff pyright。
  • src/rag_qa/config.py:LLM_BASE_URL、LLM_API_KEY、CHAT_MODEL、EMBED_MODEL、EMBED_PROVIDER(api / fake)、CHROMA_PATH、TOP_K、CHUNK_SIZE、CHUNK_OVERLAP。
  • src/rag_qa/errors.py:错误码沿用第 6 周的思路(超时、限流、额度、鉴权、输出异常、文档解析失败)。
  • src/rag_qa/llm/chat.py:async def chat(messages) -> str、async def chat_stream(messages) -> AsyncIterator[str](解析流式响应里的增量内容)。
  • src/rag_qa/llm/embeddings.py:async def embed(texts: list[str]) -> list[list[float]],按批次发送(每批条数可配置),失败按错误码重试。
  • src/rag_qa/llm/fake.py:FakeEmbeddings(用文本哈希生成固定向量,同一段文字结果永远一样)、FakeChat。
  • .env.example、.gitignore(.env、data/)。

运行方式:写一个小脚本,把三句话向量化后两两计算余弦相似度,看意思相近的是不是分数更高。

完成标准:能用自己的话讲清 RAG 的两条流水线。

第 2 天:加载和切分

学习内容

  • 切分为什么重要:太大检索不准、浪费上下文;太小丢失上下文。重叠(overlap)避免一句话被切断。
  • 按结构切:Markdown 先按标题切,段落太长再按长度切;每个片段带上所属标题,检索和引用都更准。
  • 元数据:文档 ID、文件名、标题路径、片段序号、内容哈希。

要建的文件

  • src/rag_qa/ingest/loaders.py:
    • def load_file(path: Path) -> Document:按扩展名分派;PDF 用 pypdf 按页提取并记录页码。
    • class Document(BaseModel):doc_id、filename、text、pages: list[PageSpan] | None。
  • src/rag_qa/ingest/chunker.py:
    • class Chunk(BaseModel):chunk_id、doc_id、text、heading_path、index、page、content_hash。
    • def split_markdown(doc, chunk_size, overlap) -> list[Chunk]
    • def split_plain(doc, chunk_size, overlap) -> list[Chunk]:优先在句号、换行处断开。
  • tests/test_chunker.py:片段长度不超过上限、相邻片段有重叠、标题路径正确、空文档返回空列表。

完成标准:拿 samples/ 里的一份 Markdown 切一下,打印片段,人工检查断句是否合理。

第 3 天:向量库和入库流水线

学习内容

  • Chroma 的基本用法:PersistentClient(path=...)、get_or_create_collection、add / upsert、query、delete;按元数据过滤。
  • 幂等入库:同一份文档重复上传不产生重复片段(用内容哈希做片段 ID);文档更新时先删旧片段再写新片段。

要建的文件

  • src/rag_qa/vectorstore/base.py:class VectorStore(Protocol):upsert(chunks, vectors)、query(vector, top_k, where=None) -> list[ScoredChunk]、delete_by_doc(doc_id)、count()。
  • src/rag_qa/vectorstore/chroma_store.py:class ChromaStore,相似度用余弦(建集合时指定)。
  • src/rag_qa/ingest/pipeline.py:async def ingest_file(path, store, embedder) -> IngestResult(片段数、新增数、跳过数、耗时)。
  • src/rag_qa/cli.py:rag-qa ingest samples/、rag-qa stats。
  • tests/test_pipeline.py:用 FakeEmbeddings 和临时目录;同一文件入库两次,片段数不变;删除文档后数量归零。

运行方式:uv run rag-qa ingest samples/ && uv run rag-qa stats

第 4 天:检索和生成

学习内容

  • Top-K 检索 + 相似度阈值:分数太低的片段不要,宁可回答“没找到”。
  • 提示词(Prompt)和上下文工程(Context Engineering):系统提示里规定“只能根据资料回答、每个结论标注编号、资料里没有就明确说没有”;资料按编号拼进上下文,注意总长度。
  • 引用:模型输出的 [1] 对应第 1 个片段,返回时附上片段原文、文件名、标题、页码。

要建的文件

  • src/rag_qa/retrieval/vector.py:async def retrieve(question, store, embedder, top_k, min_score) -> list[ScoredChunk]。
  • src/rag_qa/qa/prompts.py:系统提示词、拼接上下文的函数(超过长度上限就丢掉排名靠后的片段)。
  • src/rag_qa/qa/answer.py:
    • class Answer(BaseModel):text、citations: list[Citation]、retrieved: int、latency_ms。
    • async def answer(question, ...) -> Answer;async def answer_stream(question, ...) -> AsyncIterator[AnswerEvent](先发 citations 事件,再逐段发 delta,最后 done)。
    • 一个片段都没检索到时,不调模型,直接返回“文档中没有找到相关内容”。
  • cli.py:rag-qa ask "问题"。
  • tests/test_answer.py:没检索到时不调用模型;引用编号和片段对应。

完成标准:问 5 个文档里有答案的问题、3 个没有答案的问题,记录表现到 eval/results.md(这是第 8 周评测集的雏形)。

第 5 天:FastAPI 接口

要建的文件

  • src/rag_qa/api/app.py:create_app(),lifespan 里初始化向量库和客户端;统一错误响应。
  • src/rag_qa/api/routes_documents.py:
    • POST /documents:上传文件(限制类型和大小),保存后入库,返回入库结果。
    • GET /documents:文档列表(文件名、片段数、上传时间)。
    • DELETE /documents/{doc_id}。
  • src/rag_qa/api/routes_ask.py:
    • POST /ask:一次性返回 Answer。
    • POST /ask/stream:SSE,事件类型 citations / delta / done / error。(EventSource 只支持 GET,前端用 fetch 读流,这一点在 README 里说明。)
  • tests/test_api.py:上传 → 列表 → 提问 → 删除;上传不支持的文件类型返回 400。

运行方式:uv run uvicorn rag_qa.api.app:create_app --factory --reload,打开 /docs。

第 6 天:前端

要建的文件(frontend/,Vite + 你熟悉的框架)

  • src/api/rag.ts:uploadDocument、listDocuments、deleteDocument、askStream(用 fetch + ReadableStream 解析 SSE)。
  • src/pages/DocumentsPage:上传(支持拖拽)、列表、删除。
  • src/pages/ChatPage:对话界面,流式显示回答;回答里的 [1] 可点击,侧边栏显示对应原文片段和出处。
  • src/components/CitationPanel、MessageItem、UploadBox。

完成标准:上传一份文档,马上能提问,回答流式显示,引用可点开。

第 7 天:测试和第一版 README

  • 补全测试,uv run ruff check . && uv run pyright && uv run pytest 通过。
  • README.md 第一版:功能、架构图(两条流水线,mermaid)、本地运行步骤(默认可用 fake 模式跑通界面)。
  • 提交并推送到 zhuangang 仓库(路径 rag-qa/);在总纲进度记录里勾掉第 7 周。

第 8 周:评测、优化、记忆、缓存、LangChain 对照、部署

第 1 天:评测集(先有尺子,再谈优化)

学习内容

  • 没有评测,“优化”就只是感觉。检索和回答分开评:
    • 检索:正确片段有没有出现在前 K 个里(命中率,Hit@K);正确片段排在第几(MRR)。
    • 回答:是否正确、是否只用了资料里的信息、引用是否对得上。先用人工打分,再试试让模型按评分标准打分(LLM-as-judge),对比两者是否一致。

要建的文件

  • eval/questions.jsonl:自己写 30 条,每条 {"id", "question", "expected_doc", "expected_keywords", "answerable": true/false};其中至少 5 条是文档里没有答案的。
  • eval/run_eval.py:跑全部问题,输出 Hit@3、Hit@5、MRR、无答案题的正确拒答率、平均耗时;结果追加到 eval/results.md,每次记录配置(切分大小、Top-K、检索方式、模型)。
  • cli.py:rag-qa eval。

完成标准:得到一组基线数字,写在 eval/results.md 第一行。之后每做一次改动都重跑并记录。

第 2 天:检索优化

学习内容

  • 混合检索:向量检索擅长“意思相近”,关键词检索(BM25)擅长专有名词、编号、代码。中文要先分词(jieba)。
  • 融合排序:RRF(倒数排名融合),简单、不用调分数权重。
  • 重排(rerank):先粗取 20 个,再用重排模型精排出前 5 个。有可用的重排接口就试,没有就只了解原理。

要建的文件

  • uv add rank-bm25 jieba
  • src/rag_qa/retrieval/bm25.py:class Bm25Index:从向量库里取出全部片段建索引(文档变动时重建),search(query, top_k)。
  • src/rag_qa/retrieval/hybrid.py:def rrf(results_lists, k=60) -> list[ScoredChunk];async def hybrid_retrieve(...)。
  • 配置项 RETRIEVAL_MODE=vector|bm25|hybrid。
  • tests/test_retrieval.py:RRF 的排序结果符合手算。

完成标准:在评测集上对比 3 种检索方式和 2~3 种切分大小,结果表写进 eval/results.md,并写一句结论(数字以你实际跑出来的为准)。

第 3 天:多轮追问和短期记忆

学习内容

  • 多轮对话的问题:“那它的缺点呢?”直接拿去检索是查不到的,要先结合历史把问题改写完整(query rewrite)。
  • 记忆分两种:短期记忆(当前会话最近几轮,存 Redis,设过期时间);长期记忆(跨会话记住用户偏好等,第 13 周在 Agent 项目里做)。
  • 上下文长度管理:历史太长时只保留最近 N 轮,或者把更早的内容压缩成摘要。

要建的文件

  • uv add redis
  • src/rag_qa/memory/session.py:class SessionMemory:append(session_id, role, content)、recent(session_id, n),Redis List + 过期时间。
  • src/rag_qa/qa/rewrite.py:async def rewrite_question(history, question) -> str;没有历史时直接返回原问题,不调模型。
  • /ask/stream 请求体增加 session_id;前端每个对话窗口生成一个 session_id。

完成标准:连续追问 3 轮,第 2、3 轮仍能检索到正确片段;日志里能看到改写后的问题。

第 4 天:Redis 缓存和限流

要建的文件

  • src/rag_qa/cache.py:
    • 向量缓存:key = emb:{模型名}:{文本哈希},同一段文字不重复向量化(重新入库时省钱)。
    • 答案缓存:key = ans:{问题哈希}:{知识库版本号};任何文档增删时知识库版本号加 1,旧缓存自然失效。多轮对话(有历史)不走答案缓存。
  • src/rag_qa/ratelimit.py:按 IP 或会话限制提问频率(固定窗口,和第 5 周 Go 版同一个思路)。
  • Redis 不可用时降级:跳过缓存和记忆,问答照常工作。

完成标准:同一问题第二次明显更快(日志里有命中标记);重复入库同一文档时 embeddings 接口不再被调用。

第 5 天:LangChain 对照实现

学习内容

  • 招聘里普遍要求 LangChain / LangGraph 的实际项目经验。用 LangChain 的组件把问答链重写一遍:文本切分器、Chroma 向量库封装、检索器、对话模型、提示词模板。
  • 对照着看:每个组件替你做了什么,哪些地方你手写的版本更可控。
  • LlamaIndex 是另一个常见的 RAG 框架,思路类似,本周只读一下官方入门示例,能说出和 LangChain 的定位差别即可。

要建的文件

  • uv add langchain langchain-openai langchain-chroma langchain-text-splitters(包名以官方文档为准)。
  • src/rag_qa/lc_pipeline.py:用 LangChain 组件实现 ingest 和 ask,同样输出带引用的回答。
  • eval/run_eval.py 增加 --impl handwritten|langchain,两种实现在同一评测集上对比。
  • docs/handwritten-vs-langchain.md:组件对照表 + 你的结论。

完成标准:两种实现都能跑评测;能讲清“我为什么先手写,再用框架”。

第 6 天:Docker Compose 部署

要建的文件

  • Dockerfile:基于 Python 官方 slim 镜像,用 uv 安装依赖,非 root 用户运行。
  • docker-compose.yml:
    • chroma:Chroma 官方镜像跑成独立服务,数据卷持久化;api 通过 HTTP 客户端连接(ChromaStore 支持嵌入式和服务端两种模式,由配置决定)。
    • redis、api、frontend(nginx 托管静态文件,/api 反向代理到 api,注意关闭代理缓冲,否则流式输出会被攒成一整块)。
  • 部署到 Ubuntu 服务器(同第 5 周的 deploy.md 流程),在 README 里补部署小节。

完成标准:服务器上能上传文档、流式问答。

第 7 天:向量库对比和收尾

学习内容

  • 把三种向量库的定位讲清楚(只写你查证过的内容):
    • Chroma:上手快,可嵌入运行,适合原型和中小规模。
    • Milvus:专门的向量数据库,面向更大规模和分布式部署,招聘里和 Chroma 一起被点名;有轻量的单机方式可以本地试。
    • pgvector:PostgreSQL 扩展,已经在用 PostgreSQL 的团队不用多维护一个数据库,向量和业务数据可以一起查询、一起事务。
  • 有余力(可选,二选一):实现 milvus_store.py 或基于 pgvector 的实现,跑同一套评测。

要建的文件和修改

  • docs/vector-db-comparison.md:上面的对比 + 你实际试过的部分。
  • README.md 完整版:
    • 一句话介绍、截图或动图。
    • 架构图(入库流水线、问答流水线、缓存和记忆的位置)。
    • 评测结果表(来自 eval/results.md,每个数字都要能复现)。
    • 关键设计:切分策略、混合检索、拒答策略、引用、缓存失效、降级。
    • 本地运行(fake 模式无需密钥)、真实模型配置、Docker 部署。
    • 已知不足(比如:没做权限隔离、表格和图片内容没解析)。
  • 提交并推送;在总纲进度记录里勾掉第 8 周和“项目二”。

本项目自查清单

  • 能讲清 RAG 两条流水线,以及切分大小、重叠、Top-K、阈值各自的影响
  • 会用 Chroma,知道 Milvus、pgvector 的定位差别
  • 回答带引用;文档里没有的能正确拒答
  • 有 30 条的评测集,能用 Hit@K、MRR、拒答率说明优化效果
  • 做过混合检索对比
  • 多轮追问能改写问题,短期记忆存在 Redis
  • 向量缓存、答案缓存、限流、降级都做了
  • 用 LangChain 重写过一遍并做了对比
  • Docker Compose 部署到服务器,流式输出正常
  • README 完整,评测数字可复现

下一步:第 9 周开始阶段 0 盘点和阶段 1 读通画图,见总纲 README.md 第 6 节。这个 RAG 服务在第 11 周会被包装成 MCP 工具,给多 Agent 项目调用。