1209 lines
48 KiB
Markdown
1209 lines
48 KiB
Markdown
---
|
||
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 部署,数据持久化
|