# 转岗计划 · 第 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 项目调用。