Day45

知识库模块:导入、切块、混合检索、引用

把 W3 的 RAG 代码产品化:按小标题切块并带上下文前缀、按内容 hash 增量导入 pgvector、向量 + BM25 用 RRF 融合、可选 rerank、引用编号检查和渲染。最后跑一份只评检索的基线:纯 BM25 在 31 条种子查询上 recall@5 是 0.935,漏掉的两条正好说明为什么还需要向量。

读完约 54 分钟

基础篇 · 第 45 天,共 60 天

今天要解决的问题

W3 你写过一个能跑的 RAG:切块、向量化、检索、拼 prompt。能跑和能上线之间差了好几件事,今天补齐:

  1. 知识库会更新。改了一篇文章,不能把 62 篇全部重新向量化,要能增量导入,而且导入一半失败不能留下半篇。
  2. 客户会写报错码、套餐名、“专票”这种字面词。纯向量检索对这类词不敏感,要加关键词检索,两路结果用 RRF 融合。
  3. 回复里要标出处,出处要能检查(有没有编造编号)、能渲染成客户点得开的链接。
  4. 检索好不好要有数字。今天跑出第一份检索基线,以后每改一次检索都对比。

今天结束时,make kb 能把帮助中心导入数据库;给一句客户的话,能返回 5 段带编号的资料;evals/retrieval_eval.py 能打印三种检索方式的 recall@5 和 MRR。

预计用时 4–5 小时。前置:Day 15–19(embedding、RAG、pgvector、混合检索、RAG 评估),Day 44 的数据。需要 EMB_API_KEY,62 篇文章切出来 150 块,向量化一次的费用不到一分钱。

切块:按小标题切,再给每块戴个”帽子”

帮助中心文章很短,结构规整:一个标题,几个小标题,每节一两段。这种文章最自然的切法就是按 ## 小标题切,一节一块。只有某一节特别长时,才按句子滑窗再切。

# supportpilot/src/supportpilot/kb/chunker.py
import hashlib
import re
from dataclasses import dataclass


@dataclass
class Doc:
    id: str
    title: str
    category: str
    url_path: str
    updated_at: str
    body: str  # 去掉 frontmatter 后的 markdown

    @property
    def content_hash(self) -> str:
        return hashlib.sha256((self.title + self.body).encode("utf-8")).hexdigest()[:16]


@dataclass
class Chunk:
    doc_id: str
    index: int
    heading: str
    text: str  # 原文片段,给模型看、给人看
    embed_text: str  # 送去向量化的文本:带上标题和小标题,检索更准


def parse_markdown(raw: str) -> Doc:
    m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S)
    if not m:
        raise ValueError("缺少 frontmatter")
    meta = dict(line.split(": ", 1) for line in m.group(1).splitlines() if ": " in line)
    return Doc(id=meta["id"], title=meta["title"], category=meta["category"], url_path=meta["url_path"],
               updated_at=meta["updated_at"], body=m.group(2).strip())


def chunk_doc(doc: Doc, max_chars: int = 400, overlap: int = 60) -> list[Chunk]:
    """先按 ## 小标题切,一节太长再按句子滑窗切。帮助中心文章短,大部分一节就是一块。"""
    sections = re.split(r"^## +", doc.body, flags=re.M)
    chunks: list[Chunk] = []
    for sec in sections[1:]:  # sections[0] 是 "# 标题",跳过
        heading, _, text = sec.partition("\n")
        text = text.strip()
        for piece in _window(text, max_chars, overlap):
            chunks.append(Chunk(doc_id=doc.id, index=len(chunks), heading=heading.strip(), text=piece,
                                embed_text=f"{doc.title} / {heading.strip()}\n{piece}"))
    return chunks


