Files
zhuangang/topics/ai/rag/examples/mini-rag/mini_rag.py
T

206 lines
8.6 KiB
Python
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.
# requires: jieba, rank_bm25, numpy
"""最小 RAG 流水线:切分 → 嵌入 → 混合检索(向量 + BM25,RRF 融合)→ 权限过滤 → 拒答阈值 → 带引用的提示词 → 检索评测。
为了不依赖外部服务,这里的“嵌入”用字符 n-gram 哈希向量代替真实的嵌入模型(只能捕捉字面相似,不懂语义),
目的是把流程跑通、把评测方法讲清楚。换成真实嵌入模型时,只需要替换 embed() 函数。
运行:python mini_rag.py
"""
from __future__ import annotations
import hashlib
import logging
import re
from dataclasses import dataclass, field
import jieba
import numpy as np
from rank_bm25 import BM25Okapi
jieba.setLogLevel(logging.WARNING)
DIM = 512
@dataclass
class Doc:
id: str
title: str
text: str
acl: set[str] # 允许访问的部门;"all" 表示全员
@dataclass
class Chunk:
id: str
doc_id: str
title: str
text: str
acl: set[str]
vec: np.ndarray = field(repr=False, default=None)
DOCS = [
Doc("hr-leave", "员工手册-休假制度", """# 年假
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
# 病假
病假需提供医院证明,每年累计 10 天以内按全额工资发放。
# 调休
加班可在 3 个月内申请调休,过期作废。""", {"all"}),
Doc("it-vpn", "IT 指南-远程办公", """# VPN
在家办公需先安装公司 VPN 客户端,使用工号和动态口令登录。连接失败请检查系统时间是否准确。
# 设备
笔记本电脑丢失需在 24 小时内报告 IT 部门并修改所有密码。""", {"all"}),
Doc("fin-reimburse", "财务制度-报销", """# 差旅报销
出差住宿标准:一线城市每晚 500 元以内,其他城市 350 元以内。发票须在出差结束后 30 天内提交。
# 餐费
出差餐补每天 100 元,无需发票。""", {"all"}),
Doc("hr-salary", "薪酬制度(仅 HR)", """# 调薪
年度调薪在每年 3 月进行,幅度由绩效等级决定,A 级 10% 至 15%。
# 保密
薪酬信息属于保密信息,不得在公开渠道讨论。""", {"hr"}),
]
def split(doc: Doc, max_len: int = 80, overlap: int = 15) -> list[Chunk]:
"""按 Markdown 标题分节;节内超过 max_len 再按句子切,相邻块保留 overlap 个字符的重叠。
每块前面加上“文档标题 / 小节标题”,让孤立的块也有上下文(例如“满十年 10 天”属于“年假”)。"""
out: list[Chunk] = []
for sec in re.split(r"\n(?=# )", doc.text.strip()):
heading, _, body = sec.partition("\n")
heading = heading.lstrip("# ").strip()
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
buf = ""
pieces: list[str] = []
for s in sentences:
if buf and len(buf) + len(s) > max_len:
pieces.append(buf)
buf = buf[-overlap:]
buf += s
if buf:
pieces.append(buf)
for p in pieces:
out.append(Chunk(f"{doc.id}#{len(out)}", doc.id, doc.title, f"【{doc.title} / {heading}】{p}", doc.acl))
return out
def embed(text: str) -> np.ndarray:
"""字符 1~2 gram 哈希到 DIM 维并 L2 归一化(真实项目替换为嵌入模型 API)。"""
v = np.zeros(DIM, dtype=np.float32)
t = re.sub(r"\s+", "", text)
for n in (1, 2):
for i in range(len(t) - n + 1):
h = int.from_bytes(hashlib.md5(t[i:i + n].encode()).digest()[:4], "little")
v[h % DIM] += 1.0
norm = np.linalg.norm(v)
return v / norm if norm else v
def tokenize(text: str) -> list[str]:
return [w for w in jieba.lcut(text) if w.strip() and not re.fullmatch(r"[,。、:;!?【】/\s]+", w)]
class Index:
def __init__(self, docs: list[Doc]) -> None:
self.chunks = [c for d in docs for c in split(d)]
for c in self.chunks:
c.vec = embed(c.text)
self.matrix = np.stack([c.vec for c in self.chunks])
self.bm25 = BM25Okapi([tokenize(c.text) for c in self.chunks])
def allowed(self, user_depts: set[str]) -> np.ndarray:
return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks])
def vector_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
scores = self.matrix @ embed(q) # 已归一化,点积 = 余弦相似度
scores[~mask] = -np.inf # 权限过滤:检索阶段就排除,而不是生成后再删
order = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i])]
def bm25_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
scores = np.array(self.bm25.get_scores(tokenize(q)))
scores[~mask] = -np.inf
order = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i]) and scores[i] > 0]
def hybrid_search(self, q: str, mask: np.ndarray, k: int, rrf_k: int = 60) -> list[tuple[int, float]]:
"""RRF:score = Σ 1 / (rrf_k + 排名)。只用排名,不用担心两种分数量纲不同。"""
fused: dict[int, float] = {}
for results in (self.vector_search(q, mask, 20), self.bm25_search(q, mask, 20)):
for rank, (i, _) in enumerate(results, start=1):
fused[i] = fused.get(i, 0.0) + 1.0 / (rrf_k + rank)
return sorted(fused.items(), key=lambda kv: -kv[1])[:k]
def answer_context(index: Index, q: str, user_depts: set[str], k: int = 3, min_sim: float = 0.25) -> dict:
mask = index.allowed(user_depts)
hits = index.hybrid_search(q, mask, k)
# 拒答:最相关块的向量相似度太低,说明知识库里很可能没有答案
best_sim = max((float(index.matrix[i] @ embed(q)) for i, _ in hits), default=0.0)
if not hits or best_sim < min_sim:
return {"refuse": True, "prompt": None, "sources": []}
sources = [index.chunks[i] for i, _ in hits]
context = "\n".join(f"[{n}] {c.text}" for n, c in enumerate(sources, start=1))
prompt = (
"你是公司制度问答助手。只根据下面的资料回答,资料里没有的内容回答“资料中没有相关规定”。"
"回答时用 [编号] 标注依据。\n\n"
f"资料:\n{context}\n\n问题:{q}"
)
return {"refuse": False, "prompt": prompt, "sources": [c.id for c in sources], "best_sim": round(best_sim, 3)}
# ---------------- 检索评测 ----------------
EVAL = [ # (问题, 期望命中的文档)
("年假有几天", "hr-leave"),
("工作满十年有多少天带薪假", "hr-leave"),
("病假要什么材料", "hr-leave"),
("加班多久内可以调休", "hr-leave"),
("VPN 连不上怎么办", "it-vpn"),
("电脑丢了怎么处理", "it-vpn"),
("出差住酒店能报多少钱", "fin-reimburse"),
("发票什么时候交", "fin-reimburse"),
("出差吃饭有补贴吗", "fin-reimburse"),
]
def evaluate(index: Index, method: str, k: int = 3) -> tuple[float, float]:
mask = index.allowed({"all"})
hit, rr = 0, 0.0
for q, expected in EVAL:
results = getattr(index, f"{method}_search")(q, mask, k)
docs = [index.chunks[i].doc_id for i, _ in results]
if expected in docs:
hit += 1
rr += 1 / (docs.index(expected) + 1)
return hit / len(EVAL), rr / len(EVAL)
def main() -> None:
index = Index(DOCS)
print(f"共 {len(DOCS)} 篇文档,切成 {len(index.chunks)} 块")
print("\n检索评测(Recall@3 / MRR):")
scores = {}
for m in ("vector", "bm25", "hybrid"):
recall, mrr = evaluate(index, m)
scores[m] = recall
print(f" {m:7s} recall@3={recall:.2f} MRR={mrr:.2f}")
r = answer_context(index, "满十年的年假几天?", {"dev"})
print("\n问:满十年的年假几天? 引用:", r["sources"])
print(r["prompt"][:120], "……")
r_hr = answer_context(index, "A 级调薪幅度是多少", {"hr"})
r_dev = answer_context(index, "A 级调薪幅度是多少", {"dev"})
print("\nHR 提问调薪:", r_hr["sources"][:1], "| 研发提问调薪:", r_dev["sources"])
assert r_hr["sources"][0].startswith("hr-salary")
assert not any(s.startswith("hr-salary") for s in r_dev["sources"]) # 无权限的内容不会进入上下文
r_none = answer_context(index, "公司股票期权怎么行权", {"dev"})
print("问:公司股票期权怎么行权 → 拒答" if r_none["refuse"] else f"未拒答:{r_none}")
assert r_none["refuse"]
assert scores["hybrid"] >= max(scores["vector"], scores["bm25"]) - 1e-9
if __name__ == "__main__":
main()