--- id: plan.week06 title: "第 6 周:RAG 知识库问答(下)——评测、混合检索、重排、记忆、缓存、LangChain、部署" type: week domain: plan stack: [postgresql, redis, docker, rag, vector-db, langchain] week: [6] est_hours: 12 order: 6 --- # 第 6 周:RAG 知识库问答(下)——评测、混合检索、重排、记忆、缓存、LangChain、部署 > 项目二 `rag-qa` 的第 2 周。上周 `week05-rag-1.md` 完成了手写主流程;下周 `week07-frontend.md` 做前端界面。 **本周目标**:从“能跑”到“能用数字证明好不好”。先建评测集,再逐个加混合检索、重排、多轮改写、缓存,**每加一项都跑一次评测**并记录结果;最后用 LangChain 重写一遍做对照,了解 Milvus / pgvector,用 Docker Compose 部署。 **本周新增文件** ```text rag-qa/ ├── src/rag_qa/ │ ├── config.py # 覆盖:新增检索模式、Redis 配置 │ ├── container.py # 覆盖:按配置组装混合检索、缓存、会话 │ ├── eval_metrics.py # Hit@K、MRR、拒答准确率 │ ├── retrieval/bm25.py # 关键词检索(jieba 分词) │ ├── retrieval/hybrid.py # RRF 融合 + 混合检索问答服务 │ ├── retrieval/rerank.py # 重排(接口实现 + 假实现) │ ├── qa/rewrite.py # 多轮追问改写 │ ├── memory/__init__.py │ ├── memory/session.py # 会话短期记忆(内存 / Redis) │ ├── cache.py # 向量缓存、答案缓存 │ ├── api/chat.py # 多轮对话接口 │ └── lc_pipeline.py # LangChain LCEL 版对照 ├── eval/ │ ├── questions.jsonl │ ├── run_eval.py │ └── results.md ├── Dockerfile ├── docker-compose.yml └── tests/ ├── test_eval.py ├── test_hybrid.py ├── test_rerank.py ├── test_memory.py ├── test_cache.py └── test_lc_pipeline.py ``` ```bash uv add rank-bm25 jieba "redis>=5" langchain-core ``` --- ## 第 1 天:评测集和指标 ### 学习内容 - 说明: - **没有评测,所有优化都是感觉**。面试官最爱问“你怎么知道改完变好了?”——答案就是评测集 + 指标 + 对比记录。 - 检索指标(不需要调用大模型,便宜、稳定,先做这个): - **Hit@K**:前 K 个结果里有没有至少一个相关片段。 - **MRR**(平均倒数排名):第一个相关片段排第 1 得 1 分,排第 2 得 0.5 分,没找到得 0 分,取平均。看的是“排得够不够靠前”。 - **拒答准确率**:本来就没有答案的问题,系统是否正确地一个片段都不返回。 - 怎么判断“相关”:每道题写一个**必须出现在片段里的关键句**(不用片段 ID,因为改切分参数后 ID 会变)。 - 生成质量(忠实度、是否答对)可以人工抽查,也可以用模型打分(LLM-as-judge),本周人工抽查即可,第 13 周 Agent 评测再系统做。 - 案例: ```python # 文件:rag-qa/src/rag_qa/eval_metrics.py import json from pathlib import Path from pydantic import BaseModel from rag_qa.vectorstore.base import ScoredChunk class EvalItem(BaseModel): q: str must_contain: str = "" # 相关片段必须包含的关键句;为空表示这题应该拒答 class EvalReport(BaseModel): hit_at_k: float mrr: float refuse_acc: float n_answerable: int n_refuse: int misses: list[str] def load_items(path: Path) -> list[EvalItem]: return [EvalItem.model_validate_json(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] def first_relevant_rank(item: EvalItem, hits: list[ScoredChunk]) -> int | None: for rank, h in enumerate(hits, start=1): if item.must_contain in h.chunk.text: return rank return None def evaluate(items: list[EvalItem], results: list[list[ScoredChunk]]) -> EvalReport: hit = rr = refuse_ok = 0.0 answerable = [(i, r) for i, r in zip(items, results) if i.must_contain] refuse = [(i, r) for i, r in zip(items, results) if not i.must_contain] misses = [] for item, hits in answerable: rank = first_relevant_rank(item, hits) if rank is None: misses.append(item.q) else: hit += 1 rr += 1 / rank for item, hits in refuse: if hits: misses.append(f"(应拒答){item.q}") else: refuse_ok += 1 na, nr = len(answerable), len(refuse) return EvalReport(hit_at_k=round(hit / na, 3) if na else 0, mrr=round(rr / na, 3) if na else 0, refuse_acc=round(refuse_ok / nr, 3) if nr else 0, n_answerable=na, n_refuse=nr, misses=misses) def to_markdown_row(name: str, r: EvalReport) -> str: return f"| {name} | {r.hit_at_k:.3f} | {r.mrr:.3f} | {r.refuse_acc:.3f} | {len(r.misses)} |" if __name__ == "__main__": print(json.dumps({"q": "Redis 旁路缓存怎么更新", "must_contain": "先写数据库"}, ensure_ascii=False)) ``` ```python # 文件:rag-qa/tests/test_eval.py from rag_qa.eval_metrics import EvalItem, evaluate from rag_qa.ingest.chunker import Chunk from rag_qa.vectorstore.base import ScoredChunk def hit(text: str) -> ScoredChunk: return ScoredChunk(chunk=Chunk(chunk_id=text, doc_id="d", filename="f.md", text=text, index=0), score=0.5) def test_metrics(): items = [EvalItem(q="q1", must_contain="甲"), EvalItem(q="q2", must_contain="乙"), EvalItem(q="q3", must_contain="丙"), EvalItem(q="q4")] results = [[hit("甲是答案")], [hit("无关"), hit("乙在第二")], [hit("无关")], []] r = evaluate(items, results) assert r.hit_at_k == round(2 / 3, 3) assert r.mrr == round((1 + 0.5 + 0) / 3, 3) assert r.refuse_acc == 1.0 and r.misses == ["q3"] ``` 评测集文件(每行一条 JSON,至少 30 条,其中 5~8 条是文档里没有答案的): ```json {"q": "Redis 旁路缓存更新数据时的顺序是什么", "must_contain": "先写数据库"} ``` ```json {"q": "goroutine 之间推荐用什么通信", "must_contain": "channel"} ``` ```json {"q": "公司今年的年终奖怎么算"} ``` ```python # 文件:rag-qa/eval/run_eval.py """用法:uv run python eval/run_eval.py (先 rag-qa ingest samples/)""" import asyncio from pathlib import Path from rag_qa.container import build_container from rag_qa.eval_metrics import evaluate, load_items, to_markdown_row async def main() -> None: # 不自动运行 items = load_items(Path(__file__).with_name("questions.jsonl")) print("| 方案 | Hit@K | MRR | 拒答准确率 | 失败数 |\n| --- | --- | --- | --- | --- |") for mode in ["vector", "hybrid", "hybrid_rerank"]: c = build_container(mode=mode) results = [await c.qa.retrieve(i.q) for i in items] report = evaluate(items, results) print(to_markdown_row(mode, report)) for m in report.misses: print(f"") if __name__ == "__main__": asyncio.run(main()) ``` **易错点 / 面试常问(补充)** - 易错点:评测集要包含“本来就没有答案”的问题,否则看不出系统会不会硬答。 - 易错点:评测集写好后不要按评测结果去改题目;改了系统再跑同一套题才有可比性。评测结果要记下当时的参数(Top-K、阈值、检索方式)。 - 面试常问:Hit@K 和 MRR 分别衡量什么(前 K 个里有没有命中 / 命中的排名靠不靠前);检索指标和回答质量指标的区别(后者可以用人工或模型评分)。 ### 要建的文件 - `eval_metrics.py`、`tests/test_eval.py`、`eval/questions.jsonl`(从上周 8 条人工试验扩充到 30 条以上)、`eval/run_eval.py`、`eval/results.md` ### 运行方式 ```bash uv run pytest tests/test_eval.py -v uv run python eval/run_eval.py # 第 2、3 天的代码写完后才能跑全部三种方案 ``` ### 完成标准 - 评测集 ≥ 30 条;`eval/results.md` 记下**基线**(纯向量检索)的三个指标。每次改动后追加一行,写清改了什么、指标变化、原因分析。 - 实验一:把 `chunk_size` 分别设为 300 / 500 / 800,重新入库并评测,记录哪个最好(数值取决于你的文档和模型,以你自己的结果为准)。 --- ## 第 2 天:BM25 关键词检索 + RRF 混合检索 ### 学习内容 - 说明: - 向量检索擅长“意思相近”,但对**专有名词、错误码、函数名、版本号**这类精确匹配不敏感;BM25 是经典关键词检索算法(Elasticsearch 默认算法),正好互补。两者一起用叫**混合检索**。 - 中文要先分词(jieba),英文按单词。 - 两路结果的分数尺度不同(余弦 0~1,BM25 可能是 0~20),不能直接相加。**RRF(倒数排名融合)**只看排名:每个片段得分 = Σ 1/(k + 排名),k 一般取 60。简单、稳定、不用调权重。 - 坑:rank-bm25 的 `BM25Okapi` 在文档很少时,出现在一半以上文档里的词 IDF 会接近 0 甚至为负,测试时语料要多准备几条。 - 案例: ```python # 文件:rag-qa/src/rag_qa/retrieval/bm25.py import logging import re import jieba from rank_bm25 import BM25Okapi from rag_qa.ingest.chunker import Chunk from rag_qa.vectorstore.base import ScoredChunk jieba.setLogLevel(logging.WARNING) STOP = set("的 了 是 在 和 与 及 或 也 就 都 而 吗 呢 啊 怎么 什么 如何 哪些 为什么 一个".split()) def tokenize(text: str) -> list[str]: words = jieba.lcut(text.lower()) return [w for w in words if w.strip() and w not in STOP and not re.fullmatch(r"\W+", w)] class BM25Index: def __init__(self, chunks: list[Chunk]): self.chunks = chunks self.bm25 = BM25Okapi([tokenize(c.heading_path + " " + c.text) for c in chunks]) if chunks else None def search(self, query: str, top_k: int) -> list[ScoredChunk]: if self.bm25 is None: return [] scores = self.bm25.get_scores(tokenize(query)) ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k] return [ScoredChunk(chunk=self.chunks[i], score=float(scores[i])) for i in ranked if scores[i] > 0] ``` ```python # 文件:rag-qa/src/rag_qa/retrieval/hybrid.py from rag_qa.llm.base import ChatModel, Embedder from rag_qa.qa.answer import QAService from rag_qa.retrieval.bm25 import BM25Index from rag_qa.retrieval.rerank import Reranker from rag_qa.vectorstore.base import ScoredChunk, VectorStore def rrf(*ranked_lists: list[ScoredChunk], k: int = 60) -> list[ScoredChunk]: scores: dict[str, float] = {} by_id: dict[str, ScoredChunk] = {} for lst in ranked_lists: for rank, h in enumerate(lst, start=1): cid = h.chunk.chunk_id scores[cid] = scores.get(cid, 0) + 1 / (k + rank) by_id.setdefault(cid, h) order = sorted(scores, key=scores.get, reverse=True) return [ScoredChunk(chunk=by_id[cid].chunk, score=round(scores[cid], 6)) for cid in order] class HybridQAService(QAService): """只覆盖 retrieve(),回答、流式、引用逻辑全部复用父类。""" def __init__(self, store: VectorStore, embedder: Embedder, chat: ChatModel, *, top_k: int = 4, min_score: float = 0.2, max_context_chars: int = 6000, candidates: int = 20, reranker: Reranker | None = None, rerank_min_score: float = 0.0): super().__init__(store, embedder, chat, top_k, min_score, max_context_chars) self.candidates, self.reranker, self.rerank_min_score = candidates, reranker, rerank_min_score self._bm25: BM25Index | None = None self._version: tuple = () def bm25(self) -> BM25Index: version = tuple((d.doc_id, d.content_hash) for d in self.store.list_docs()) if self._bm25 is None or version != self._version: # 文档有变化才重建索引 self._bm25, self._version = BM25Index(self.store.all_chunks()), version return self._bm25 async def retrieve(self, question: str) -> list[ScoredChunk]: [qv] = await self.embedder.embed([question]) vec = [h for h in self.store.query(qv, self.candidates) if h.score >= self.min_score] kw = self.bm25().search(question, self.candidates) fused = rrf(vec, kw) if self.reranker and fused: fused = [h for h in await self.reranker.rerank(question, fused) if h.score >= self.rerank_min_score] return fused[: self.top_k] ``` ```python # 文件:rag-qa/tests/test_hybrid.py from rag_qa.ingest.chunker import Chunk from rag_qa.ingest.loaders import load_bytes from rag_qa.ingest.pipeline import ingest_document from rag_qa.llm.fake import FakeChat, FakeEmbedder from rag_qa.retrieval.bm25 import BM25Index from rag_qa.retrieval.hybrid import HybridQAService, rrf from rag_qa.vectorstore.base import ScoredChunk from rag_qa.vectorstore.chroma_store import ChromaStore DOC = """# 运维手册 ## 错误码 错误码 E1024 表示磁盘空间不足,需要清理日志目录。 ## 缓存 缓存命中率下降时,先检查过期时间设置。 ## 数据库 慢查询要先看执行计划,再考虑加索引。 ## 部署 部署前先跑完测试,再构建镜像。 ## 监控 监控面板要包含请求量、错误率、延迟。 """ def sc(cid: str) -> ScoredChunk: return ScoredChunk(chunk=Chunk(chunk_id=cid, doc_id="d", filename="f", text=cid, index=0), score=1) def test_rrf_prefers_items_in_both_lists(): fused = rrf([sc("a"), sc("b"), sc("c")], [sc("c"), sc("d")]) assert fused[0].chunk.chunk_id == "c" # 两路都出现,排第一 assert {h.chunk.chunk_id for h in fused} == {"a", "b", "c", "d"} def test_bm25_exact_term(): chunks = [Chunk(chunk_id=str(i), doc_id="d", filename="f", text=t, index=i) for i, t in enumerate(["错误码 E1024 表示磁盘不足", "缓存过期", "执行计划", "镜像构建", "监控面板"])] hits = BM25Index(chunks).search("E1024 是什么错误", top_k=3) assert hits[0].chunk.text.startswith("错误码 E1024") async def test_hybrid_service(tmp_path): store, emb = ChromaStore(str(tmp_path)), FakeEmbedder() await ingest_document(load_bytes("ops.md", DOC.encode()), store, emb) svc = HybridQAService(store, emb, FakeChat("见 [1]"), top_k=2) hits = await svc.retrieve("E1024") assert "磁盘空间不足" in hits[0].chunk.text ans = await svc.answer("E1024") assert ans.citations[0].cited ``` ### 要建的文件 - `retrieval/bm25.py`、`retrieval/hybrid.py`、`tests/test_hybrid.py`(`retrieval/rerank.py` 明天写,今天可以先写一个只有 `Reranker` 协议的版本) ### 运行方式 ```bash uv run pytest tests/test_hybrid.py -v uv run python eval/run_eval.py ``` ### 完成标准 - 测试通过;评测结果追加 `hybrid` 一行。如果评测集里有专有名词、错误码类问题,通常能看到混合检索的提升——**以你自己的数据为准**,没有提升也要记录并分析原因。 - 面试常问:为什么要混合检索?RRF 的公式和优点? --- ## 第 3 天:重排(Rerank) ### 学习内容 - 说明: - 向量检索用的是“双塔”模型:问题和片段**分别**变成向量再比较,快但粗。**重排模型(cross-encoder)**把“问题 + 片段”**放在一起**打分,准但慢。所以流程是:先粗召回 20~50 个候选,再用重排模型精排,取前 K 个。 - 常见重排模型:bge-reranker 系列等;很多模型服务提供 `/rerank` 接口,请求一般是 `{model, query, documents, top_n}`,返回每个文档的 `index` 和 `relevance_score`。**不同服务商的字段可能不同,以服务商文档为准**。 - 重排分数可以再设一个阈值,进一步减少“不相关片段混进上下文”。 - 案例: ```python # 文件:rag-qa/src/rag_qa/retrieval/rerank.py from typing import Protocol import httpx from rag_qa.llm.base import LLMError from rag_qa.llm.fake import tokens from rag_qa.vectorstore.base import ScoredChunk class Reranker(Protocol): async def rerank(self, query: str, hits: list[ScoredChunk]) -> list[ScoredChunk]: ... class ApiReranker: """调用 /rerank 接口。请求和响应字段以你所用服务商的文档为准。""" def __init__(self, client: httpx.AsyncClient, model: str, top_n: int = 8): self.client, self.model, self.top_n = client, model, top_n async def rerank(self, query: str, hits: list[ScoredChunk]) -> list[ScoredChunk]: resp = await self.client.post("/rerank", json={ "model": self.model, "query": query, "documents": [h.chunk.text for h in hits], "top_n": min(self.top_n, len(hits)), }) if resp.status_code >= 400: raise LLMError("RERANK", f"重排服务返回 {resp.status_code}") results = sorted(resp.json()["results"], key=lambda r: r["relevance_score"], reverse=True) return [ScoredChunk(chunk=hits[r["index"]].chunk, score=r["relevance_score"]) for r in results] class OverlapReranker: """假实现:按“问题词在片段里出现的比例”打分。只用于测试和没有重排服务时的演示。""" async def rerank(self, query: str, hits: list[ScoredChunk]) -> list[ScoredChunk]: q = set(tokens(query)) scored = [ScoredChunk(chunk=h.chunk, score=len(q & set(tokens(h.chunk.text))) / (len(q) or 1)) for h in hits] return sorted(scored, key=lambda h: h.score, reverse=True) ``` ```python # 文件:rag-qa/tests/test_rerank.py import json import httpx from rag_qa.ingest.chunker import Chunk from rag_qa.retrieval.rerank import ApiReranker, OverlapReranker from rag_qa.vectorstore.base import ScoredChunk def hits(*texts: str) -> list[ScoredChunk]: return [ScoredChunk(chunk=Chunk(chunk_id=t, doc_id="d", filename="f", text=t, index=i), score=0.5) for i, t in enumerate(texts)] async def test_api_reranker_reorders(): def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) assert body["documents"] == ["甲", "乙"] and body["top_n"] == 2 return httpx.Response(200, json={"results": [{"index": 0, "relevance_score": 0.1}, {"index": 1, "relevance_score": 0.9}]}) async with httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://r.test") as c: out = await ApiReranker(c, "reranker").rerank("问题", hits("甲", "乙")) assert [h.chunk.text for h in out] == ["乙", "甲"] and out[0].score == 0.9 async def test_overlap_reranker(): out = await OverlapReranker().rerank("索引 执行计划", hits("部署镜像", "先看执行计划再加索引")) assert out[0].chunk.text.startswith("先看执行计划") ``` ### 要建的文件 - `retrieval/rerank.py`、`tests/test_rerank.py` ### 运行方式 ```bash uv run pytest tests/test_rerank.py -v uv run python eval/run_eval.py ``` ### 完成标准 - 评测结果追加 `hybrid_rerank` 一行;记下重排带来的**延迟增加**(在 `rag-qa ask` 里看 `latency_ms`),说明“效果和速度的取舍”。 - 面试常问:双塔和交叉编码器的区别?为什么不直接用重排模型检索全部文档?(太慢,每个片段都要和问题一起过一遍模型) --- ## 第 4 天:多轮追问——问题改写 + 会话短期记忆(Redis) ### 学习内容 - 说明: - 用户第二轮常问“那它的缺点呢?”——直接拿去检索肯定找不到。做法:**用模型把追问改写成独立问题**(“Redis 旁路缓存的缺点是什么”),再检索。 - 会话历史存 Redis 列表:`RPUSH` 追加、`LTRIM` 只保留最近 N 轮、`EXPIRE` 设置过期。这是“短期记忆”;跨会话的“长期记忆”放到第 12 周 Agent 项目。 - 历史只用于改写和展示,回答时仍然**只给模型当前检索到的资料**,避免历史里的错误被反复放大。 - 案例: ```python # 文件:rag-qa/src/rag_qa/memory/__init__.py ``` ```python # 文件:rag-qa/src/rag_qa/memory/session.py import json from typing import Protocol from rag_qa.llm.base import Message class SessionStore(Protocol): async def history(self, session_id: str) -> list[Message]: ... async def append(self, session_id: str, question: str, answer: str) -> None: ... class MemorySessionStore: def __init__(self, max_turns: int = 6): self.max_turns = max_turns self.data: dict[str, list[Message]] = {} async def history(self, session_id: str) -> list[Message]: return list(self.data.get(session_id, [])) async def append(self, session_id: str, question: str, answer: str) -> None: items = self.data.setdefault(session_id, []) items += [{"role": "user", "content": question}, {"role": "assistant", "content": answer}] del items[: -self.max_turns * 2] class RedisSessionStore: def __init__(self, redis, max_turns: int = 6, ttl_s: int = 3600): self.r, self.max_turns, self.ttl_s = redis, max_turns, ttl_s def _key(self, session_id: str) -> str: return f"rag:session:{session_id}" async def history(self, session_id: str) -> list[Message]: return [json.loads(x) for x in await self.r.lrange(self._key(session_id), 0, -1)] async def append(self, session_id: str, question: str, answer: str) -> None: key = self._key(session_id) async with self.r.pipeline(transaction=True) as p: # 一次往返完成四个命令 p.rpush(key, json.dumps({"role": "user", "content": question}, ensure_ascii=False), json.dumps({"role": "assistant", "content": answer}, ensure_ascii=False)) p.ltrim(key, -self.max_turns * 2, -1) p.expire(key, self.ttl_s) await p.execute() ``` ```python # 文件:rag-qa/src/rag_qa/qa/rewrite.py from rag_qa.llm.base import ChatModel, Message from rag_qa.memory.session import SessionStore from rag_qa.qa.answer import Answer, QAService REWRITE = """根据对话历史,把用户最后的追问改写成一个不依赖上下文、可以单独检索的完整问题。 只输出改写后的问题,不要回答,不要解释。""" async def rewrite_question(chat: ChatModel, history: list[Message], question: str) -> str: if not history: return question # 第一轮不需要改写,省一次模型调用 convo = "\n".join(f"{m['role']}: {m['content'][:300]}" for m in history[-6:]) out = (await chat.complete([ {"role": "system", "content": REWRITE}, {"role": "user", "content": f"对话历史:\n{convo}\n\n追问:{question}"}, ])).strip() return out if 0 < len(out) <= 200 else question # 改写结果异常就退回原问题 class ConversationalQA: def __init__(self, qa: QAService, sessions: SessionStore, rewriter: ChatModel): self.qa, self.sessions, self.rewriter = qa, sessions, rewriter async def ask(self, session_id: str, question: str) -> tuple[str, Answer]: history = await self.sessions.history(session_id) standalone = await rewrite_question(self.rewriter, history, question) ans = await self.qa.answer(standalone) await self.sessions.append(session_id, question, ans.text) return standalone, ans ``` ```python # 文件:rag-qa/tests/test_memory.py import os import pytest from rag_qa.ingest.loaders import load_bytes from rag_qa.ingest.pipeline import ingest_document from rag_qa.llm.fake import FakeChat, FakeEmbedder from rag_qa.memory.session import MemorySessionStore, RedisSessionStore from rag_qa.qa.answer import QAService from rag_qa.qa.rewrite import ConversationalQA from rag_qa.vectorstore.chroma_store import ChromaStore class ScriptedChat(FakeChat): """改写请求返回固定问题,其余请求返回固定回答。""" async def complete(self, messages): self.received.append(messages) if "改写" in messages[0]["content"]: return "旁路缓存的缺点是什么" return "可能出现短暂不一致 [1]。" async def test_follow_up_is_rewritten(tmp_path): store, emb, chat = ChromaStore(str(tmp_path)), FakeEmbedder(), ScriptedChat() doc = "# 缓存\n## 旁路缓存\n旁路缓存的缺点是可能出现短暂的数据不一致。\n## 其他\n无关内容。" await ingest_document(load_bytes("cache.md", doc.encode()), store, emb) conv = ConversationalQA(QAService(store, emb, chat), MemorySessionStore(), chat) q1, _ = await conv.ask("s1", "什么是旁路缓存") q2, ans = await conv.ask("s1", "那它的缺点呢") assert q1 == "什么是旁路缓存" # 第一轮不改写 assert q2 == "旁路缓存的缺点是什么" and ans.citations async def test_memory_store_keeps_last_turns(): s = MemorySessionStore(max_turns=2) for i in range(3): await s.append("s", f"q{i}", f"a{i}") assert [m["content"] for m in await s.history("s")] == ["q1", "a1", "q2", "a2"] @pytest.mark.skipif(not os.getenv("REDIS_URL"), reason="需要 REDIS_URL,例如 redis://localhost:6379/0") async def test_redis_session_store(): import redis.asyncio as redis r = redis.from_url(os.environ["REDIS_URL"], decode_responses=True) s = RedisSessionStore(r, max_turns=2, ttl_s=60) await r.delete("rag:session:test") for i in range(3): await s.append("test", f"q{i}", f"a{i}") assert [m["content"] for m in await s.history("test")] == ["q1", "a1", "q2", "a2"] assert 0 < await r.ttl("rag:session:test") <= 60 await r.aclose() ``` **易错点 / 面试常问(补充)** - 易错点:多轮追问时直接拿“它呢?”去检索会什么都找不到,要先结合历史把问题改写成完整的问题,再检索。 - 易错点:会话历史要限制条数或长度,并设置过期时间(本项目用 Redis 过期),否则越聊越慢、越贵。 - 面试常问:短期记忆和长期记忆的区别(当前会话上下文 / 跨会话的用户偏好和事实);问题改写失败会有什么后果,怎么兜底(改写结果为空时用原问题)。 ### 要建的文件 - `memory/__init__.py`、`memory/session.py`、`qa/rewrite.py`、`tests/test_memory.py` ### 运行方式 ```bash docker run -d --name rag-redis -p 6379:6379 redis:7 # 第 3 周装过可以复用 REDIS_URL=redis://localhost:6379/0 uv run pytest tests/test_memory.py -v ``` ### 完成标准 - 三个测试都通过(Redis 测试没有跳过);用真实模型连续追问 3 轮,看改写结果是否合理。 --- ## 第 5 天:缓存、配置组装、多轮对话接口 ### 学习内容 - 说明: - **向量缓存**:同一段文字的向量永远一样,按“模型名 + 文字哈希”缓存,重复入库、重复问题都省钱。键里必须带模型名,换模型就自动失效。 - **答案缓存**:同一问题短时间内重复问,直接返回。难点是**失效**:文档一更新,旧答案可能就错了。做法是把“索引版本”(全部文档内容哈希的摘要)放进缓存键,文档一变键就变,旧缓存自然不再命中,过期后自动清理。 - 这里用“装饰器模式”:`CachedEmbedder` 实现同样的 `Embedder` 协议,包在真实实现外面,业务代码完全不用改。 - 第 3 周学过的缓存问题(穿透、击穿、雪崩)在这里同样存在,面试时可以结合讲。 - 案例: ```python # 文件:rag-qa/src/rag_qa/cache.py import hashlib import json import time from typing import Protocol from rag_qa.llm.base import Embedder from rag_qa.qa.answer import Answer, QAService class KV(Protocol): async def get(self, key: str) -> str | None: ... async def set(self, key: str, value: str, ttl_s: int) -> None: ... class MemoryKV: def __init__(self): self.data: dict[str, tuple[float, str]] = {} async def get(self, key: str) -> str | None: item = self.data.get(key) if item is None or item[0] < time.time(): return None return item[1] async def set(self, key: str, value: str, ttl_s: int) -> None: self.data[key] = (time.time() + ttl_s, value) class RedisKV: def __init__(self, redis): self.r = redis async def get(self, key: str) -> str | None: return await self.r.get(key) async def set(self, key: str, value: str, ttl_s: int) -> None: await self.r.set(key, value, ex=ttl_s) def sha(text: str) -> str: return hashlib.sha1(text.encode()).hexdigest() class CachedEmbedder: def __init__(self, inner: Embedder, kv: KV, model: str, ttl_s: int = 7 * 86400): self.inner, self.kv, self.model, self.ttl_s = inner, kv, model, ttl_s async def embed(self, texts: list[str]) -> list[list[float]]: keys = [f"rag:emb:{self.model}:{sha(t)}" for t in texts] cached = [await self.kv.get(k) for k in keys] # 生产环境用 MGET / pipeline 一次取回 missing = [i for i, v in enumerate(cached) if v is None] fresh = await self.inner.embed([texts[i] for i in missing]) if missing else [] for i, vec in zip(missing, fresh): await self.kv.set(keys[i], json.dumps(vec), self.ttl_s) cached[i] = json.dumps(vec) return [json.loads(v) for v in cached] class CachedQA: def __init__(self, qa: QAService, kv: KV, ttl_s: int = 600): self.qa, self.kv, self.ttl_s = qa, kv, ttl_s def _key(self, question: str) -> str: version = sha("|".join(f"{d.doc_id}:{d.content_hash}" for d in self.qa.store.list_docs())) return f"rag:ans:{version[:12]}:{sha(' '.join(question.lower().split()))}" async def answer(self, question: str) -> Answer: key = self._key(question) if (hit := await self.kv.get(key)) is not None: return Answer.model_validate_json(hit) ans = await self.qa.answer(question) await self.kv.set(key, ans.model_dump_json(), self.ttl_s) return ans ``` ```python # 文件:rag-qa/tests/test_cache.py from rag_qa.cache import CachedEmbedder, CachedQA, MemoryKV from rag_qa.ingest.loaders import load_bytes from rag_qa.ingest.pipeline import ingest_document from rag_qa.llm.fake import FakeChat, FakeEmbedder from rag_qa.qa.answer import QAService from rag_qa.vectorstore.chroma_store import ChromaStore async def test_cached_embedder_only_embeds_missing(): inner = FakeEmbedder() emb = CachedEmbedder(inner, MemoryKV(), model="fake") a = await emb.embed(["甲", "乙"]) b = await emb.embed(["乙", "丙"]) assert inner.calls == 2 and a[1] == b[0] async def test_answer_cache_invalidated_by_doc_change(tmp_path): store, emb, chat = ChromaStore(str(tmp_path)), FakeEmbedder(), FakeChat("答 [1]") await ingest_document(load_bytes("a.md", "# 甲\n甲的内容是一".encode()), store, emb) qa = CachedQA(QAService(store, emb, chat), MemoryKV()) await qa.answer("甲的内容") await qa.answer(" 甲的内容 ") # 空白不同也命中缓存 assert len(chat.received) == 1 await ingest_document(load_bytes("a.md", "# 甲\n甲的内容是二".encode()), store, emb) await qa.answer("甲的内容") assert len(chat.received) == 2 # 文档更新后不再使用旧答案 ``` 把各部分按配置组装起来(覆盖第 5 周的 `config.py` 和 `container.py`): ```python # 文件:rag-qa/src/rag_qa/config.py from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") llm_base_url: str = "" llm_api_key: str = "" chat_model: str = "" embed_model: str = "" rerank_model: str = "" # 为空时用假重排 embed_batch: int = 32 chroma_path: str = "data/chroma" collection: str = "rag_chunks" chunk_size: int = 500 chunk_overlap: int = 80 top_k: int = 4 min_score: float = 0.2 max_context_chars: int = 6000 max_upload_mb: int = 10 retrieval_mode: Literal["vector", "hybrid", "hybrid_rerank"] = "hybrid" redis_url: str = "" # 为空时缓存和会话放内存(只适合单进程开发) @property def use_fake(self) -> bool: return not self.llm_api_key ``` ```python # 文件:rag-qa/src/rag_qa/container.py from dataclasses import dataclass import httpx from rag_qa.cache import KV, CachedEmbedder, CachedQA, MemoryKV, RedisKV from rag_qa.config import Settings from rag_qa.llm.base import ChatModel, Embedder from rag_qa.llm.fake import FakeChat, FakeEmbedder from rag_qa.llm.openai_compat import OpenAICompatChat, OpenAICompatEmbedder from rag_qa.memory.session import MemorySessionStore, RedisSessionStore, SessionStore from rag_qa.qa.answer import QAService from rag_qa.qa.rewrite import ConversationalQA from rag_qa.retrieval.hybrid import HybridQAService from rag_qa.retrieval.rerank import ApiReranker, OverlapReranker, Reranker from rag_qa.vectorstore.base import VectorStore from rag_qa.vectorstore.chroma_store import ChromaStore @dataclass class Container: settings: Settings store: VectorStore embedder: Embedder chat: ChatModel qa: QAService kv: KV sessions: SessionStore conv: ConversationalQA cached_qa: CachedQA def build_container(settings: Settings | None = None, store: VectorStore | None = None, embedder: Embedder | None = None, chat: ChatModel | None = None, mode: str | None = None) -> Container: s = settings or Settings() mode = mode or s.retrieval_mode client = None if not s.use_fake: client = httpx.AsyncClient(base_url=s.llm_base_url, timeout=60, headers={"Authorization": f"Bearer {s.llm_api_key}"}) embedder = embedder or (OpenAICompatEmbedder(client, s.embed_model, s.embed_batch) if client else FakeEmbedder()) chat = chat or (OpenAICompatChat(client, s.chat_model) if client else FakeChat()) if s.redis_url: import redis.asyncio as redis r = redis.from_url(s.redis_url, decode_responses=True) kv: KV = RedisKV(r) sessions: SessionStore = RedisSessionStore(r) else: kv, sessions = MemoryKV(), MemorySessionStore() embedder = CachedEmbedder(embedder, kv, s.embed_model or "fake") store = store or ChromaStore(s.chroma_path, s.collection) if mode == "vector": qa = QAService(store, embedder, chat, s.top_k, s.min_score, s.max_context_chars) else: reranker: Reranker | None = None if mode == "hybrid_rerank": reranker = ApiReranker(client, s.rerank_model) if client and s.rerank_model else OverlapReranker() qa = HybridQAService(store, embedder, chat, top_k=s.top_k, min_score=s.min_score, max_context_chars=s.max_context_chars, reranker=reranker) return Container(s, store, embedder, chat, qa, kv, sessions, ConversationalQA(qa, sessions, chat), CachedQA(qa, kv)) ``` 多轮对话接口单独放一个模块,在 `create_app` 的 `return app` 之前加一行注册: ```python # 片段:rag-qa/src/rag_qa/api/app.py 的 create_app() 里 from rag_qa.api.chat import register_chat_routes register_chat_routes(app, c) return app ``` ```python # 文件:rag-qa/src/rag_qa/api/chat.py from fastapi import FastAPI from pydantic import BaseModel, Field from rag_qa.container import Container from rag_qa.qa.answer import Answer class ChatIn(BaseModel): session_id: str = Field(min_length=1, max_length=64) question: str = Field(min_length=1, max_length=500) class ChatOut(BaseModel): standalone_question: str answer: Answer def register_chat_routes(app: FastAPI, c: Container) -> None: @app.post("/chat", response_model=ChatOut) async def chat(body: ChatIn): q, ans = await c.conv.ask(body.session_id, body.question) return ChatOut(standalone_question=q, answer=ans) @app.get("/chat/{session_id}/history") async def history(session_id: str): return await c.sessions.history(session_id) ``` ```python # 文件:rag-qa/tests/test_chat_api.py from fastapi.testclient import TestClient from rag_qa.api.app import create_app from rag_qa.api.chat import register_chat_routes from rag_qa.config import Settings from rag_qa.container import build_container from rag_qa.vectorstore.chroma_store import ChromaStore def test_chat_routes(tmp_path): c = build_container(Settings(llm_api_key="", redis_url=""), store=ChromaStore(str(tmp_path))) app = create_app(c) register_chat_routes(app, c) with TestClient(app) as client: flow(client) def flow(client: TestClient): client.post("/documents", files={"file": ("go.md", "# Go\ngoroutine 用 channel 通信".encode(), "text/markdown")}) r = client.post("/chat", json={"session_id": "s1", "question": "goroutine 怎么通信"}) assert r.status_code == 200 and r.json()["answer"]["citations"] assert len(client.get("/chat/s1/history").json()) == 2 ``` **易错点 / 面试常问(补充)** - 易错点:答案缓存的键要包含“知识库版本”,否则文档更新后还会返回旧答案(本项目用文档哈希算版本)。 - 易错点:流式接口和多轮对话的回答一般不缓存,或者只缓存嵌入向量(同一段文字的向量是固定的)。 - 面试常问:缓存什么最划算(嵌入向量、高频问题的答案);缓存穿透、击穿、雪崩分别是什么(第 14 周有专题)。 ### 要建的文件 - `cache.py`、`config.py`(覆盖)、`container.py`(覆盖)、`api/chat.py`、在 `api/app.py` 里注册路由、`tests/test_cache.py`、`tests/test_chat_api.py` ### 运行方式 ```bash uv run pytest -v REDIS_URL=redis://localhost:6379/0 uv run rag-qa ask "……" # 问两次,第二次看 latency_ms redis-cli --scan --pattern 'rag:*' | head ``` ### 完成标准 - 全部测试通过;能讲清“答案缓存怎么失效”和“为什么向量缓存键要带模型名”。 --- ## 第 6 天:LangChain 对照实现 + Milvus / pgvector ### 学习内容 **1. LangChain LCEL** - 说明:LangChain 的 LCEL 用 `|` 把“检索 → 提示词 → 模型 → 解析”串成一条链,每个环节都是 `Runnable`,自带 `invoke` / `ainvoke` / `stream` / `batch`。它帮你省掉的是**胶水代码**;检索策略、阈值、拒答、引用这些**核心逻辑仍然要你自己设计**——这就是先手写的意义,面试时这样讲很加分。 - 接入真实模型可以用 `init_chat_model` 或 `langchain-openai` 的 `ChatOpenAI(base_url=...)`;向量库有 `langchain-chroma`、`langchain-milvus` 等集成包。LangChain 版本更新快,**接口以官方文档为准**:https://python.langchain.com/ (LCEL 概念:https://python.langchain.com/docs/concepts/lcel/ ) - 案例:复用我们自己的检索和提示词,只把编排换成 LCEL。 ```python # 文件:rag-qa/src/rag_qa/lc_pipeline.py from langchain_core.language_models import BaseChatModel from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import Runnable, RunnableLambda, RunnablePassthrough from rag_qa.qa.answer import QAService from rag_qa.qa.prompts import NOT_FOUND, SYSTEM, build_context def build_chain(qa: QAService, llm: BaseChatModel) -> Runnable: async def retrieve(question: str) -> str: hits = await qa.retrieve(question) context, _ = build_context(hits, qa.max_context_chars) return context prompt = ChatPromptTemplate.from_messages([ ("system", SYSTEM), ("human", "<资料>\n{context}\n\n\n问题:{question}"), # {} 是模板变量 ]) answer_chain = prompt | llm | StrOutputParser() async def route(inputs: dict) -> str: # 没检索到资料就不调模型 if not inputs["context"]: return NOT_FOUND return await answer_chain.ainvoke(inputs) return {"context": RunnableLambda(retrieve), "question": RunnablePassthrough()} | RunnableLambda(route) ``` ```python # 文件:rag-qa/tests/test_lc_pipeline.py from langchain_core.language_models.fake_chat_models import FakeListChatModel from rag_qa.ingest.loaders import load_bytes from rag_qa.ingest.pipeline import ingest_document from rag_qa.lc_pipeline import build_chain from rag_qa.llm.fake import FakeChat, FakeEmbedder from rag_qa.qa.answer import QAService from rag_qa.qa.prompts import NOT_FOUND from rag_qa.vectorstore.chroma_store import ChromaStore async def test_lcel_chain(tmp_path): store, emb = ChromaStore(str(tmp_path)), FakeEmbedder() await ingest_document(load_bytes("k8s.md", "# K8s\nPod 是最小调度单位。".encode()), store, emb) chain = build_chain(QAService(store, emb, FakeChat()), FakeListChatModel(responses=["Pod 是最小调度单位 [1]"])) assert await chain.ainvoke("Pod 是什么") == "Pod 是最小调度单位 [1]" assert await chain.ainvoke("今天天气") == NOT_FOUND ``` **对照表(写进 README)** | 环节 | 手写版 | LangChain 版 | | --- | --- | --- | | 模型调用 | `OpenAICompatChat`(httpx) | `ChatOpenAI` / `init_chat_model` | | 编排 | 普通 async 函数 | LCEL `\|` 组合 Runnable | | 流式 | 自己解析 `data:` 行 | `astream()` | | 检索、阈值、拒答、引用 | 自己写 | **仍然自己写**(或用现成 Retriever 再定制) | | 优点 | 每一步可控、好调试 | 生态集成多、换组件快 | | 缺点 | 胶水代码多 | 抽象层多,出问题要读源码;版本变化快 | **2. Milvus 和 pgvector** - 说明: - **Milvus**:专门的分布式向量数据库,适合千万级以上向量、需要水平扩展的场景,部署比 Chroma 重(单机版也要 etcd、MinIO 等组件);有 Milvus Lite 可以本地体验。 - **pgvector**:PostgreSQL 的向量扩展。好处是向量和业务数据在同一个库里,可以一条 SQL 同时做向量检索和权限过滤、事务一致;规模中等时很实用。 - **Chroma**:上手最快,适合原型和中小规模。 - 换库只需要再写一个实现 `VectorStore` 协议的类,其他代码不动——面试时讲“我是怎么为替换做准备的”。 - 案例:pgvector 版的核心 SQL(需要装好 pgvector 扩展,例如用 `pgvector/pgvector:pg17` 镜像): ```sql CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE rag_chunks ( chunk_id text PRIMARY KEY, doc_id text NOT NULL, filename text NOT NULL, heading_path text NOT NULL DEFAULT '', text text NOT NULL, embedding vector(1024) NOT NULL -- 维度必须和向量模型一致 ); CREATE INDEX ON rag_chunks USING hnsw (embedding vector_cosine_ops); CREATE INDEX ON rag_chunks (doc_id); -- <=> 是余弦距离,相似度 = 1 - 距离;$1 是问题向量 SELECT chunk_id, text, 1 - (embedding <=> $1) AS score FROM rag_chunks ORDER BY embedding <=> $1 LIMIT 4; ``` Milvus 的 Python 用法示意(`pymilvus` 的 `MilvusClient`,接口以官方文档为准:https://milvus.io/docs ): ```python # 不自动运行:需要 uv add pymilvus 和一个 Milvus 服务(或 Milvus Lite 本地文件) from pymilvus import MilvusClient client = MilvusClient("milvus_demo.db") # 本地 Milvus Lite;连服务器时传 uri="http://host:19530" client.create_collection(collection_name="rag_chunks", dimension=4, metric_type="COSINE") client.insert("rag_chunks", [{"id": 1, "vector": [0.1, 0.2, 0.3, 0.4], "text": "示例", "doc_id": "d1"}]) res = client.search("rag_chunks", data=[[0.1, 0.2, 0.3, 0.4]], limit=3, output_fields=["text", "doc_id"]) print(res) ``` | | Chroma | Milvus | pgvector | | --- | --- | --- | --- | | 定位 | 轻量、嵌入式或单服务 | 专业分布式向量库 | PostgreSQL 扩展 | | 规模 | 中小 | 大 | 中 | | 运维 | 最简单 | 最重 | 复用已有 PostgreSQL | | 和业务数据联查 | 弱 | 弱 | 强(同一条 SQL、同一个事务) | | 本项目 | 默认 | 了解 + 选做 | 选做 | **易错点 / 面试常问(补充)** - 易错点:LangChain 的包拆得很细(`langchain-core`、`langchain-openai`、各种集成包),网上旧教程的导入路径经常已经失效,以官方文档为准:https://docs.langchain.com/oss/python/langchain/overview 。 - 易错点:换向量库(Milvus、pgvector)时,距离类型(余弦 / 内积 / 欧氏)要和嵌入模型的要求一致,否则分数没有意义。 - 面试常问:Chroma、Milvus、pgvector 怎么选(原型和小规模用 Chroma;数据量大、要分布式用 Milvus;已经有 PostgreSQL 且规模不大用 pgvector,事务和过滤方便);什么是 HNSW 索引(近似最近邻,用一点精度换速度)。 ### 要建的文件 - `lc_pipeline.py`、`tests/test_lc_pipeline.py`;README 加对照表和向量库对比 - 选做:`vectorstore/pgvector_store.py`(复用第 2 周的 PostgreSQL 知识,`uv add "psycopg[binary]" pgvector`) ### 运行方式 ```bash uv run pytest tests/test_lc_pipeline.py -v ``` ### 完成标准 - 能回答:“LangChain 帮你做了什么,没做什么?”“Chroma、Milvus、pgvector 怎么选?” --- ## 第 7 天:Docker Compose 部署 + 复盘 ### 学习内容 - 说明: - Python 镜像用 `python:3.12-slim` + uv:先只复制依赖文件安装依赖(利用缓存层),再复制代码。 - 数据卷:Chroma 数据(`/app/data`)必须挂卷,否则容器重建数据全丢。 - Chroma 也可以作为独立服务运行(官方镜像 `chromadb/chroma`),这时代码改用 `chromadb.HttpClient(host=..., port=...)`;本项目单实例先用嵌入式。 - 多个 API 实例时:会话和缓存必须放 Redis(内存版各实例互相看不到),嵌入式 Chroma 不能被多个进程同时写——要么单实例,要么换成独立的向量库服务。 - 案例: ```dockerfile FROM python:3.12-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy COPY pyproject.toml uv.lock README.md ./ RUN uv sync --frozen --no-dev --no-install-project COPY src ./src RUN uv sync --frozen --no-dev ENV PATH="/app/.venv/bin:$PATH" EXPOSE 8000 CMD ["uvicorn", "rag_qa.api.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] ``` ```yaml services: api: build: . env_file: .env environment: REDIS_URL: redis://redis:6379/0 CHROMA_PATH: /app/data/chroma volumes: - rag-data:/app/data ports: - "8000:8000" depends_on: redis: condition: service_healthy restart: unless-stopped redis: image: redis:7 healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s retries: 10 volumes: rag-data: ``` **易错点 / 面试常问(补充)** - 易错点:Chroma 的数据目录要挂到数据卷上,否则重建容器后索引就没了。 - 面试常问:RAG 上线后怎么持续优化(收集答不好的问题 → 加进评测集 → 调切分 / 检索 / 提示词 → 对比指标)。 ### 要建的文件 - `Dockerfile`、`docker-compose.yml`、`.dockerignore`(`.venv`、`data`、`.env`、`__pycache__`) - `eval/results.md` 整理成表格;README 补“评测结果”“设计取舍”“对照表” ### 运行方式 ```bash docker compose up -d --build curl localhost:8000/health curl -F "file=@samples/你的文件.md" localhost:8000/documents docker compose restart api && curl localhost:8000/documents # 重启后数据还在 ``` ### 完成标准 - Compose 一键启动,重启后数据不丢;部署到服务器的事放到第 14 周统一做。 - 能脱稿回答: 1. 你的评测集怎么建的?指标是什么?各项优化分别带来了什么变化? 2. 混合检索和重排分别解决什么问题?代价是什么? 3. 多轮追问怎么处理?会话存哪里? 4. 缓存怎么失效? 5. LangChain 帮你做了什么? 6. 向量库怎么选? --- ## 本周自查清单 - [ ] 评测集 ≥ 30 条(含拒答题),会算 Hit@K、MRR、拒答准确率 - [ ] `eval/results.md` 有基线、切分实验、混合检索、重排的对比记录 - [ ] 混合检索(BM25 + 向量 + RRF)和重排都有测试 - [ ] 多轮追问改写 + Redis 会话记忆(真实 Redis 测试通过) - [ ] 向量缓存、答案缓存(带失效策略) - [ ] LangChain LCEL 对照实现,能讲清手写和框架的取舍 - [ ] 了解 Milvus、pgvector,能说出选型理由 - [ ] Docker Compose 部署,数据持久化