AI手搓脚本批量导入知识库:扫描解析投递与断点续传 📅 发布时间:2026/9/18 9:37:24 👁 浏览次数: 折腾了大半年知识库脚本终于跑通的那天晚上我盯着终端里滚动的 1287 个文件名心情有点复杂。这大概是我第一个真正意义上纯 AI 手搓的脚本程序——从目录遍历、文件解析到批量导入知识库的接口调用代码里几乎每一行都出自 AI 之手我做的只是拆需求、卡约束、看日志、改参数。放在两年前这种人不写代码只当监工的玩法我是不信的但现在它确实躺在我硬盘里稳定跑完了两万多份文档的批量导入。这篇文章不聊虚的就把这个纯 AI 手搓脚本的完整思路摊开讲它解决了什么问题、架构为什么这么设计、每一段代码到底在干什么、踩了哪些坑、哪些地方 AI 给的答案我直接推翻重写了。如果你手里也堆着一大坨 Word、PDF、Markdown 想批量灌进知识库或者你也想试试让 AI 帮你从零搓一个能用的自动化脚本这篇应该能省你不少时间。1. 需求拆解为什么非得自己写一个批量导入脚本1.1 手动拖拽的崩溃现场先说清楚背景。我手上的资料大概分三类一类是多年积累的 Markdown 笔记散在十几个目录里结构还算规整一类是行业报告和合同模板清一色 PDF加起来四百多份还有一类是同事交接过来的 Word 文档命名风格堪称灾难什么最终版最终版2最终版_真的最终.docx全都有。最开始我的做法很原始打开知识库后台新建文档复制粘贴保存等切片完成再建下一个。前三份我还挺有耐心到第十份的时候手已经开始机械化了到第三十份我意识到一个问题——这么干两万份文档我得干到明年。而且人工操作必然伴随遗漏和重复同一份文件我可能因为手滑点了两次有些文件在文件夹深处我根本没想起来还有些文件名里的空格和中文括号会让手动输入变得极其难受。这时候真正的痛点就浮出来了知识库的价值在于全而我手动搬的过程天然保证不了全。一个漏掉的目录可能正好就是最关键的那批资料。1.2 三种导入路径的对比与取舍在动手写脚本之前我把能想到的路子都过了一遍做了个简单的对比方案实现成本可维护性适用规模主要问题后台手动上传极低差20 份以内容易漏、容易重、无法追溯知识库自带文件夹同步低中中等依赖部署形态格式支持有限调用 API 批量投递中高上千份无压力需要处理限流、断点、解析直接操作底层向量库高差特殊场景耦合太深升级即翻车我最后选了第三条路调用知识库暴露的 HTTP 接口用脚本把本地文件一份一份喂进去。理由有三条很实在。第一API 是有状态可记录的。每份文件投递成功还是失败、花了多久、返回了什么 ID我都能落进日志里。手动上传是黑箱出了问题只能重新翻一遍脚本投递是白盒出错能定位到具体文件。第二API 能把解析这一步交给我自己控制。很多知识库自带的文档解析不太理想尤其是那种排版复杂的 PDF切出来的段落七零八落。我自己在本地先把文本抽干净、把标题层级理清楚再送进去检索质量会明显好一截。第三脚本是可以反复跑的。今天导入一批下周新增了三十份我再跑一次就行——前提是脚本得支持增量识别这一点后面会专门讲。提示选方案之前先确认你的知识库是否开放了文档写入接口以及接口是否要求企业版授权。有些平台把批量写入放在付费档位里动手前先确认别写完了发现调不通。1.3 什么样的知识库最适合接脚本从我的经验看能被脚本喂的知识库通常有三个特征有稳定的文档创建接口、支持以纯文本或文件流的方式提交内容、返回结构化的文档 ID。RAG 类知识库基本都满足比如 Dify 这类开源方案的知识库模块接口文档写得相对清楚创建文档、查询状态、删除文档都有对应端点非常适合做批处理。反过来如果你的目标是像 Obsidian 这种纯本地 Markdown 库那导入这个词的含义就变了——它本质上就是文件放进目录不需要调接口脚本只需要做格式清洗和目录归位。这两种场景的脚本写法差别很大动手前一定要先想清楚你到底是在投递还是在归位。2. 整体架构设计AI 辅助写脚本的正确打开方式2.1 三段式结构扫描、解析、投递整个脚本我拆成了三段每一段职责单一可以单独运行、单独调试扫描段负责把一个根目录下的所有目标文件找出来过滤掉不该处理的目录和文件类型输出一份待办清单。这一段不碰文件内容只认路径和后缀。解析段负责把清单里每个文件读成纯文本同时抽取元数据——来源路径、文件名、最后修改时间、所属目录标签。解析失败的文件单独记录不阻塞后面的流程。投递段负责把文本和元数据通过接口送进知识库处理限流、重试、失败登记最后生成一份导入报告。这么拆的最大好处是可回滚。扫描有问题我只看清单就知道解析出乱码我单独跑解析段就能复现接口挂了前两步的产物已经落盘等接口恢复直接重跑投递段不用从头再来。注意千万不要把三段揉进一个函数里。我第一版图省事一个 main 函数从头跑到尾结果第十份文件解析报错整个流程中断前面九份白跑。拆开之后任何一段崩了都不影响其他段的产物。2.2 为什么是 Shell 加 Python 混着写有朋友问我既然都用 AI 写了为什么不干脆全用 Python或者全用 Shell我当时的考虑是这样的。Shell 适合做调度和文件系统层面的粗活设置环境变量、切换工作目录、串联多个步骤、处理退出码、把日志重定向到带时间戳的文件里。这些东西用 Shell 写就是几行用 Python 写反而啰嗦。Python 适合做内容层面的细活解析 docx、解析 PDF、算哈希、发 HTTP 请求、处理 JSON、做指数退避重试。这些用 Shell 写就是自我折磨。所以最终形态是一个run.sh做总调度三个 Python 脚本做具体工作中间用 JSON 文件当接口Shell 完全不需要理解 JSON 里面是什么。#!/usr/bin/env bash set -euo pipefail ROOT_DIR${1:-./docs} WORK_DIR./.kb_import mkdir -p $WORK_DIR echo [1/3] 扫描文件... python3 scan.py --root $ROOT_DIR --out $WORK_DIR/manifest_scan.json echo [2/3] 解析文本... python3 parse.py --in $WORK_DIR/manifest_scan.json --out $WORK_DIR/manifest_parsed.jsonl echo [3/3] 批量投递... python3 push.py --in $WORK_DIR/manifest_parsed.jsonl \ --state $WORK_DIR/state.json \ --log $WORK_DIR/push.log echo 全部完成报告见 $WORK_DIR/push.logset -euo pipefail这三个参数很重要值得单独说一下。-e让脚本遇到非零退出码立刻停止-u让引用未定义变量直接报错-o pipefail让管道中任意一环失败都算失败。少了这几个脚本会在出错后继续往下跑最后给你一个看起来成功了的假象这种坑最难查。2.3 和 AI 协作的提示词拆分策略这是我觉得最值得分享的部分。很多人用 AI 写脚本习惯一次性把需求全丢过去帮我写个脚本把文件夹里的文档批量导入知识库。这样拿到的代码通常看着挺全但一跑就废——因为它不知道你的目录结构、不知道你的接口长什么样、不知道你的文件命名习惯。我的做法是把提示词拆成四层逐层喂第一层给角色和边界。你是一名 Python 工程师写一个只做文件扫描的函数输出文件绝对路径列表。不要做任何文件读取不要发网络请求。 边界越清楚AI 越不容易自作主张加东西。第二层给输入输出样例。直接告诉它输入是什么、输出长什么样。比如输入是一个 Path 对象指向根目录输出是一个生成器逐个 yield Path 对象。第三层给约束和例外。跳过 .git、node_modules、.trash 目录跳过软链接后缀只保留 .md/.txt/.docx/.pdf目录名含空格和中文要能正确处理。 这些例外才是真实世界和玩具代码的分界线。第四层才是让它写实现。前三层对齐之后实现部分基本一次就能过。我实测下来这种分层喂的方式代码一次通过率能从三成提到八成以上。反过来如果你把四层混成一段话丢过去AI 会挑它最容易实现的那部分做剩下的细节全部糊过去。3. 核心细节解析扫描、解析与元数据设计3.1 目录遍历与文件类型白名单扫描看着简单其实细节不少。第一个坑是递归遍历时的性能问题如果根目录下挂着一个巨大的node_modules或者.gitrglob(*)会把里面每一个文件都枚举一遍几万个小文件能让脚本卡上几十秒。我的处理是提前判断路径里有没有需要跳过的片段一旦命中就直接剪枝from pathlib import Path ALLOW_SUFFIX {.md, .markdown, .txt, .docx, .pdf} SKIP_PARTS {.git, .obsidian, node_modules, __pycache__, .trash, .kb_import} def walk(root: Path): root root.resolve() for p in sorted(root.rglob(*)): if any(part in SKIP_PARTS for part in p.parts): continue if p.is_symlink(): continue if not p.is_file(): continue if p.suffix.lower() not in ALLOW_SUFFIX: continue yield p这里p.is_symlink()那行是我后来补的。起因是我有个目录用软链接指回了上层结果脚本开始无限递归文件数从一千多突然涨到几十万跑了半天没停。加上这一行之后立刻正常。第二个细节是排序。sorted()看起来多余但它保证了每次运行的扫描顺序一致配合后面的断点续传才能对得上。不排序的话文件系统返回的顺序在不同机器上可能不同状态文件就对不上了。3.2 Word、PDF、Markdown 的文本提取差异三种格式的解析难度完全不在一个量级上我把它们分开处理。Markdown 最简单直接读文件就行。但有一点要注意读的时候必须显式指定编码encodingutf-8而且最好加errorsreplace否则遇到一个混了 GBK 的老文件整个流程就中断了。Word 中等难度。文本不能只从paragraphs里拿因为很多文档的关键信息其实在表格里。表格如果不处理导进去的就是残缺内容。我用的方案是把段落和表格按顺序都抽出来表格用竖线拼成一行import docx def parse_docx(path: Path) - str: d docx.Document(str(path)) buf [] for para in d.paragraphs: t para.text.strip() if t: buf.append(t) for table in d.tables: for row in table.rows: cells [c.text.strip().replace(\n, ) for c in row.cells] line | .join(c for c in cells if c) if line: buf.append(line) return \n\n.join(buf)PDF 最麻烦麻烦在于它分两种情况带文字层的和纯扫描件的。带文字层的用pdfplumber或pypdf就能抽扫描件抽出来是空字符串必须走 OCR。我第一版脚本没做这个区分结果四百多份 PDF 里有一百多份导进去是空的检索时怎么都搜不到排查了半天才发现是扫描件。我的做法是在解析结果里加一个长度判断如果抽出来的文本长度小于某个阈值就在日志里打一个醒目的标记让这些文件走人工通道def parse_pdf(path: Path) - tuple[str, str]: import pdfplumber buf [] with pdfplumber.open(str(path)) as pdf: for page in pdf.pages: txt page.extract_text() or if txt.strip(): buf.append(txt) text \n.join(buf) if len(text.strip()) 50: return text, SUSPECT_SCANNED return text, OK提示不要指望脚本能自动化处理所有情况。遇到扫描件与其硬上 OCR识别率不稳定还可能把数字和表格识别错不如把这类文件单独列出来人工确认一遍再用专门的工具处理。一百份文件人工过一遍是两小时的事写一套稳定的 OCR 后处理流程可能是两天。3.3 元数据怎么设计才方便后续检索元数据这部分我返工过两次值得多写几句。第一版我只存了文件名和文本导进去之后发现检索结果全是文件名完全看不出内容属于哪个项目、哪年写的用起来很难受。第二版我把元数据设计成了这样几个字段字段来源用途source_path文件的相对路径定位原文方便回查file_name文件名展示用去掉后缀dir_tags相对路径按斜杠拆开充当天然的目录标签mtime文件最后修改时间判断资料新旧fingerprint内容哈希增量导入和幂等判断parse_status解析状态标记扫描件、解析失败其中dir_tags这个设计我觉得挺妙的。因为我的资料目录本身就是按项目/年份/类型组织的相对路径拆开之后天然就是一组标签不用再让 AI 去猜文件属于什么分类。比如一个文件路径是行业研究/2023/新能源/XX报告.pdf拆出来的标签就是行业研究、2023、新能源检索的时候按标签过滤非常准。fingerprint是幂等的关键。我用文件内容的 SHA256 加上修改时间做一个哈希投递前先查状态文件里有没有这个哈希有就跳过。这样同一批文件重复跑一百次也只有第一次会真正投递。import hashlib def fingerprint(path: Path, text: str) - str: h hashlib.sha256() h.update(text.encode(utf-8, errorsreplace)) h.update(str(path.stat().st_mtime_ns).encode()) return h.hexdigest()[:32]3.4 分块策略与 token 估算文本切块这件事直接影响检索质量比很多人想的要重要。切得太碎语义不完整检索出来的片段读起来像断句切得太粗一个块里混了好几个主题向量表示会被平均掉检索精度下降。我的策略是优先按标题切其次按段落切最后才按长度硬切。具体逻辑是先找 Markdown 里的一级二级标题以标题为边界切大块如果某个大块还是超过上限就再按空行切段落段落还超就按句子切。长度上限我用的是 800 个字符重叠 120 个字符。这个数字不是拍脑袋来的我做过一轮小规模测试拿 20 个已知答案的问题去检索分别用 500、800、1200 三种块长跑800 这一档的召回准确率最高500 会因为上下文太短丢信息1200 会因为块内主题太杂拉低相似度。当然这只是我的语料上的结果你的资料风格不一样建议也做一轮这样的对比。如果你的知识库服务端自己会做切片那本地这一步可以简化只做超长文本的预切。但要注意服务端的切片规则通常是固定长度的对标题结构不敏感遇到技术文档和报告容易切坏。我个人的偏好是本地切好把每个块当成一份独立文档投递元数据里带上块序号检索时可以通过序号把相邻块拼回去看上下文。4. 完整实操从零把脚本跑起来4.1 环境准备与依赖安装环境这块没什么花活Python 3.9 以上就行依赖也就几个python3 -m venv .venv source .venv/bin/activate pip install requests pdfplumber python-docx pyyaml四个包的用途分别是requests发 HTTP 请求pdfplumber抽 PDF 文字python-docx读 Wordpyyaml读配置文件。没有一个是重型依赖装起来很快。Windows 上要注意一点虚拟环境激活脚本是\.venv\Scripts\activate而且如果你在 PowerShell 里跑可能会因为执行策略被拦下来。这时候不用去改系统策略直接换成cmd或者用\.venv\Scripts\python.exe显式调用解释器就行后者更省事。注意如果你是从别的机器上拷过来的脚本第一次跑之前先确认换行符。Windows 上编辑过的.sh文件在 Linux 上会因为\r\n报错报错信息通常是bad interpreter或者$\r: command not found。一行sed -i s/\r$// run.sh就能解决。4.2 配置文件设计把易变的东西全部抽出来硬编码是脚本的头号敌人。接口地址、密钥、目录路径、并发数、重试次数这些东西写死在代码里换一个环境就得改代码改完还容易漏。我的做法是全部塞进一个config.yamlsource: root: ./docs allow_suffix: [.md, .txt, .docx, .pdf] skip_parts: [.git, .obsidian, node_modules, .trash] chunk: max_chars: 800 overlap_chars: 120 target: base_url: https://your-kb-host/v1 dataset_id: your-dataset-id api_key_env: KB_API_KEY timeout: 60 throttle: qps: 2 max_retry: 5 backoff_base: 1.5注意api_key_env这一项存的是环境变量的名字不是密钥本身。密钥通过环境变量注入这样配置文件可以放心提交到仓库不会泄密。这是个很小的习惯但能避免很多麻烦。export KB_API_KEY你的密钥4.3 投递模块限流、重试和超时投递是整个脚本里最容易出问题的部分因为它依赖外部服务。我踩过的坑包括接口限流返回 429、单个大文件超时、网络抖动导致连接中断、服务端偶发 5xx。处理这些的标准做法是指数退避重试只对可重试的错误码重试import time import requests import os RETRYABLE {429, 500, 502, 503, 504} def push_one(cfg, name, text, meta, session): url f{cfg[target][base_url]}/datasets/{cfg[target][dataset_id]}/document/create-by-text headers { Authorization: fBearer {os.environ[cfg[target][api_key_env]]}, Content-Type: application/json, } payload { name: name, text: text, indexing_technique: high_quality, process_rule: {mode: custom}, } last_err None for attempt in range(cfg[throttle][max_retry]): try: resp session.post(url, jsonpayload, headersheaders, timeoutcfg[target][timeout]) except requests.RequestException as e: last_err e else: if resp.status_code 300: return True, resp.json() if resp.status_code not in RETRYABLE: return False, resp.text last_err fHTTP {resp.status_code} wait cfg[throttle][backoff_base] ** attempt time.sleep(min(wait, 30)) return False, str(last_err)这段代码里有几个细节值得说。session是复用的不是每次请求都新建。TCP 连接复用能显著降低高频请求的失败率尤其是在 QPS 稍微高一点的时候。退避时间用1.5 ** attempt也就是 1 秒、1.5 秒、2.25 秒这样递增并且用min(wait, 30)封顶。为什么要封顶如果不封顶第五次重试要等 7.6 秒看起来还好但如果你的backoff_base设成 2第五次就是 16 秒中间一旦有几十个文件同时遇到限流脚本会卡到你以为它死了。最关键的判断是if resp.status_code not in RETRYABLE: return False。这一行把不该重试的错误和该重试的错误分开了。比如 401 是密钥错了你重试一百次也没用400 是请求体格式不对重试同样没用。只有限流和服务端故障才值得重试。这个逻辑不加脚本遇到密钥失效会硬扛五次重试白白浪费几分钟。4.4 断点续传状态文件怎么存脚本跑到一半被打断是常事——网络断了、我不小心关了终端、服务端临时维护。这时候如果没有断点续传就得从头再来两万份文件重跑一次动辄一两个小时。我的做法是维护一个状态文件记录每个文件哈希的投递结果{ a1b2c3d4e5f6...: {status: ok, doc_id: doc-xxx, ts: 1700000000}, 9f8e7d6c5b4a...: {status: failed, reason: HTTP 400, ts: 1700000005} }每投递成功一份立刻写一次状态文件。注意是立刻不是最后统一写。我第一版是全部跑完才落盘结果有一次跑到 80% 断电状态全丢了。写状态文件还有一个技巧先写临时文件再原子替换。否则刚好在写的过程中被打断状态文件会变成一个残缺的 JSON下次读的时候直接解析报错。import json, os, tempfile def save_state(path, state): d os.path.dirname(os.path.abspath(path)) fd, tmp tempfile.mkstemp(dird) with os.fdopen(fd, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) os.replace(tmp, path)os.replace在同一个文件系统内是原子操作这样不管什么时候中断状态文件要么是旧的完整版本要么是新的完整版本不会出现半截内容。4.5 限速参数怎么算出来qps: 2这个值我是这么定的。先查接口文档看它有没有写明速率限制我这边文档里写的是每分钟 120 次也就是每秒 2 次。但这是理论上限实际跑的时候服务端还要做切片和向量化压力比单纯接收请求大得多。我的做法是在理论上限的基础上打七折用 1.4 左右。然后跑一百份文件观察失败率如果 429 出现次数在三次以内就往上加反之就往下调。最后稳定在 1.5 左右两万份文件跑了大约三个半小时中间只有零星几次限流重试。这个调参过程听着繁琐但比设个高并发然后被服务端拉黑要省事得多。批量导入这种任务本来就不追求实时性稳比快重要。5. 常见问题与排查技巧实录5.1 中文乱码和文件名特殊字符乱码问题我遇到过两次原因不同。第一次是 Markdown 文件读了半天全是问号。原因是老文件用了 GBK 编码而我用 UTF-8 强读。解决办法是读的时候加errorsreplace同时在日志里记录哪些文件出现了替换字符事后单独处理。更稳妥的方案是用chardet之类的库先探测编码但会增加依赖我选择先用简单方案。第二次是文件名导致的。有几个文件名里带空格和中文全角括号我在 Shell 里用for f in $(ls)遍历空格直接把文件名劈成了两半。正确的做法是不要用 Shell 遍历文件全部交给 Python 的pathlib处理。如果你非要在 Shell 里遍历用find . -print0 | while IFS read -r -d f这种写法-print0和read -d 配对才能正确处理空格和换行。提示投递到知识库的文档名最好做一次清洗把换行、制表符、连续空格全部替换掉长度截断到 100 字符以内。有些接口对文档名长度有限制超了会直接返回 400而错误信息往往很含糊查起来很费劲。5.2 重复导入和幂等性幂等这件事我一开始没当回事结果知识库里出现了同一个文件的七八个副本检索的时候同一段内容重复出现体验很差。根因是只按文件名判重。不同目录下同名文件很常见比如十几个目录里都有README.md反过来同一个文件被复制到别的目录文件名一样但内容相同也会被当成两份。正确的判重维度是内容哈希。只要哈希一样就认为是同一份内容跳过。如果确实需要保留同一个内容在不同目录下的版本那就在哈希里加上路径参与计算。我的选择是内容哈希加修改时间理由是这样既能避免重复又能在文件被修改后重新导入。还有一个细节如果投递请求发出去了但响应超时你并不知道服务端到底建没建。这时候重试就会产生重复。处理办法是先查一次文档列表按文档名匹配匹配到就认为已经建好了。这个查询接口一般都有值得花十分钟加上。5.3 排查速查表我把这段时间遇到的问题整理成了下面这张表遇到类似现象可以对着查现象最可能的原因排查动作脚本秒退没有任何输出set -e遇到某个命令非零退出临时去掉-e看真实报错bad interpreter换行符是 CRLFsed -i s/\r$// 脚本名全部文件都投递失败密钥错了或环境变量没导出echo $KB_API_KEY确认大量 429并发太高调低qps看退避是否生效PDF 导进去是空的扫描件没有文字层看日志里SUSPECT_SCANNED标记中文变成问号文件编码不是 UTF-8检查日志里的替换字符计数同一份文档重复出现判重逻辑只按文件名改成内容哈希判重文件数远超预期软链接导致循环遍历加is_symlink()判断跑到一半卡住不动单文件超时且没有设 timeout给请求加显式 timeout状态文件解析报错写入过程被打断改成临时文件加原子替换5.4 一个容易被忽略的检查点投递后的切片状态投递接口返回 200 不代表文档就能被检索到它只是收到并排队。服务端还要做文本清洗、切片、向量化这个过程对一份长文档可能要几十秒。我一开始不知道这个差别脚本跑完立刻去检索发现什么都搜不到以为是投递失败了又重跑了一遍结果造成大量重复。后来才明白要去查文档的状态字段等它变成已完成再开始检索测试。所以如果你的脚本是投递完就结束最好在最后加一个轮询环节把所有投递成功的文档 ID 收集起来定期查一次状态把还在处理中的数量打进日志。这不是必须的但能让你对整批导入的进度心里有数也方便判断什么时候可以进行检索效果验证。6. 从纯 AI 手搓脚本这件事上我攒下的几点体会6.1 AI 写的代码审查重点在哪里两万多份文件跑下来我对AI 写的代码哪些地方必须自己盯有了比较清晰的认识。边界条件必须自己看。AI 写主流程很利索但对空文件、零字节文件、超长单行、循环软链接这类边界情况的处理经常是缺失的。我的做法是专门写一个edge_cases目录里面放各种畸形文件——空文件、只有一行的文件、编码混用的文件、超大文件——每次改完脚本先拿这个目录跑一遍。错误处理必须自己看。AI 倾向于写try: ... except Exception: pass这种写法在脚本里是灾难因为它会把所有问题吞掉。我现在拿到 AI 生成的代码第一件事就是搜except看每一个捕获是不是做了最起码的日志记录。资源释放必须自己看。文件句柄、HTTP 会话、临时文件这些东西用完了该关的要关。批量处理几千个文件的时候句柄泄漏会让你在某一个临界点上突然报too many open files而且报错位置和真正的原因隔得很远。6.2 后续可以怎么扩展这套脚本目前只是个一次性搬运工但它的骨架其实能撑起更多东西。第一个扩展方向是定时增量同步。把脚本挂到系统定时任务里每天凌晨跑一次靠内容哈希自动判断哪些是新文件只投递增量部分。这样知识库就变成了一个持续更新的状态而不是一次性的快照。要注意的是定时任务里的环境变量和交互式终端里不一样密钥一定要在任务脚本里显式导出。第二个方向是投递前的质量预处理。比如自动去掉页眉页脚、自动合并被换行打断的段落、自动识别并跳过目录页和封面页。这些处理能明显提升检索质量但需要针对你的语料特点来写通用方案效果有限。第三个方向是加一层导入后的效果验证。准备一批问题—期望答案的测试集每次导入完成后自动跑一遍检索看命中率有没有下降。如果某次导入之后命中率明显掉了很可能是那批新文件里混进了大量低质量内容把检索结果稀释了。这个环节我目前还在搭但思路是清楚的导入不是终点能用起来才是。最后说个实在话。这套东西从有想法到跑通前后大概花了三个周末其中真正写代码的时间不到三分之一剩下的都花在调试参数、处理各种畸形文件、看日志找问题上。AI 帮你把键盘活干了但这份文件为什么解析出来是空的这个 429 到底是并发高还是密钥限速这类判断还得你自己拿日志一点点啃。脚本能跑通的那一刻确实爽但爽之前的那段排查才是真正长本事的部分。