def _window(text: str, max_chars: int, overlap: int) -> list[str]:
    if len(text) <= max_chars:
        return [text]
    sents = re.split(r"(?<=[。!?;\n])", text)
    out: list[str] = []
    cur = ""
    for s in sents:
        if len(cur) + len(s) > max_chars and cur:
            out.append(cur)
            cur = cur[-overlap:]  # 带一点上一块的尾巴,防止一句话被切断后两边都看不懂
        cur += s
    if cur.strip():
        out.append(cur)
    return out
  • import hashlib 后面算 content_hash 用,增量导入靠它判断文章变没变。
  • @dataclass 自动生成 __init__,Doc(id=..., title=...) 就能建对象,不用手写构造函数。
  • class Doc: 一篇帮助中心文章。拿 KB-refund-002 实测,解析出来是 id='KB-refund-002'、title='年付 14 天无理由退款'、category='refund'、url_path='/help/refund/002'、updated_at='2026-09-15'。
  • body: str 去掉 frontmatter 之后的正文,开头是 '# 年付 14 天无理由退款\n\n## 条件\n\n首次购买的年付订阅…'。
  • @property 把方法变成属性,读的时候写 doc.content_hash,不带括号。每次读都重新算一遍,不存起来。
  • return hashlib.sha256((self.title + self.body) 标题和正文拼起来算 SHA-256,取前 16 个十六进制字符。实测 KB-refund-002 的值是 13fdf7614f8c3732。改标题或改正文都会让它变,改 updated_at 不会,因为它不在里面。
  • class Chunk: 一个块。index 是这篇文章里的第几块,从 0 开始。
  • text: str 给模型看、给人看的原文片段。
  • embed_text: str 拿去向量化的文本,前面多了「标题 / 小标题」。下面 chunk_doc 会拼出来。
  • def parse_markdown(raw: str) -> Doc: 把一个 .md 文件的全文解析成 Doc。
  • m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S) 两个分组:第 1 组是两条 --- 之间的 frontmatter,第 2 组是剩下的正文。re.S 让 . 也能匹配换行,不然 .* 到第一行末尾就停了。.*? 是非贪婪,遇到第一个 \n---\n 就结束。
  • raise ValueError("缺少 frontmatter") 实测传 "# no fm" 进来,报的就是 ValueError: 缺少 frontmatter。
  • meta = dict(line.split(": ", 1) 每行按第一个 ": " 切成两半,比如 'title: 年付 14 天无理由退款' 切成 ['title', '年付 14 天无理由退款'],dict() 把这些两元素列表变成字典。split 的第二个参数 1 表示只切一刀,值里再出现冒号也不怕。if ": " in line 把空行这类不合格的行过滤掉。
  • return Doc(id=meta["id"] 按名字取出 5 个字段,正文 .strip() 去掉首尾空白。少任何一个字段都会抛 KeyError,这是故意的,文章格式不对就该停下来。
  • def chunk_doc(doc: Doc, max_chars: int = 400, overlap: int = 60) 先按 ## 切,一节超过 400 字再滑窗。实测 62 篇文章切出 150 块,最长 178 字,一次滑窗都没触发。
  • sections = re.split(r"^## +", doc.body, flags=re.M) re.M 让 ^ 匹配每一行的开头,不只是整个字符串的开头。KB-refund-002 切出 3 段:'# 年付 14 天无理由退款\n'、'条件\n\n首次购买…'、'不适用\n\n续费订单…'。
  • for sec in sections[1:]: 第 0 段是 # 标题 那一行,没有内容,跳过。
  • heading, _, text = sec.partition("\n") partition 按第一个换行切成三部分:前面、分隔符本身、后面。'条件\n\n首次购买…' 得到 heading='条件'、text='\n首次购买…'。中间那个分隔符用不上,用 _ 接住。
  • for piece in _window(text, max_chars, overlap): 一节不长时 _window 原样返回 [text],循环只走一次,一节就是一块。
  • chunks.append(Chunk(doc_id=doc.id, index=len(chunks) index 用 len(chunks),也就是追加之前已经有几块,天然从 0 递增。实测 KB-refund-002 得到两块:0 '条件' 46 字,1 '不适用' 42 字。
  • embed_text=f"{doc.title} / {heading.strip()}\n{piece}" 第 0 块的 embed_text 是 '年付 14 天无理由退款 / 条件\n首次购买的年付订阅,从付款日起 14 天内…'。
  • def _window(text: str, max_chars: int, overlap: int) 按句子滑窗。前面的下划线表示模块内部用,别人别直接调。
  • if len(text) <= max_chars: 不超长就直接返回,帮助中心的文章全走这条。
  • sents = re.split(r"(?<=[。!?;\n])", text) (?<=…) 是后行断言:在这些标点「后面」切,标点留在前一句末尾,不会丢。注意中括号里的 !?; 是半角,中文全角的 ! ? 不在里面,遇到全角感叹号不会切开。
  • if len(cur) + len(s) > max_chars and cur: 再加这一句就超长了,先把当前攒的这块收走。and cur 防止一句话本身就超过 400 字时追加一个空块。
  • cur = cur[-overlap:] 新块从上一块的最后 60 个字开始。我拿一段 310 字、每句 5 字的文本、max_chars=100, overlap=20 实测,切出 4 块,长度是 [100, 98, 100, 72],第 2 块开头就是第 1 块末尾那 20 个字。
  • if cur.strip(): 循环结束还剩一点没收走,收进最后一块。

这里有两个字段值得多看一眼:text 和 embed_text。

text 是原文,给模型看,也给人看。embed_text 是拿去向量化的,前面加了”标题 / 小标题”。为什么要加?看一个例子:退款政策总览里有一节叫”月付”,正文只有一句”月付订阅不支持退款,可以关闭自动续费”。单独拿这一句去向量化,它和”关闭自动续费怎么操作”那篇文章的向量会挨得很近。加上”退款政策总览 / 月付”这个前缀,向量里就带上了”这是在讲退款”的信息。

这是 Anthropic 那篇 Contextual Retrieval 文章的简化版。原文的做法是让模型给每块生成一段上下文描述再拼上去,效果更好但要为每块调一次模型。我们的文章结构清楚,标题本身就是很好的上下文,直接拼标题,零成本。

切块参数 max_chars=400, overlap=60 是按我们的文章定的:62 篇文章切完 150 块,最长的一块 178 个字,实际上没有一节触发滑窗。参数留着是给以后长文章用的。别照抄别人的”512 token、重叠 50”这种数字,先看你自己的文档长什么样。我的做法是切完打印一下块数和最大长度,心里有数就行。

向量化和增量导入

# supportpilot/src/supportpilot/kb/embed.py
from openai import OpenAI

from ..config import Settings, get_settings


class Embedder:
    def __init__(self, settings: Settings | None = None):
        self.s = settings or get_settings()
        self.client = OpenAI(base_url=self.s.emb_base_url, api_key=self.s.emb_api_key, max_retries=3)

    def embed(self, texts: list[str], batch: int = 32) -> list[list[float]]:
        out: list[list[float]] = []
        for i in range(0, len(texts), batch):
            resp = self.client.embeddings.create(model=self.s.emb_model, input=texts[i:i + batch])
            out.extend(d.embedding for d in sorted(resp.data, key=lambda d: d.index))
        if out and len(out[0]) != self.s.emb_dim:
            raise ValueError(f"embedding 维度 {len(out[0])} 和 EMB_DIM={self.s.emb_dim} 不一致,改配置并重建表")
        return out
  • from ..config import Settings, get_settings 两个点是相对导入:从 kb 往上一层的 supportpilot 包里拿 config。
  • self.s = settings or get_settings() 传了配置就用传的,测试里方便换;没传就读 .env。
  • self.client = OpenAI(base_url=self.s.emb_base_url embedding 也走 OpenAI 兼容协议,这里连的是硅基流动。max_retries=3 遇到 429、5xx,SDK 会自己退避重试 3 次。
  • def embed(self, texts: list[str], batch: int = 32) 一次最多送 32 段,太多会撞上厂商的单次请求上限。
  • for i in range(0, len(texts), batch): 150 块的话,i 依次是 0、32、64、96、128,一共发 5 次请求。
  • resp = self.client.embeddings.create(model=self.s.emb_model, input=texts[i:i + batch]) 切片 texts[128:160] 越界也不会报错,只会拿到剩下的 22 段。实测送两句话进去,0.23 秒返回,每条是 1024 维,第一条前三个数是 [-0.0277, 0.0101, -0.0554]。
  • out.extend(d.embedding for d in sorted(resp.data, key=lambda d: d.index)) 按 index 排回输入顺序再接到 out 后面。extend 是逐个追加,append 会把整个生成器当成一个元素塞进去。
  • if out and len(out[0]) != self.s.emb_dim: 只看第一条的维度就够了,同一个模型输出的维度都一样。bge-m3 是 1024,和建表时的 vector(1024) 对得上。

两个细节。返回结果按 d.index 排序,因为接口文档并不保证返回顺序和输入顺序一致,大多数时候一致,但”大多数时候”在数据管道里就是隐患。第二,检查维度,和 Day 44 表结构里写死的 vector(1024) 对上,不对立刻报错。

# supportpilot/scripts/ingest_kb.py
"""增量导入帮助中心:内容 hash 没变的文章跳过;变了的整篇删掉旧块重建;库里有、目录里没了的文章删除。"""

from pathlib import Path

from supportpilot.db import session
from supportpilot.kb.chunker import chunk_doc, parse_markdown
from supportpilot.kb.embed import Embedder

KB_DIR = Path(__file__).resolve().parent.parent / "data" / "kb"


def main() -> None:
    docs = [parse_markdown(p.read_text(encoding="utf-8")) for p in sorted(KB_DIR.glob("*.md"))]
    emb = Embedder()
    added = updated = skipped = 0
    with session() as conn:
        existing = {r["id"]: r["content_hash"] for r in conn.execute("SELECT id, content_hash FROM kb.docs")}
        for doc in docs:
            if existing.get(doc.id) == doc.content_hash:
                skipped += 1
                continue
            chunks = chunk_doc(doc)
            vectors = emb.embed([c.embed_text for c in chunks])
            with conn.transaction():  # 一篇文章的删旧 + 写新放一个事务,中途失败不会留下半篇
                conn.execute("DELETE FROM kb.docs WHERE id = %s", (doc.id,))  # chunks 级联删除
                conn.execute(
                    "INSERT INTO kb.docs (id, title, category, url_path, updated_at, content_hash, content) "
                    "VALUES (%s, %s, %s, %s, %s, %s, %s)",
                    (doc.id, doc.title, doc.category, doc.url_path, doc.updated_at, doc.content_hash, doc.body))
                with conn.cursor() as cur:
                    cur.executemany(
                        "INSERT INTO kb.chunks (doc_id, chunk_index, heading, content, embedding) "
                        "VALUES (%s, %s, %s, %s, %s)",
                        [(c.doc_id, c.index, c.heading, c.text, v) for c, v in zip(chunks, vectors, strict=True)])
            if doc.id in existing:
                updated += 1
            else:
                added += 1
        gone = set(existing) - {d.id for d in docs}
        for doc_id in gone:
            conn.execute("DELETE FROM kb.docs WHERE id = %s", (doc_id,))
    print(f"added={added} updated={updated} skipped={skipped} deleted={len(gone)}")


if __name__ == "__main__":
    main()
  • KB_DIR = Path(__file__).resolve().parent.parent / "data" / "kb" 脚本在 scripts/ 里,往上两级是项目根目录,再进 data/kb。用 __file__ 定位,从哪个目录运行都找得到。
  • docs = [parse_markdown(p.read_text(encoding="utf-8")) for p in sorted(KB_DIR.glob("*.md"))] 一次把 62 篇全读进来。sorted 让处理顺序固定,日志好对。
  • added = updated = skipped = 0 连续赋值,三个计数器都从 0 开始。
  • with session() as conn: Day 44 的上下文管理器,块结束自动关连接。
  • existing = {r["id"]: r["content_hash"] 库里已有文章的 {id: hash}。第一次跑表是空的,existing = {};第二次是 62 条,比如 'KB-account-001': 'caa2da36c802d50c'。
  • if existing.get(doc.id) == doc.content_hash: 库里没有这篇时 .get() 返回 None,和任何 hash 都不相等,所以新文章不会被误判成「没变」。
  • chunks = chunk_doc(doc) 只有新的或改过的文章才切块、才花 embedding 的钱。
  • vectors = emb.embed([c.embed_text for c in chunks]) 送去向量化的是带标题的 embed_text,不是 text。
  • with conn.transaction(): 连接是 autocommit 的,这里显式开一个事务:里面三步要么全部成功,要么全部回滚。
  • conn.execute("DELETE FROM kb.docs WHERE id = %s", (doc.id,)) 先把旧文章删掉,外键是 ON DELETE CASCADE,它的块会跟着一起删。新文章删的是 0 行,也不会报错。
  • "INSERT INTO kb.docs %s 是 psycopg 的占位符,值放在后面的元组里,由驱动负责转义。
  • cur.executemany( 一条 SQL 配多组参数,一次插入这篇文章的全部块。
  • [(c.doc_id, c.index, c.heading, c.text, v) for c, v in zip(chunks, vectors, strict=True)] strict=True 是 3.10 起有的:两个列表长度不一样就直接报错,防止块和向量错位。向量是 Python 的 float 列表,Day 44 注册过 pgvector 的类型适配,能直接写进 vector 列。
  • if doc.id in existing: 用来区分是「更新」还是「新增」,只影响最后打印的计数。
  • gone = set(existing) - {d.id for d in docs} 集合相减:库里有、目录里已经没有的文章编号。
  • print(f"added={added} updated={updated} skipped={skipped} deleted={len(gone)}") 实测四种情况:
    第一次 added=62 updated=0 skipped=0 deleted=0;
    再跑一次 added=0 updated=0 skipped=62 deleted=0;
    给 KB-invoice-001 末尾加一句话 added=0 updated=1 skipped=61 deleted=0;
    把这个文件挪走 added=0 updated=0 skipped=61 deleted=1。
data/kb/*.md 62 篇 算 content_hash 和库里比 没变 → 跳过 切块 + embedding 只处理变了的 一个事务里: 删旧文档(块级联) + 写新 目录里没了 → 删
增量导入:内容 hash 没变的文章直接跳过;变了的整篇删掉旧块再写新块,放在一个事务里,中途失败不会留下半篇。

为什么”变了就整篇重建”,而不是只更新变了的那几块?因为改一个小标题,后面所有块的 chunk_index 都可能错位,逐块比对写起来复杂又容易错。帮助中心一篇文章就几块,整篇重建的成本可以忽略。如果你的文档是几百页的 PDF,那就值得做块级的 diff,但别一开始就上。

删除用了外键 ON DELETE CASCADE,删文档时它的块自动跟着删,不用写两条 DELETE。

跑一次:

cd scripts && uv run python gen_kb.py && cd ..
uv run python scripts/ingest_kb.py
# 第一次:added=62 updated=0 skipped=0 deleted=0
uv run python scripts/ingest_kb.py
# 第二次:added=0 updated=0 skipped=62 deleted=0

第二次全部跳过,说明增量逻辑是对的。你可以手动改一篇文章再跑,应该看到 updated=1。

混合检索:两路召回,按名次融合

# supportpilot/src/supportpilot/kb/retriever.py
"""混合检索:向量(pgvector)+ 关键词(BM25),用 RRF 融合,可选 rerank。"""

from dataclasses import dataclass, field

import httpx
import jieba
from rank_bm25 import BM25Okapi

from ..config import Settings, get_settings
from .embed import Embedder

jieba.setLogLevel(60)  # 关掉 jieba 首次加载词典时的日志


@dataclass
class Hit:
    chunk_id: int
    doc_id: str
    title: str
    heading: str
    text: str
    url_path: str
    category: str
    score: float = 0.0
    via: list[str] = field(default_factory=list)  # 被哪路召回的:vector / bm25,调试用


def tokenize(text: str) -> list[str]:
    return [t.lower() for t in jieba.lcut(text) if t.strip() and t not in ",。、:;!?()「」 "]


def rrf(rankings: list[list[int]], k: int = 60) -> dict[int, float]:
    """Reciprocal Rank Fusion:每一路里排第 r 名得 1/(k+r) 分,累加。只看名次,不看原始分数。"""
    scores: dict[int, float] = {}
    for ranking in rankings:
        for r, cid in enumerate(ranking, start=1):
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + r)
    return scores


class Reranker:
    """调用 /rerank 接口(硅基流动、Jina、Cohere 都是这个形状)。字段以你用的厂商文档为准。"""

    def __init__(self, s: Settings):
        self.s = s

    def rerank(self, query: str, docs: list[str], top_n: int) -> list[tuple[int, float]]:
        r = httpx.post(f"{self.s.rerank_base_url}/rerank", timeout=30,
                       headers={"Authorization": f"Bearer {self.s.rerank_api_key}"},
                       json={"model": self.s.rerank_model, "query": query, "documents": docs, "top_n": top_n})
        r.raise_for_status()
        return [(x["index"], x["relevance_score"]) for x in r.json()["results"]]


class HybridRetriever:
    def __init__(self, conn, embedder: Embedder | None = None, settings: Settings | None = None):
        self.conn = conn
        self.s = settings or get_settings()
        self.embedder = embedder or Embedder(self.s)
        self.reranker = Reranker(self.s) if self.s.rerank_enabled else None
        self._rows: dict[int, dict] = {}
        self._bm25: BM25Okapi | None = None
        self._ids: list[int] = []

    def _load(self) -> None:
        """BM25 索引放内存:几百个块,启动时建一次,不到一秒。知识库重新导入后调 refresh()。"""
        rows = self.conn.execute(
            "SELECT c.id, c.doc_id, c.heading, c.content, d.title, d.url_path, d.category "
            "FROM kb.chunks c JOIN kb.docs d ON d.id = c.doc_id ORDER BY c.id").fetchall()
        self._rows = {r["id"]: r for r in rows}
        self._ids = [r["id"] for r in rows]
        self._bm25 = BM25Okapi([tokenize(f'{r["title"]} {r["heading"]} {r["content"]}') for r in rows])

    def refresh(self) -> None:
        self._bm25 = None

    def vector_search(self, query: str, k: int) -> list[int]:
        vec = self.embedder.embed([query])[0]
        rows = self.conn.execute(
            "SELECT id FROM kb.chunks ORDER BY embedding <=> %s::vector LIMIT %s", (vec, k)).fetchall()
        return [r["id"] for r in rows]

    def bm25_search(self, query: str, k: int) -> list[int]:
        if self._bm25 is None:
            self._load()
        scores = self._bm25.get_scores(tokenize(query))
        ranked = sorted(zip(self._ids, scores, strict=True), key=lambda x: x[1], reverse=True)
        return [cid for cid, sc in ranked[:k] if sc > 0]

    def search(self, query: str, k: int | None = None, pool: int = 20) -> list[Hit]:
        k = k or self.s.retrieve_k
        if self._bm25 is None:
            self._load()
        vec_ids = self.vector_search(query, pool)
        kw_ids = self.bm25_search(query, pool)
        fused = rrf([vec_ids, kw_ids])
        ranked = sorted(fused, key=fused.get, reverse=True)[: max(pool, k)]
        hits = [self._hit(cid, fused[cid], vec_ids, kw_ids) for cid in ranked]
        if self.reranker and hits:
            order = self.reranker.rerank(query, [f"{h.title} / {h.heading}\n{h.text}" for h in hits], top_n=k)
            hits = [hits[i] for i, _ in order]
            for h, (_, sc) in zip(hits, order, strict=True):
                h.score = sc
        return hits[:k]

    def _hit(self, cid: int, score: float, vec_ids: list[int], kw_ids: list[int]) -> Hit:
        r = self._rows[cid]
        via = [name for name, ids in (("vector", vec_ids), ("bm25", kw_ids)) if cid in ids]
        return Hit(chunk_id=cid, doc_id=r["doc_id"], title=r["title"], heading=r["heading"], text=r["content"],
                   url_path=r["url_path"], category=r["category"], score=score, via=via)
  • import jieba 中文分词库。import 时会打几条 SyntaxWarning: invalid escape sequence,来自它源码里的老式正则,不影响使用。
  • from rank_bm25 import BM25Okapi BM25 的现成实现。给它一组已经分好词的文档,它会算好词频和 IDF。
  • jieba.setLogLevel(60) 日志级别设成比 CRITICAL(50)还高,首次加载词典的提示就不打了。
  • class Hit: 一条检索结果,带着后面引用要用的 doc_id、title、url_path。
  • via: list[str] = field(default_factory=list) 可变的默认值必须用 default_factory。直接写 = [] 的话,所有 Hit 会共用同一个列表,dataclass 会直接报错拦住你。
  • def tokenize(text: str) -> list[str]: 实测:
    "银行流水同步失败 提示E3001" → ['银行', '流水', '同步', '失败', '提示', 'e3001']
    "我买了年付专业版一周了想退款" → ['我', '买', '了', '年付', '专业版', '一周', '了', '想', '退款']
  • return [t.lower() for t in jieba.lcut(text) if t.strip() and t not in ",。、:;!?()「」 "] jieba.lcut 返回列表(cut 返回的是生成器)。t.strip() 为空的是空格,丢掉。t not in "…" 是在字符串里查子串,单个标点在里面就丢掉。.lower() 让 E3001 和 e3001 当成同一个词。
  • def rrf(rankings: list[list[int]], k: int = 60) 按名次融合。
  • for r, cid in enumerate(ranking, start=1): start=1 让名次从 1 数起,第 1 名得 1/61。
  • scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + r) 实测 rrf([[1, 2, 3], [3, 1, 9]]):
    1 两路分别是第 1、第 2 名,1/61 + 1/62 = 0.032522;
    3 分别是第 3、第 1 名,1/63 + 1/61 = 0.032266;
    2 只在第一路排第 2,得 0.016129;9 只在第二路排第 3,得 0.015873。
  • class Reranker: 调 /rerank 接口的薄封装。
  • r = httpx.post(f"{self.s.rerank_base_url}/rerank", timeout=30, 没用 SDK,直接发 HTTP。拼出来的地址是 https://api.siliconflow.cn/v1/rerank。
  • json={"model": self.s.rerank_model, "query": query, "documents": docs, "top_n": top_n}) httpx 的 json= 会自动序列化,并带上 Content-Type: application/json。
  • r.raise_for_status() 4xx、5xx 直接抛异常,别把一个报错的 JSON 当成结果往下传。
  • return [(x["index"], x["relevance_score"]) for x in r.json()["results"]] 返回的 index 是在 docs 里的位置,结果已经按分数从高到低排好。
  • class HybridRetriever: 两路召回、RRF 融合,可选 rerank。
  • self.reranker = Reranker(self.s) if self.s.rerank_enabled else None 配置里默认关,None 表示不精排。
  • self._rows: dict[int, dict] = {} 块 id → 这一行的数据,命中以后按 id 取详情用。
  • def _load(self) -> None: 从库里读出全部块,在内存里建 BM25。
  • "SELECT c.id, c.doc_id, c.heading, c.content, d.title, d.url_path, d.category " 块表 JOIN 文档表,标题和链接一次带出来。实测 150 行,id 从 1 开始,第 1 行是 KB-account-001 的「检查」那一节。
  • self._ids = [r["id"] for r in rows] 记下顺序:BM25 返回的分数是按这个顺序排的,第 i 个分数对应 _ids[i]。
  • self._bm25 = BM25Okapi([tokenize(f'{r["title"]} {r["heading"]} {r["content"]}') for r in rows]) 标题、小标题、正文拼起来再分词,一块对应一个词列表。
  • def refresh(self) -> None: 只是把索引置空,下一次查询时会重新 _load。
  • vec = self.embedder.embed([query])[0] 查询也要向量化,花一次 embedding 的钱。
  • "SELECT id FROM kb.chunks ORDER BY embedding <=> %s::vector LIMIT %s" <=> 是 pgvector 的余弦距离运算符,越小越近;配合建表时的 HNSW 索引,不用全表扫。实测查 银行流水同步失败 提示E3001,前 5 个是块 57、60、59、58(都属于 KB-error-005)和 138(KB-technical-003)。
  • scores = self._bm25.get_scores(tokenize(query)) 一次性给 150 块全部打分。同一个查询,最高分是块 57 的 22.249。
  • ranked = sorted(zip(self._ids, scores, strict=True), key=lambda x: x[1], reverse=True) 把 (块 id, 分数) 配对,按分数从高到低排。
  • return [cid for cid, sc in ranked[:k] if sc > 0] 分数为 0 表示一个词都没对上,这种不算召回。
  • def search(self, query: str, k: int | None = None, pool: int = 20) 两路各取 pool=20 个候选,最后返回 k 个(默认 retrieve_k=5)。
  • fused = rrf([vec_ids, kw_ids]) 实测 E3001 这条,两路前 5 是同一批块,只是顺序不一样:
    向量是 57、60、59、58、138,BM25 是 57、138、58、60、59。
  • ranked = sorted(fused, key=fused.get, reverse=True)[: max(pool, k)] sorted 一个字典,排的是它的键;key=fused.get 表示按值排序。
  • hits = [self._hit(cid, fused[cid], vec_ids, kw_ids) for cid in ranked] 实测融合后前 5:
    57 KB-error-005 报错含义 0.03279
    60 KB-error-005 仍未解决 0.03175
    138 KB-technical-003 授权有效期 0.03151
    58 KB-error-005 原因 0.0315
    59 KB-error-005 处理步骤 0.03126
    五条的 via 都是 ['vector', 'bm25']。
  • if self.reranker and hits: 打开 rerank 以后才走这里。实测用 Settings(rerank_enabled=True) 查「我上周买的年付,如果不想用了能退吗」,rerank 这一步花了 1.36 秒。
  • order = self.reranker.rerank(query, [f"{h.title} / {h.heading}\n{h.text}" for h in hits], top_n=k) 送去精排的文本也带上标题,和入库时的 embed_text 一个思路。
  • hits = [hits[i] for i, _ in order] 按 rerank 给的顺序重排,只留 top_n 个。
  • h.score = sc 分数换成 rerank 的相关度。上面那条实测结果是 KB-refund-001 0.9821、KB-refund-002 0.9816、KB-plans-002 0.9742……不开 rerank 时这里是 RRF 分,0.03 左右,两种分数的量纲完全不同,别拿来比。
  • via = [name for name, ids in (("vector", vec_ids), ("bm25", kw_ids)) if cid in ids] 看这一块是被哪一路召回的。实测查「这个月被扣了两次 39 元,请退一笔」,KB-billing-004 在向量这一路排第 1,在 BM25 只排第 4。字面上没对上的说法,是向量这一路补回来的。

为什么要两路

纯向量检索擅长”意思相近、说法不同”:客户写”扣了两次”,文章写”重复扣款”,向量能把它们拉到一起。但它不擅长”一串必须字面相同的符号”:E3001 和 E3002 在向量空间里几乎挨着,模型分不太清。关键词检索(BM25)正好反过来:字面一样就命中,换个说法就抓瞎。两路一起上,互相补位。

BM25 是什么,Day 18 讲过,这里只提一句它的直觉:一个词在这篇文档里出现得越多、在所有文档里越少见,这篇文档对这个词的得分越高。“E3001”只出现在一两篇文章里,一旦客户提到它,那一两篇的分数会远高于别的。

中文要先分词

BM25 是按”词”算的,英文按空格分就行,中文得先分词。我用 jieba,tokenize 顺手把标点和空白去掉、英文转小写。看看分出来是什么样:

"银行流水同步失败 提示E3001"   → ['银行', '流水', '同步', '失败', '提示', 'e3001']
"我买了年付专业版一周了想退款" → ['我', '买', '了', '年付', '专业版', '一周', '了', '想', '退款']

e3001 作为一个整体保留下来了,这正是我们要的。“了""我”这种虚词也被留着,BM25 会因为它们太常见而自动给很低的权重,不用专门维护停用词表。

jieba 在 Python 3.12 下 import 时会打几条 SyntaxWarning: invalid escape sequence,是 jieba 源码里正则写法老旧,不影响使用,可以忽略。

BM25 索引为什么放内存

150 块文本,建 BM25 索引不到一秒,内存占用可以忽略。放内存的代价是知识库更新后要调 refresh() 重建。Postgres 自带全文检索,但中文分词要装 zhparser 之类的扩展,部署麻烦得多。等你的知识库到了几十万块,再考虑 Elasticsearch 或者 Postgres 的中文分词扩展。这又是一个”先用简单的,够用就行”的选择。

RRF:只看名次,不看分数

两路结果怎么合?最直接的想法是把分数加起来,但向量的余弦距离在 0 到 2 之间,BM25 分数可能是 0 到 30,量纲完全不同,直接加等于让 BM25 说了算。归一化也不靠谱,BM25 分数的分布每个查询都不一样。

RRF(Reciprocal Rank Fusion)绕开了这个问题:不看分数,只看名次。在某一路里排第 r 名,就得 1/(60 + r) 分,两路的得分加起来。手算一个例子:

向量检索排名:[A, B, C]      BM25 排名:[C, A, D]
A: 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
C: 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03227
B: 1/(60+2)            = 0.01613
D:            1/(60+3) = 0.01587
最终:A > C > B > D

两路都排前面的 A 和 C 胜出,只在一路出现的 B 和 D 靠后。常数 60 是原论文的经验值,作用是让第 1 名和第 2 名的差距不要太悬殊。你可以把测试里 rrf([[1, 2, 3], [3, 1, 9]]) 的结果对照着算一遍。

客户的话 向量检索 top20 pgvector <=> BM25 top20 jieba 分词 · 内存 RRF 融合 1/(60+名次) rerank 可选 top5 带 KB 编号
一次检索:两路各取 20 个候选,按名次融合,可选再用 rerank 模型精排,最后取前 5 段。每段带着文档编号,后面引用要用。

rerank:可选,先别开

rerank 模型(比如 bge-reranker-v2-m3)和 embedding 模型的区别:embedding 是把问题和文档分别变成向量再比距离,快,但问题和文档”没见过面”;rerank 是把问题和每段文档拼在一起送进模型打分,慢,但判断准得多。所以通常先用便宜的方法召回 20 段,再用 rerank 精排出 5 段。

Reranker 类调的是 /rerank 接口,硅基流动、Jina、Cohere 都是这个形状(请求里 model、query、documents、top_n,返回 results 里每条有 index 和 relevance_score),字段以你用的厂商文档为准。配置里 RERANK_ENABLED 默认关。原因是我想让你先拿到没有 rerank 的基线,再打开对比,看它到底值不值多花的那次调用和几百毫秒延迟。有论文发现 rerank 后只取很少几段,反而会漏掉相关内容,所以别默认它一定有用,让评测说话。

果粒奶优有果粒 · 【进阶 RAG】带你深入了解 Rerank 模型的原理与使用 · 18 分钟,前半段讲 cross-encoder 和 bi-encoder 的区别,看懂为什么 rerank 准但慢。

引用:编号要能查、能渲染

# supportpilot/src/supportpilot/kb/cite.py
import re

from .retriever import Hit

CITE_RE = re.compile(r"\[(KB-[a-z]+-\d{3})\]")


def build_context(hits: list[Hit]) -> str:
    """把检索结果拼成给模型看的资料块。每段前面是可引用的编号。"""
    if not hits:
        return "(知识库里没有找到相关资料)"
    return "\n\n".join(f"[{h.doc_id}] {h.title} / {h.heading}\n{h.text}" for h in hits)


def extract_citations(text: str) -> list[str]:
    return list(dict.fromkeys(CITE_RE.findall(text)))  # 去重并保持顺序


def check_citations(text: str, hits: list[Hit]) -> tuple[list[str], list[str]]:
    """返回 (合法引用, 编造的引用)。编造 = 引用了不在本次检索结果里的编号。"""
    allowed = {h.doc_id for h in hits}
    cited = extract_citations(text)
    return [c for c in cited if c in allowed], [c for c in cited if c not in allowed]


def render_sources(text: str, hits: list[Hit], base_url: str = "https://help.cloudledger.example") -> str:
    """发给客户之前,把文中的 [KB-xxx] 换成 [1] [2],文末附链接。"""
    order: dict[str, int] = {}
    by_id = {h.doc_id: h for h in hits}

    def sub(m: re.Match) -> str:
        doc_id = m.group(1)
        if doc_id not in by_id:
            return ""
        order.setdefault(doc_id, len(order) + 1)
        return f"[{order[doc_id]}]"

    body = CITE_RE.sub(sub, text)
    if not order:
        return body
    refs = "\n".join(f"[{n}] {by_id[d].title} {base_url}{by_id[d].url_path}" for d, n in order.items())
    return f"{body}\n\n参考文章:\n{refs}"
  • CITE_RE = re.compile(r"\[(KB-[a-z]+-\d{3})\]") 匹配 [KB-refund-002] 这种编号,括号里那一段是分组,取出来的是不带方括号的 KB-refund-002。分类只认小写字母,数字必须正好 3 位。
  • if not hits: 一条都没检索到时,明确告诉模型「没找到」,别给它一个空字符串让它自己猜。实测返回 '(知识库里没有找到相关资料)'。
  • return "\n\n".join(f"[{h.doc_id}] {h.title} / {h.heading}\n{h.text}" for h in hits) 实测前两段拼出来是:
    [KB-refund-001] 退款政策总览 / 年付首次购买
    年付订阅首次购买后 14 天内(含第 14 天)可无理由申请全额退款。
    空一行,再是 [KB-refund-002] 年付 14 天无理由退款 / 条件 和它的正文。
  • return list(dict.fromkeys(CITE_RE.findall(text))) findall 会返回重复的编号,dict.fromkeys 去重,同时保留第一次出现的顺序(集合 set 不保证顺序)。实测 '可以全额退款 [KB-refund-002]。续费另算 [KB-refund-009],详见 [KB-refund-002]':findall 得到 3 个,去重后是 ['KB-refund-002', 'KB-refund-009']。
  • allowed = {h.doc_id for h in hits} 这一次检索结果里有的编号才算合法。
  • return [c for c in cited if c in allowed], [c for c in cited if c not in allowed] 同一句话实测返回 (['KB-refund-002'], ['KB-refund-009']),009 是编造的。
  • order: dict[str, int] = {} 内部编号 → 对外序号,第一次被引用的排 1。
  • by_id = {h.doc_id: h for h in hits} 按编号查回标题和链接。
  • def sub(m: re.Match) -> str: 定义在函数里面的函数,能直接用外层的 order 和 by_id。它会作为 re.sub 的替换函数,每匹配到一个编号就调一次。
  • if doc_id not in by_id: 编造的编号直接替换成空串删掉,不给客户一个点进去是 404 的链接。实测 续费另算 [KB-refund-009], 变成了 续费另算 ,,原来编号前面的空格还留着。正常流程里质检节点会先把编造的编号打回去重写,走不到这一步。
  • order.setdefault(doc_id, len(order) + 1) 没出现过就分配下一个序号,出现过就保留原来的。所以同一篇文章引用两次,两处都是 [1]。
  • body = CITE_RE.sub(sub, text) 实测上面那句变成 可以全额退款 [1]。续费另算 ,详见 [1]。
  • if not order: 全文一个合法引用都没有,就不加「参考文章」那一段。
  • refs = "\n".join(f"[{n}] {by_id[d].title} {base_url}{by_id[d].url_path}" for d, n in order.items()) 实测得到 [1] 年付 14 天无理由退款 https://help.cloudledger.example/help/refund/002。

三个函数,对应引用的三个阶段:

build_context 把检索结果拼成给模型看的资料块,每段前面是 [KB-refund-002] 这样的编号。Day 47 的起草提示词会要求模型”用到资料的句子在句末标注编号,只能用资料里出现过的编号”。

check_citations 检查模型有没有编造编号。模型偶尔会写出 [KB-refund-009] 这种看起来很像但根本不存在的编号,尤其是资料里有 KB-refund-001、KB-refund-002 时,它会”推理”出还有 003、004。编造的引用比不引用更糟,客户点进去是 404,或者更糟,点进去是一篇不相关的文章。质检节点会用这个函数,有编造就打回重写。

render_sources 在发给客户之前把内部编号换成 [1] [2],文末附上文章标题和链接。客户不需要知道 KB-refund-002 是什么,内部编号留在系统里给评测和审计用。

检索基线:先拿到数字

评测集要到 Day 50 才建,但检索可以先评。我从 Day 50 的评测场景里给每个”需要查资料”的场景挑了一种问法,一共 31 条,放在 evals/datasets/retrieval_seed.jsonl,每条写着问题和”答对需要的文章编号”(任意一篇在前 5 就算命中):

{"query": "开发票 怎么开发票?在哪里申请?", "kb_docs": ["KB-invoice-001"]}
{"query": "专票 我们公司要增值税专用发票,怎么开?", "kb_docs": ["KB-invoice-002", "KB-invoice-001"]}
{"query": "发票开错了 发票抬头写错了一个字,能重开吗?", "kb_docs": ["KB-invoice-003"]}

完整的 31 条你可以照着 Day 50 的场景表自己写,也可以等 Day 50 的 build_dataset.py 生成后用里面的数据。

# supportpilot/evals/retrieval_eval.py
"""只评检索,不调大模型(只花 embedding 的钱,100 条不到 1 分钱)。
用评测集里每条用例的 kb_docs 当标准答案:前 k 个结果里出现任意一篇就算命中。

uv run python -m evals.retrieval_eval                                   # 默认用 retrieval_seed,k=5
uv run python -m evals.retrieval_eval evals/datasets/tickets_v1.jsonl   # Day 50 之后换成完整评测集
"""

import json
import sys
from pathlib import Path

from supportpilot.db import connect
from supportpilot.kb.retriever import HybridRetriever, rrf

DEFAULT = Path(__file__).resolve().parent / "datasets" / "retrieval_seed.jsonl"


def load(path: Path) -> list[dict]:
    """两种格式都认:retrieval_seed 的 {query, kb_docs},以及 Day 50 评测集的完整用例。"""
    out = []
    for line in path.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        c = json.loads(line)
        if "query" in c:
            out.append({"id": c["query"][:20], "query": c["query"], "want": set(c["kb_docs"])})
        elif c["expect"]["kb_docs"]:
            out.append({"id": c["id"], "query": f"{c['ticket']['subject']} {c['ticket']['body']}",
                        "want": set(c["expect"]["kb_docs"])})
    return out


def main(path: Path = DEFAULT, k: int = 5) -> None:
    cases = load(path)
    r = HybridRetriever(connect())
    r._load()
    hits = {"vector": 0, "bm25": 0, "hybrid": 0}
    rr = {"vector": 0.0, "bm25": 0.0, "hybrid": 0.0}
    misses = []
    for c in cases:
        q = c["query"]
        vec = r.vector_search(q, 20)
        kw = r.bm25_search(q, 20)
        fused = rrf([vec, kw])
        ranked = {"vector": vec, "bm25": kw, "hybrid": sorted(fused, key=fused.get, reverse=True)}
        want = c["want"]
        for name, ids in ranked.items():
            docs = [r._rows[i]["doc_id"] for i in ids]
            first = next((n for n, d in enumerate(docs, start=1) if d in want), None)
            if first and first <= k:
                hits[name] += 1
            if first:
                rr[name] += 1 / first
            if name == "hybrid" and not (first and first <= k):
                misses.append((c["id"], docs[:k]))
    n = len(cases)
    for name in hits:
        print(f"{name:7s} recall@{k}={hits[name] / n:.3f}  MRR={rr[name] / n:.3f}")
    print(f"\n混合检索没命中的 {len(misses)} 条:")
    for cid, docs in misses:
        print(f"  {cid}: {docs}")


if __name__ == "__main__":
    main(Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT)
  • from supportpilot.kb.retriever import HybridRetriever, rrf 两路分开跑、再自己融合,所以 rrf 也要单独导入。
  • DEFAULT = Path(__file__).resolve().parent / "datasets" / "retrieval_seed.jsonl" 默认数据集和脚本放在同一个目录下的 datasets/ 里。
  • for line in path.read_text(encoding="utf-8").splitlines(): JSONL:一行一个 JSON。
  • if not line.strip(): 跳过空行,文件末尾多一个换行也不会出错。
  • if "query" in c: 种子集的格式:{query, kb_docs}。
  • out.append({"id": c["query"][:20], "query": c["query"], "want": set(c["kb_docs"])}) 种子集没有 id,拿问题的前 20 个字凑一个,打印漏检的时候能认出是哪条。want 用集合,判断「是不是想要的文章」很快。
  • elif c["expect"]["kb_docs"]: Day 50 完整评测集的格式。kb_docs 为空的用例(比如纯账单操作)不用查资料,不参与检索评测。
  • "query": f"{c['ticket']['subject']} {c['ticket']['body']}" 完整用例拿工单的标题和正文当查询,和 Agent 真实收到的输入一致。
  • r._load() 先把 BM25 索引建好。下划线方法本来是内部用的,评测脚本直接调问题不大。
  • hits = {"vector": 0, "bm25": 0, "hybrid": 0} 三种方式各自的命中数。
  • rr = {"vector": 0.0, "bm25": 0.0, "hybrid": 0.0} 三种方式各自的倒数排名之和,最后除以用例数就是 MRR。
  • vec = r.vector_search(q, 20) 两路各取 20,和 search() 用的候选池大小一样。
  • fused = rrf([vec, kw]) 这里没走 search(),所以不会调用 rerank。想评 rerank 要改成调 r.search(),正文的练习就是做这件事。
  • docs = [r._rows[i]["doc_id"] for i in ids] 块 id 换成文档编号:一篇文章有好几块,评的是「文章找没找到」。
  • first = next((n for n, d in enumerate(docs, start=1) if d in want), None) 第一个命中的名次。next(生成器, None):取第一个满足条件的值,一个都没有就返回 None。
  • if first and first <= k: 前 5 名里有就算命中。
  • if first: 只要在 20 个候选里出现过就计倒数排名:排第 1 得 1,排第 2 得 0.5,排第 4 得 0.25。
  • if name == "hybrid" and not (first and first <= k): 只把混合检索漏掉的记下来,打印出来人工看。
  • print(f"{name:7s} recall@{k}={hits[name] / n:.3f} MRR={rr[name] / n:.3f}") :7s 是左对齐、补齐到 7 个字符宽。我只用正文里出现过的 5 条查询(3 条示例 + 2 条漏检)实跑了一次,结果是:
    vector recall@5=1.000 MRR=0.740
    bm25 recall@5=0.600 MRR=0.490
    hybrid recall@5=0.800 MRR=0.629
    只有 5 条,数字的波动会很大,只用来看脚本怎么用。完整的 31 条要你自己补齐了再跑。
  • print(f" {cid}: {docs}") 实测漏掉的那条是「能退吗 我上周买的年付,如果不想用了能退」,混合检索的前 5 是 ['KB-refund-003', 'KB-refund-004', 'KB-refund-003', 'KB-billing-002', 'KB-refund-004']。refund-003 和 004 各占了两个位置,这就是正文说的同一篇文章挤掉了别人的位置。

两个指标。recall@5:前 5 个结果里有没有正确文章,有就算命中,命中的比例。MRR(平均倒数排名):第一个正确结果排第几名,取倒数再平均,排第 1 得 1 分,排第 2 得 0.5 分,没找到得 0 分。recall 看”找没找到”,MRR 看”排得靠不靠前”。对 RAG 来说 recall 更要紧,因为 5 段资料都会给模型看,排第 1 还是第 3 影响不大;但 MRR 低说明前面塞了不相关的东西,会干扰模型。

BM25 这一路不需要调任何 API,我在本机直接用同样的切块和分词跑了一遍,这是真实结果:

检索方式数据recall@5MRR
纯 BM2531 条种子查询0.9350.818
纯 BM2586 条(Day 50 评测集里需要查资料的全部用例)0.9190.786
纯向量31 条种子查询(你跑出来填)
混合 RRF31 条种子查询(你跑出来填)
混合 RRF + rerank31 条种子查询(你跑出来填)

纯 BM25 在 31 条里漏了两条,看看漏的是什么:

漏 1:"能退吗 我上周买的年付,如果不想用了能退吗?先问问"
      想要 KB-refund-002 / 001,BM25 前 5:billing-002, refund-004, invoice-005, refund-003, refund-003
漏 2:"扣了两次 这个月被扣了两次 39 元,请退一笔"
      想要 KB-billing-004(被重复扣款怎么办),BM25 前 5:plans-001, refund-003, invoice-003, plans-002, billing-009

第二条最典型:客户说”扣了两次”,文章标题是”被重复扣款怎么办”,正文写的是”重复扣款”。两边没有一个词重合,BM25 无能为力。这正是向量检索该补上的地方。你跑出纯向量的结果后,应该能看到它找到了 billing-004。

第一条更微妙。“能退吗”加上”年付”,BM25 找到了 refund-003(年付超过 14 天怎么退)和 refund-004(月付能退吗),这两篇都在讲退款,但”上周买的”这个时间信息指向的是 14 天无理由那篇。这种需要理解”上周 = 14 天内”的,纯关键词做不到,向量也不一定做得到。Day 47 的 act 节点会去查账单、算天数,所以检索漏了也不致命,但它说明了一件事:检索不是孤立的,整个系统里有别的环节可以兜底。

另外注意 BM25 结果里 refund-003 出现了两次。同一篇文章的两个块都进了前 5,等于浪费了一个位置。要不要按文档去重(每篇最多一块),是个可以用评测回答的问题,留给你做练习。

Jason Liu · Systematically Improving RAG applications · 他的核心主张是先建检索评测、按问题类型分桶看失败,再决定改什么。今天的基线就是这个思路的第一步,看前 20 分钟。

上线以后:知识库是要有人养的

检索代码写完不等于知识库就好了。真实项目里,检索质量出问题,一半以上的原因不在算法,在内容。

最常见的是文章过期。价格调了、规则改了,帮助中心没人更新,Agent 就会理直气壮地引用旧规则。kb.docs 表里有 updated_at 字段,就是给这种情况留的:你可以在检索时把超过一年没更新的文章降权,或者至少在评测报告里统计”引用了多少篇超过半年没更新的文章”,交给内容负责人去处理。

第二种是文章互相矛盾。两篇文章对同一条规则说法不同,模型会挑一篇用,今天挑这篇明天挑那篇。我们的事实源设计从根上避免了这个问题,真实项目里只能靠定期巡检:让模型两两比对同一主题的文章,标出可能冲突的地方,再让人确认。

第三种是缺内容。客户老问一个问题,帮助中心根本没写。这种问题检索评测发现不了(因为评测集里也没有),要从线上数据里看:哪些工单检索结果的最高分都很低,哪些工单最后被转了人工。把这些问题整理出来交给内容团队补文章,补完 ingest_kb.py 一跑就生效。这是 Day 52 可观测要做的事情之一。

做 FDE 或者企业落地的工程师,经常要花不少时间推动这些事。技术上它们都不难,难在要说服业务方配合。

动手练习

步骤 1:导入知识库。

make kb          # 生成文章 + 导入,第一次会调 embedding API
uv run python scripts/ingest_kb.py   # 再跑一次,确认 skipped=62

步骤 2:手动试几次检索。

# 在 uv run python 里
from supportpilot.db import connect
from supportpilot.kb.retriever import HybridRetriever
from supportpilot.kb.cite import build_context

r = HybridRetriever(connect())
hits = r.search("银行流水同步失败 提示E3001")
for h in hits:
    print(h.doc_id, h.heading, round(h.score, 4), h.via)
print(build_context(hits[:2]))
  • r = HybridRetriever(connect()) 不传 settings,就读 .env。RERANK_ENABLED 默认是 false,所以这里不精排。
  • hits = r.search("银行流水同步失败 提示E3001") 第一次调用会顺带建 BM25 索引,所以比之后的调用慢一点。
  • print(h.doc_id, h.heading, round(h.score, 4), h.via) 实测第一行是 KB-error-005 报错含义 0.0328 ['vector', 'bm25']。这条查询的 5 条结果两路都召回了,分数在 0.0313 到 0.0328 之间,差得很小。RRF 的分数只看名次,所以本来就挤在一起。
  • print(build_context(hits[:2])) 只看前两段拼出来是什么样,每段开头是 [KB-error-005] 这样的编号。

看 via 字段:有的结果两路都召回了(['vector', 'bm25']),有的只有一路。两路都有的通常排在前面,这就是 RRF 的效果。

步骤 3:跑检索基线,填上面那张表。

uv run python -m evals.retrieval_eval

把纯向量和混合的数字填进表里。然后在 .env 里打开 RERANK_ENABLED=true(需要硅基流动的 key),改一下 retrieval_eval.py 让混合结果走一遍 r.search()(它会调 rerank),再跑一次,填最后一行。

步骤 4:改一篇文章,验证增量导入。 打开 scripts/kb_facts.py,给”如何申请发票”加一句话,重新 gen_kb.py 和 ingest_kb.py,应该是 updated=1 skipped=61。

步骤 5:跑测试。

# supportpilot/tests/test_kb.py
from pathlib import Path

from supportpilot.kb.chunker import chunk_doc, parse_markdown
from supportpilot.kb.cite import check_citations, render_sources
from supportpilot.kb.retriever import Hit, rrf

KB = Path(__file__).resolve().parent.parent / "data" / "kb"


def test_every_article_parses_and_chunks():
    files = sorted(KB.glob("*.md"))
    assert len(files) >= 60
    for p in files:
        chunks = chunk_doc(parse_markdown(p.read_text(encoding="utf-8")))
        assert chunks and all(len(c.text) <= 460 for c in chunks)
        assert all(c.embed_text.startswith(chunks[0].embed_text.split(" / ")[0]) for c in chunks)


def test_rrf_prefers_items_ranked_by_both():
    s = rrf([[1, 2, 3], [3, 1, 9]])
    assert max(s, key=s.get) in (1, 3) and s[3] > s[2]


def test_citations():
    hits = [Hit(1, "KB-refund-002", "年付 14 天无理由退款", "条件", "…", "/help/refund/002", "refund")]
    ok, fake = check_citations("可以退 [KB-refund-002],另见 [KB-refund-009]", hits)
    assert ok == ["KB-refund-002"] and fake == ["KB-refund-009"]
    out = render_sources("可以全额退款 [KB-refund-002]。", hits)
    assert "[1]" in out and "/help/refund/002" in out and "KB-" not in out.split("参考文章")[0]
  • KB = Path(__file__).resolve().parent.parent / "data" / "kb" 读的是 gen_kb.py 生成的文章,不连库。所以要先跑过一次 make kb。
  • assert len(files) >= 60 实际是 62 篇。写 >= 而不是 ==,以后加文章测试不用跟着改。
  • assert chunks and all(len(c.text) <= 460 for c in chunks) 上限 460 是 max_chars=400 加 overlap=60:滑窗以后一块最多就这么长。实测最长 178。
  • assert all(c.embed_text.startswith(chunks[0].embed_text.split(" / ")[0]) for c in chunks) 每一块的 embed_text 都必须以文章标题开头,防止有人改 chunk_doc 时把「帽子」弄丢。
  • s = rrf([[1, 2, 3], [3, 1, 9]]) 实测 {1: 0.032522, 2: 0.016129, 3: 0.032266, 9: 0.015873}。
  • assert max(s, key=s.get) in (1, 3) and s[3] > s[2] 两路都出现的 1 和 3 排在前面;3 虽然在第一路只排第 3,但比只出现在一路的 2 高。
  • hits = [Hit(1, "KB-refund-002", "年付 14 天无理由退款", "条件", "…", "/help/refund/002", "refund")] 按位置传参,顺序和 Hit 定义的字段顺序一致。score 和 via 有默认值,不用传。
  • ok, fake = check_citations("可以退 [KB-refund-002],另见 [KB-refund-009]", hits) 009 不在检索结果里,应该被识别成编造。
  • assert "[1]" in out and "/help/refund/002" in out and "KB-" not in out.split("参考文章")[0] 只检查正文部分:内部编号已经全部换掉了。实测 make test 时这个文件 3 个测试都通过,用时 0.69 秒。
make test

练习(选做):按文档去重。 在 search() 里加一个参数 max_per_doc=1,每篇文档最多保留一块,看 recall@5 是升是降。我的预期是对短文章的帮助中心会小幅提升,因为腾出的位置能放别的候选文章;但对需要同一篇文章多个小节才能回答的问题(比如”降级什么时候生效、会不会退钱”)可能变差。用数字验证这个预期。

验收标准: 第二次导入全部跳过;检索基线表里纯向量和混合两行填上了真实数字;混合检索的 recall@5 不低于纯 BM25 的 0.935(如果低了,先检查 embedding 维度和 embed_text 拼接);make test 全绿。

名词解释

名词意思
切块 chunking把长文档切成适合检索的小段
上下文前缀向量化前给每块拼上标题和小标题,让短块带上主题信息
增量导入只处理内容变化的文档,用 hash 判断是否变化
BM25经典的关键词检索打分算法,看词频和词的稀有程度
混合检索向量检索和关键词检索同时召回,再融合
RRFReciprocal Rank Fusion,按名次倒数融合多路检索结果
rerank把问题和候选文档一起送进模型逐对打分,精排
recall@k前 k 个结果里包含正确答案的比例
MRR第一个正确结果名次的倒数的平均值
引用渲染把内部编号换成客户可见的序号和链接

常见误区

照抄别人的切块参数。 “512 token 加 50 重叠”是别人在别人的文档上调出来的。先看自己的文档结构,结构清晰的按标题切往往比固定长度好得多。

两路检索的分数直接相加。 量纲不同,加起来等于一路说了算。用 RRF 或者至少做归一化,而 RRF 更简单稳定。

以为加了 rerank 一定更好。 rerank 多一次调用、多几百毫秒延迟、多一份费用,在知识库小而规整时提升可能很小。开不开,看评测数字。

允许模型自由引用。 不检查的引用迟早会出现编造编号。编号格式要固定,能用正则检查,质检时必查。

检索没有基线就开始调。 改了切块、换了模型、加了 rerank,到底是变好还是变坏?没有基线,所有优化都是凭感觉。

学习资料

文档与文章

B 站视频

  • 上面嵌的《【进阶 RAG】带你深入了解 Rerank 模型的原理与使用》。
  • Coggle数据科学《动手学 RAG:Part6 文本多路召回与重排序》(BV12H4y157og,12 分钟):多路召回加重排的完整流程,和我们的结构一样,下面嵌了。
  • AI开发者-就爱瞎鼓捣《ReRank 与 Embedding 模型的区别?如何选择 ReRank 模型?》(BV1r1421R77Y,7 分钟):如果 rerank 那段没太懂,这个更短更直白。
Coggle数据科学 · 动手学 RAG:Part6 文本多路召回与重排序 · 12 分钟,看多路召回后怎么合并和重排。

其他经典视频

  • 上面嵌的 Jason Liu《Systematically Improving RAG applications》。

书籍

  • Jay Alammar、Maarten Grootendorst《Hands-On Large Language Models》(O’Reilly,2024)第 8 章 Semantic Search and Retrieval-Augmented Generation:图解稠密检索、rerank 和 RAG 评估,配图非常清楚,约 1.5 小时。中文版书名《图解大模型:生成式 AI 原理与实践》,2025 年 5 月出版。
  • Chip Huyen《AI Engineering》第 6 章 RAG and Agents 的 RAG 部分:检索算法比较(词法 vs 语义)、切块策略、查询改写,约 1 小时。

开源项目与代码

  • explodinggradients/ragas:RAG 评估框架,今天不用,但看一眼它的检索指标定义(context recall、context precision),和我们的 recall@k 对比。
  • dorianbrown/rank_bm25:源码只有几百行,想弄懂 BM25 公式可以直接读。

自测

1. embed_text 为什么要拼上”标题 / 小标题”?举一个不拼会出问题的例子。

答案

短块单独向量化会丢失主题信息。比如”退款政策总览”里”月付”那一节只有”月付订阅不支持退款,可以关闭自动续费”,不拼标题的话,它的向量会和”如何关闭自动续费”那篇很接近,检索”关闭自动续费”时可能被错误召回;拼上”退款政策总览 / 月付”后,向量带上了”退款”的语义。

2. 向量检索排名 [A, B, C],BM25 排名 [B, D, A],用 RRF(k=60)算最终顺序。

答案

A = 1/61 + 1/63 ≈ 0.01639 + 0.01587 = 0.03226;B = 1/62 + 1/61 ≈ 0.01613 + 0.01639 = 0.03252;C = 1/63 ≈ 0.01587;D = 1/62 ≈ 0.01613。顺序 B > A > D > C。

3. 客户写”扣了两次”,文章写”重复扣款”,哪一路检索能找到?为什么?

答案

向量检索。两句话没有共同的词,BM25 得分为零;但语义相近,embedding 向量距离近。这是我们基线里 BM25 真实漏掉的一条。

4. 增量导入时,为什么一篇文章变了就整篇重建,而不是只改变了的块?

答案

文章结构一变(比如加了一个小标题),后面块的编号会错位,逐块比对和更新逻辑复杂、容易出错;帮助中心文章只有几块,整篇重建成本可以忽略。删旧写新放在一个事务里,保证不会出现半篇。

5. 「面试题」你的 RAG 为什么用混合检索?怎么证明它比纯向量好?

答案

客服场景有大量必须字面匹配的内容(报错码 E3001、套餐名、“专票”这类术语),纯向量对它们不敏感;同时客户的口语化表达(“扣了两次”)又需要语义匹配。用两路召回、RRF 按名次融合。证明靠检索基线:用带标准答案的查询集分别测纯 BM25、纯向量、混合的 recall@5 和 MRR,并逐条看失败案例。我的数据里纯 BM25 recall@5 是 0.935,漏掉的正是语义改写类问题。

6. 「面试题」模型在回复里引用了一个不存在的文档编号,你的系统怎么发现和处理?

答案

编号格式固定为 KB-分类-三位数字,用正则提取回复中所有编号,和本次检索结果的编号集合比对,不在集合里的就是编造。质检节点发现编造会把草稿打回重写,并把问题写进重写提示;重写一次还不过就转人工。发给客户前再把内部编号渲染成序号和链接,编造的编号在渲染时会被去掉。

明天预告

Day 46 · 业务工具:订单/账单 MCP server 与退款审批。把查客户、查订阅、查账单、判断退款资格、创建退款申请写成业务函数,退款规则写死在代码里并用 7 个剧情客户做单元测试;按权限分级注册给模型;对接企业系统:审批通知推到飞书/钉钉/企业微信群,加一个只读、带 SQL 校验和行级隔离的 text-to-SQL 查询工具;最后把同一套函数包成 MCP server,在 Claude Desktop 里查账单。

评论

  1. 加载中…

只保存昵称和内容。会显示你的大致地区(国家 / 省),不保存 IP 地址。