大模型不知道你公司的报销流程、不知道你笔记里的项目细节。RAG(检索增强生成)就是解药:回答前先去你的资料库里翻出相关段落,塞给模型,让它"开卷考试"。本篇用零依赖的纯 Python 实现一个能用的中文检索,代码对应
zhiliao/agent/rag.py。
一、RAG 到底在干什么?
四步流程,一个都不能少:
你的文档(markdown/txt)
│
① 切块 Chunking → 长文档切成几百字的小段
② 建索引 Indexing → 每段变成可比较的"向量"
③ 检索 Retrieve → 用户问题也变向量,找最像的几段
④ 注入 Inject → 命中段落塞进提示词,模型开卷作答
为什么非要这么麻烦,不能把整个文档库直接塞给模型?因为上下文窗口装不下(也付不起)。RAG 的本质是:用检索做过滤器,只把最相关的 1% 塞进窗口。
顺带说清一个易混点:
| 微调 Fine-tuning | RAG | |
|---|---|---|
| 改变什么 | 模型的"性格和能力" | 模型每次"开卷看到的资料" |
| 更新知识 | 要重新训练 | 改文档就行,实时生效 |
| 成本 | 高 | 极低 |
| 适合 | 固定的风格/技能 | 常变的事实/知识 |
个人知识库场景,RAG 是压倒性的正确选择。
二、切块:小段的艺术
def _chunk_text(text, max_len=480):
chunks = []
for para in re.split(r"\n\s*\n", text): # 先按空行分段(尊重作者的段落)
para = para.strip()
while len(para) > max_len: # 超长段落再切
cut = para.rfind("。", 0, max_len) # 尽量在句号处断,别拦腰斩断句子
cut = cut + 1 if cut > max_len // 2 else max_len
chunks.append(para[:cut])
para = para[cut:]
...
为什么是 480 字符? 这是"金发姑娘原则"的权衡:块太大 → 检索不精准(命中一段里一半是废话),还浪费窗口;块太小 → 上下文断裂("它"指代谁都不知道)。几百字符是社区经验值的起点,值得你按自己的文档实测调整。
三、本篇的核心魔法:不用向量库的 TF-IDF
标准 RAG 教程第二步就是"装 embedding 模型 + 向量数据库"。但对几十篇文档的个人知识库,纯 Python 手写的 TF-IDF 就够用,而且能让你真正理解"语义相似度"的底层。
词怎么切?中文用"二字组合"
英文按空格切词,中文没有空格。我们的偷懒法:相邻两个汉字组成一个"词":
"函数调用能力" → ["函数", "数调", "调用", "用能", "能力"]
查询"什么是 Function Calling"切成 ["what","is","function","calling","函数","调用"...] ——"函数""调用"都命中了。粗糙,但对检索"哪段在讲这件事"这个任务,意外地有效。
TF-IDF:一个词有多"重要"?
- TF(词频):这个词在本段出现越多,越可能是本段主题
- IDF(逆文档频率):这个词在全文库越稀有,区分度越大。"的"在每段都有,毫无区分度;"RAG"只在 1 段出现,命中它基本就是它了
def build(self):
df = {} # 文档频率
for toks in tokenlists:
for t in set(toks):
df[t] = df.get(t, 0) + 1
self._idf = {t: log(n/(1+d)) + 1 for t, d in df.items()} # 稀有词分高
每段变成一个向量后,用余弦相似度(向量夹角)比"问题"和"段落"有多像。向量归一化后余弦相似度就是点积,一行算完。
用起来是什么样?
index = load_knowledge_dir("knowledge/") # 扫描目录下所有 .md/.txt
hits = index.search("Function Calling 是什么", top_k=1)
# → [{"score": 0.31, "source": "agent_faq.md", "text": "Function Calling 是大模型的…"}]
我们给产品预置的知识库 knowledge/agent_faq.md 就是现成的实验品。
四、检索结果怎么喂给 Agent?两种模式
模式 A:每轮自动注入(知了的默认)。 core.py 每轮拿用户输入检索 top3,拼进系统提示:
hits = self.rag.search(user_input, top_k=self.cfg.rag_top_k)
context_blocks.append("【知识库参考片段】\n" + "\n---\n".join(
f"(来源 {h['source']}, 相关度 {h['score']}) {h['text'][:300]}" for h in hits))
优点:用户无感知。缺点:检索词只是用户原话,复杂问题可能检索不准;且不管三七二十一都注入(相关度低于阈值的不注,search 里 if s > 0 过滤了)。
模式 B:注册成工具,模型主动查。 search_knowledge_base 是一个普通工具,模型判断"这个问题该翻资料"时自己调用,还能改写检索词多查几次。
生产级 Agent 通常两者结合:简单问题靠 A 兜底,深挖靠 B。
五、升级路线(当你真的需要时)
- 中文分词:二字组合 →
jieba分词,消除"数调"这种伪词 - Embedding 向量:TF-IDF → embedding API(或本地 sentence-transformers),"宠物App"能被"毛球项目"召回——这才叫语义检索
- 向量数据库:几十万块以上再上 FAISS / Chroma / 云厂商向量服务;个人规模,NumPy 矩阵点积就够
- 混合检索 + 重排:关键词 + 向量双路召回,再用 rerank 模型精排——RAG 检索质量的天花板手法
每一步都是只换 rag.py 内部实现,接口不动。当初把它写成独立模块的红利现在兑现。
六、给文档写作者的 3 个建议
RAG 效果差,一半的锅在文档本身:
- 一段说一件事:段落主题越聚焦,切块质量越高
- 多用标题和列表:它们天然是好的切分边界
- 少用"如上文所述":切块后"上文"没了,每段要能独立读懂
本篇作业
- 往
knowledge/里丢几篇你自己的笔记(markdown),问知和一个只有笔记里才有答案的问题,观察它引用来源。 - 把
max_len从 480 改成 100 和 2000 各试一次,对比检索结果,体会块大小的权衡。 - 把
_tokenize里的中文二字组改成"逐个汉字",实测检索质量下降多少——亲手验证"二元组"的价值。
下一篇预告:《07 · 技能系统》——工具让 Agent"能做",知识让它"知道",还差一样:让它"会做"。我们把团队的方法论写成 markdown 技能文件,按需加载。