20 KiB
转岗计划 · 第 7~8 周:RAG 知识库问答项目(项目二)
所属阶段:第二段“Python 与 AI 应用”。总纲见同目录
README.md,上周计划week06-python-basics.md,第 9~10 周见总纲第 6 节。
目标
做一个“上传文档 → 基于文档问答 → 回答带出处”的知识库问答系统。招聘里 AI Agent 岗位最常见的要求之一就是 RAG 和向量数据库,这个项目专门对上它。
做完后要能演示和讲清:
- 上传 Markdown / TXT / PDF,自动切分、向量化、入库,能删除和重建。
- 提问后流式输出回答,每个结论标注出处
[1]、[2],点开能看到原文片段;文档里没有的内容明确回答“文档中没有找到”。 - 有一个小评测集,能用数字(命中率等)说明改了切分或检索方式之后效果是变好还是变差。
- 支持多轮追问(短期记忆)。
- 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 jiebasrc/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 redissrc/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,旧缓存自然失效。多轮对话(有历史)不走答案缓存。
- 向量缓存:key =
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 项目调用。