ARTICLE · 1126516
国庆7天|Day5·让它读你自己的PDF
一、系列导航
这是第 5 天。7 篇只养一个助理——小喵助理,新来的进公众号主页「合集」看全部。
前四天一直在敲代码,今天也一样:加一个文件、一个函数,它就能读你的文件了。
二、昨日回顾:它会干活了,但没看过你的文件
昨天它能把一件大事拆成几步办完,最后落成一份文件。
可你问它"依据某份文件里的规定,这种情况该怎么处理",它就露馅了——答得又快又顺,
但那全是凭印象答的。你内部的条例、你产品的说明书,它一个字都没读过。
三、今天目标
30—60 分钟。做完你手上会有一个能读你自己 PDF 的助理。
你问它文件里的一句话,它给你答案,并说出出自哪个文件、第几页。
随便一个聊天框都能"看起来"回答文档问题;
值钱的是它能指给你看原文在哪,你才分得清它是在答还是在编。
四、前置检查
1. 一台还能跑的小喵助理(Day1 建的,别新建)
2. 它已经会调工具(Day3 做完,终端里见过 🔧 调用 ... 那一行)
3. Python 3.10 以上,配好 PyCharm
4. 一份你自己的 PDF——条例、说明书、论文、产品手册都行,20—80 页最合适
Day1 装依赖时已带了 pypdf>=4.2.0,不用重装;Key 照旧写在 .env 里,不要写死在代码里。
先确认那份 PDF 能不能选中文字:选一行按 Ctrl+C,复制不出来就是扫描件(见 Q1)。
五、概念:拆 → 找 → 答,只有三步
今天不碰底层原理,你不需要听懂"向量"也能跑通。
• 拆:把 PDF 变成一段一段的文字,每段都记住"我来自哪个文件、第几页"。
• 找:你一提问,它就按关键词给每段打分,挑出最相关的几段。
• 答:只把这几段原文塞给大模型,要它在答案里标明出处。
记住关键一句:它不是把文件"背"下来了,而是每次你提问,它都去翻一遍。
六、动手:四步,让它读上你的文件
第 1 步:把 PDF 复制进 data/ 目录
进到 xiaomiao-agent/,里面有个 data/ 目录(没有就自己建,全小写),把 PDF 复制进去。
文件名请起成人话:公文处理条例.pdf 比 扫描件001.pdf 强太多——
答案里那个"出自哪个文件",用的就是这个文件名。
这个目录是在 config.py 里定义好的,打开确认一眼:
DATA_DIR = BASE_DIR / "data" # 你自己的资料(PDF 放这里)
第 2 步:新建 rag.py
在工程根目录新建 rag.py,把下面这段抄进去(以下是 rag.py 的全文):
# -*- coding: utf-8 -*-"""rag.py —— Day5:让它读你自己的 PDF,并且指着原文回答。三步:拆(把 PDF 变成一段段文字)→ 找(按关键词找出最相关的几段)→ 答(只把这几段塞给模型,并要求它标出处)。这里刻意不引入向量数据库:先用纯 Python 的关键词打分跑通,你已经能听懂"检索增强"到底在增强什么,再决定要不要升级。"""import mathimport refrom pypdf import PdfReaderimport configCHUNK_SIZE = 400 # 每段目标字数OVERLAP = 80 # 相邻段的重叠,避免答案正好被切断def load_pdf(path) -> list[dict]: """把 PDF 拆成 [{来源, 页码, 内容}, ...]。""" chunks = [] reader = PdfReader(str(path)) for page_no, page in enumerate(reader.pages, start=1): text = (page.extract_text() or "").replace("\n", "") text = re.sub(r"\s+", " ", text).strip() if not text: continue # 扫描件(图片型 PDF)抽不出字,跳过 step = CHUNK_SIZE - OVERLAP for i in range(0, len(text), step): piece = text[i:i + CHUNK_SIZE] if len(piece) > 50: chunks.append({ "source": f"{path.name} 第{page_no}页", "content": piece, }) return chunksdef _terms(query: str) -> list[str]: """提问切词:中文按 2 字一段滑窗,英文按单词。够土,但对中文管用。""" cn = re.findall(r"[\u4e00-\u9fff]{2,}", query) words = re.findall(r"[A-Za-z0-9]{3,}", query) grams = [s[i:i + 2] for s in cn for i in range(len(s) - 1)] return list(dict.fromkeys(grams + [w.lower() for w in words]))def search(query: str, chunks: list[dict], top_k: int = 4) -> list[dict]: """关键词打分(TF-IDF 的简化版):命中的词越稀有、出现越多,得分越高。""" terms = _terms(query) df = {t: sum(1 for c in chunks if t in c["content"]) for t in terms} scored = [] for c in chunks: score = 0.0 for t in terms: tf = c["content"].count(t) if tf and df[t]: score += tf * math.log(len(chunks) / df[t]) if score > 0: scored.append({**c, "score": round(score, 2)}) scored.sort(key=lambda x: -x["score"]) return scored[:top_k]def build_context(hits: list[dict]) -> str: """把命中的段落拼成给模型看的资料块,带上出处。""" if not hits: return "(资料库里没有找到和问题相关的内容)" return "\n\n".join(f"[{h['source']}]\n{h['content']}" for h in hits)def load_library() -> list[dict]: """把 data/ 目录下所有 PDF 装进资料库。""" chunks = [] for pdf in sorted(config.DATA_DIR.glob("*.pdf")): chunks += load_pdf(pdf) return chunks
三个地方先讲清。
① OVERLAP = 80 为什么要重叠? 一段话正好被从中间切断,答案那句就断了;
留 80 字重叠,保证关键句完整落进至少一段。
② _terms() 的中文切词为什么这么土? 中文没空格,这里是按 2 个字往前滑一格:
"公文种类"会被切成"公文""文种""种类"三个词。土,但对中文管用。
③ 打分是 TF-IDF 的简化版:一个词在资料里越稀有,命中它的段落得分越高。
"的""了"满篇都是、几乎不加分;"公文"只出现在几段里,很值钱。
第 3 步:改 tools.py,加三处
第一处,一个 _LIBRARY 缓存:资料库只从硬盘装一次,之后都在内存里查。
# ── Day5 新增:翻你自己的资料 ──────────────────────────_LIBRARY: list = [] # 资料库只装一次,之后每次提问都在里面查
第二处,search_docs() 函数:import rag 写在函数里,这叫延迟导入——
rag.py 要 import config,写在文件顶部容易绕成循环。
def search_docs(query: str) -> str: """在 data/ 里你自己的 PDF 中检索,返回带出处的原文段落。""" import rag # 延迟导入,避免循环依赖 global _LIBRARY if not _LIBRARY: _LIBRARY = rag.load_library() if not _LIBRARY: return "资料库是空的:请先把 PDF 放进 data/ 目录。" hits = rag.search(query, _LIBRARY, top_k=4) return rag.build_context(hits)
第三处,TOOLS 列表里那份说明书——今天最重要的一段文字,原样抄:
{ "type": "function", "function": { "name": "search_docs", "description": ( "在主人自己的资料库(data 目录里的 PDF)中检索原文。" "凡是涉及资料原文、文件内容、条例规定的问题,必须先用它查," "再根据返回的原文回答,并在答案里标明出自哪个文件哪一页。" "没查到就直说没查到,不要凭常识编。" ), "parameters": { "type": "object", "properties": {"query": {"type": "string", "description": "要检索的关键词或问题"}}, "required": ["query"], }, }, },
再在 TOOL_MAP 里加一行,把函数名和函数对上:
"search_docs": search_docs, # ← Day5 加的这一行
多说一句:函数写出来了,不等于它会去用——模型只看得懂说明书,所以这段文字写死了
三件事:涉及原文必须先用它查、答案里要标明出自哪个文件哪一页、没查到就直说别编。
模型的行为,是被这段文字约束出来的。
第 4 步:跑起来验证
在 Terminal 里跑 python main.py,等它打印出 你 >,敲:
你 > 资料里对公文种类是怎么规定的
它会先打印一行工具调用,形如:
🔧 调用 search_docs({"query": "公文种类"}) → [公文处理条例.pdf 第1页] ……
箭头后面省略的一大串,就是它翻出来的原文(带 [文件名 第X页] 标头)。
留意:资料库只在你第一次提问时装载一次(那个 _LIBRARY 缓存),所以第一次会顿一下,
之后提问都很快。
再追问一句"这条出自哪一页,原文怎么写的",能引出原句,今天就成了。
🔴 隐私红线:动手之前,先读这一段
今天你要把自己电脑里的文件交给云端的接口去处理,这一段不能跳。
不要放这些资料进 data/:公司机密、内部经营数据、客户名单与联系方式;
身份证、护照等证件,病历与体检报告;合同原件与报价单。
调用云端接口,意味着文件内容会离开你的电脑,被发送到大模型的服务器上。
涉密材料请用本地模型跑,或者干脆不用。
涉及别人的信息先脱敏:真名换成"张先生""甲公司",手机号、身份证号先删掉。
七、卡住了怎么办
Q1:它回"资料库是空的",或者什么都不返回。
八成是扫描件:手机拍照的 PDF 本质是一张张图片,里面没有一个字,
page.extract_text() 对它就返回空,load_pdf() 里那句会把它跳过。
确认方法:选一行按 Ctrl+C,复制不了就是图片;解法是先做 OCR 转成文字版。
Q2:ModuleNotFoundError: No module named 'pypdf'
包没装进当前解释器。在 Terminal 里敲:
pip install -r requirements.txt
敲完确认 pip -V 的路径在 .venv 里;不在就重装一次,
PyCharm 右下角的解释器也要选成同一个环境。
Q3:答"资料库里没有找到和问题相关的内容"。
三个办法按顺序试:换词,用资料里真实出现过的词提问;
把问题写具体,别问"这份资料讲了什么",问"第 2 条对退款时限是怎么规定的";
调大 top_k,把 search_docs() 里的 top_k=4 改成 8,代价是发给模型的文字变多。
Q4:它还是编,答得头头是道但原文里没有。
先改工具说明书(第 3 步第三处),把"没查到就直说,不要凭常识编"写得更死,
再当场追问:这条出自哪一页,原文怎么写的?如果原文里没有,请直接说没有。
它一引原文就现形;引不出来,就是它在编。
Q5:几十页的 PDF,第一次提问卡住好几秒。
正常的。_LIBRARY 缓存就是这么设计的:第一次装载,后面全快。
别以为卡死就 Ctrl+C——中断了下次还得从头装。
Q6:PDF 里全是表格或双栏,抽出来的文字顺序是乱的。
page.extract_text() 按阅读顺序猜,遇表格和双栏就会串行,不是你的代码写错了。
解法:换排版干净的原文,或先转成纯文本再放进去。
八、今日代码清单
四条:
① 补装依赖(确认 pypdf 在)
pip install -r requirements.txt
② 把 PDF 复制进 data/(资源管理器里复制粘贴即可,无命令)
xiaomiao-agent/data/公文处理条例.pdf
③ 跑起来
python main.py
④ 敲进去的第一个问题
资料里对公文种类是怎么规定的
九、支线加餐【进阶,可跳过】
今天 rag.py 的打分是"关键词对不对得上",短板很明显:
同一个意思换个说法,它就找不到了。 你问"怎么请长假",资料里写的是"年休假申请",
可能一分都不给。
升级方向叫向量检索,思路只有一句话:先把每段文字变成一串数字,
意思相近的两段,数字就长得像;提问时把问题也变成一串数字,再去比"像不像",
挑最像的几段——这样"请假"和"休假"会被认成同一件事。
要改的位置很小:rag.py 里把 search() 的打分方式换掉就行,其它三处不用动。
def search(query: str, chunks: list[dict], top_k: int = 4) -> list[dict]: """向量版:把 query 和每个 chunk 都变成一串数字,按"像不像"排序。""" # 1) 调一个"把文字变成数字"的接口,拿到 query 那串数字 # 2) 每一段也做一次(或者提前算好存起来,别每次都算) # 3) 按两组数字的相似程度从大到小排序,取前 top_k 段 ...
今天先不要写这段代码,它多一个依赖、多一份花费;撞上这堵墙再动手不迟。
十、作业与验收
作业:把你自己的资料放进 data/,问 3 个"只有原文才知道"的冷门问题——
在网上查不到答案的那种,比如某个具体数字、某个条款的编号:
• 这份条例一共规定了几种公文种类,第 3 种叫什么?
• 退款申请最迟要在几个工作日内处理完?
验收标准(硬):3 题至少答对 2 个,并且能说出出自哪个文件、第几页。
再补一个动作:从它给的出处里挑一条,翻到你那份 PDF 的那一页对一眼。
对得上,这个资料库就是真能用的。
三档进度:保底是 PDF 被读到、答对 1 题;标准是 3 题答对 2 题、出处能核对;
进阶是把第九段看懂了。
十一、今日互动 + 明日预告
今天想看你留下「资料类型 + 它答对的那一题」;被它编过一次的,更值得发出来。
回复【Day5】获取今天的完整代码与配置模板。
卡住了?评论区留「Day5 + 报错原文」,我一条条回。
明天:把它装进微信——用 Flask 起一个服务,别人在微信里就能跟它说话。