Files
zhuangang/plan/week06-rag-2.md
T

1209 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"<!-- {mode} 失败:{m} -->")
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 部署,数据持久化