RAG入库前文档体检:用Agent技能封装27个扫描脚本

发布时间:2026/10/20 3:36:58
RAG入库前文档体检:用Agent技能封装27个扫描脚本
做RAG的人迟早会碰到一个尴尬时刻用户提问后检索结果返回的文本要么是乱码、要么带着手机号系统看似在认真回答实际已经泄露了不该出现的内容。问题大多不在检索模型而在“入库前”没有做文档体检。我在实际项目里把文档扫描做成了Agent技能用27个脚本覆盖格式解析、编码检测、乱码评估、敏感信息识别、重复文档过滤等场景。核心思路就一句话脚本不进大模型上下文用户只用一句话触发整个扫描流程。今天把这套方案的设计、代码、踩坑完整拆出来给要搭RAG知识库的同学作参考。这套方案适合谁如果你是做RAG开发、知识库运维或者刚接触Agent结构设计又恰好被“脏文档进库、敏感信息暴露、检索效果差”折磨过这篇内容可以直接照着落地。下面从为什么需要扫描、脚本分类、机制设计、调度器实现、实测数据、排查经验六个部分展开。1. 为什么入库前必须做文档扫描1.1 不扫描入库的典型翻车现场许多RAG项目上线前最容易被忽略的就是“文档卫生”。我在某企业知识库项目里见过大量真实案例。公司导入的合同PDF表格区域被解析成一堆粘连的英文字母和数字某个系统导出的TXT文本一半是UTF-8一半是GBK大模型把乱码和正文混在一起切成chunk更麻烦的是有些Word文档自带修订记录文档里同时存在“修改前”和“修改后”两套内容检索时命中旧版本回答自然就错了。最典型的翻车是敏感信息入库。某次测试中一批Excel导出的数据库备份说明文档混着测试人员的手机号、身份证号、内网IP进入向量库线上问答系统被问到“联系方式”时直接把这些原文吐出来。虽然只是县级的内部测试环境但也足够让人冒冷汗。这类问题的根源不是向量检索优化不到位而是文档在入库前缺了一道过滤和质检工序。1.2 为什么不能让大模型直接扫描文档有人会说既然大模型阅读理解能力强直接把文档丢给它扫描不就行了。理论上可行实际跑起来全是坑。先算token账。我这边有一批约1200份文档的测试集文本总量接近4000万字符。按一个汉字约两个token粗略估算约8000万token。如果用大模型逐个扫描按当前商用模型API价格粗略计算光推理成本就足以让项目提前结束。更不用说上下文窗口限制即使文档拆成多个段每段交给大模型判断耗时和成本也是线性增长完全背离了“入库前扫描”该有的轻量特征。另一个问题是模型输出的不确定性。大模型判断“文本是否乱码”“编码是UTF-8还是GBK”本身就不稳定。同样的文本换一个Prompt措辞就可能从“正常”变成“乱码”这种不可控性在工程化流程里很难接受。扫描这件事适合用确定性的规则脚本去完成而不是让生产式模型做判断。1.3 Agent Skills带来的思路转变我们需要的模式是大模型不下场做脏活只负责理解用户指令、拆解任务扫描动作交给一组外部脚本每个脚本负责一个非常具体的判断。这套模式就是Agent Skills的典型应用——把能力封装成“技能”注册给AgentAgent根据用户的话自动选择调用哪些技能。注意这里说的“不进上下文”不是故弄玄虚。27个脚本加起来有上万个函数、约两万行代码如果全部塞进系统提示词单次请求的token消耗会直接爆炸。正确做法是只把技能的名称、功能描述、参数说明这类“元信息”暴露给大模型脚本源码、中间结果、局部变量全部留在外部进程里。这样模型上下文里只有轻量级描述实际执行完全靠脚本完成。2. 27个扫描脚本的分类设计2.1 七大类扫描脚本总览这27个脚本是我在多次迭代后沉淀下来的一套组合覆盖了入库前最关键的检查项。分类如下脚本类别数量核心职责返回要点格式探测类5判断文件类型、解析PDF/Word/HTML/Markdown结构格式、页数、解析置信度编码识别类3检测字符集、判断UTF-8/GBK/ANSI编码名、置信度乱码与文本质量类4扫描乱码字符、空格、控制字符乱码比例、异常位置清洗规范化类4BOM头、零宽字符、全角半角统一处理前后的文本摘要敏感信息类4手机号、身份证号、邮箱、内网IP命中类型、位置、脱敏后文本私密数据类4API密钥、数据库连接串、云凭证、测试账号风险等级、建议动作去重与质量打分类3精确哈希去重、近似去重、文本质量打分重复率、质量得分这27个脚本对应一个核心原则单一职责。每个脚本只做一件事例如“detect_mobile_phone”只负责手机号识别“detect_garbled_text”只负责乱码判断。好处非常直接单个脚本逻辑清晰测试维护容易出问题时不互相牵连更重要的是Agent在调度时可以根据任务描述精确选到正确的脚本组合不会出现“一个万能脚本做所有事”导致的误判。2.2 核心脚本的关键实现格式探测类里的PDF解析脚本重点不只是提取文本还要检查消息。PDF可能含有扫描图片直接提取是空的必须检测“文本层是否存在”如果文本层缺失就需要调用OCR判断。这里有个细节检测PDF是否带文本层用常规库读取内容后判断“字符数是否接近零”比直接看文件大小更可靠。乱码检测脚本是这类项目里最容易写歪的。早期版本我用正则匹配“”这类替换字符后来发现大量GBK编码的中文文本被错误识别为乱码因为这些文本在UTF-8环境下本就会显示成“锟斤拷”。后来改成组合策略先查替换字符密度再查连续控制字符比例最后结合编码检测结果判断。三个指标合成一个评分低于阈值才判定为乱码误报率降了不少。敏感信息脚本中手机号检测要额外注意边界。单纯用“1[3-9]\d{9}”这种正则在长数字串里会疯狂误报比如一串流水号中间恰好遇到13位连续数字。实际方案是加边界判断要求号码前面不能是数字或字母后面也不能紧跟数字再配合“号段白名单”校验。这样能把误报率从十位数级别压到个位数。2.3 为什么要坚持脚本参数最小化每个脚本的入参我控制在一个到两个通常是“文件路径”或“文本字符串”最多外加一个可选配置比如“是否启用白名单”。参数越少Agent调度时越容易自动补全。如果脚本需要七八个参数大模型每次都得猜猜错就执行失败根本无法做到“一句话触发”。输出格式同样做了统一。所有脚本返回一个结构一致的JSON字段包括scan_type、status、confidence、summary、details。summary是扼要的总体结论details是详细命中信息。这个统一约定让我可以写一套通用的调度逻辑而不用为每个脚本单独写解析分支。3. 不污染上下文的关键机制3.1 技能注册表只暴露描述要把27个脚本“藏起来”需要先给每个脚本做一份注册信息。我的做法是在脚本目录里建一个registry.json每条记录包含四个字段技能名、功能描述、参数说明、脚本路径。描述字段最关键它决定了大模型会不会选错工具。我花了很长时间打磨每个描述。例如乱码检测脚本的描述写成“检测文本中的乱码字符比例适用于打开后出现锟斤拷、%uFFFF等异常内容的文件输入文本或文件路径返回乱码比例与可疑位置”。这句描述明确告诉模型“什么时候该用、输入什么、得到什么”。字段很短全部27条注册信息加起来约1.8KB。这1.8KB就是大模型上下文里关于扫描能力的全部内容。3.2 结果摘要化只回传统计信息脚本执行完会产生大量数据。比如敏感信息扫描脚本可能逐个展示每条命中记录包含原文片段、位置、类型数据量能到几百KB。如果直接把完整结果丢回大模型上下文那“不进上下文”就成了一句空话token消耗照样爆炸。我做了数据流拆分脚本往输出目录写一份完整JSON报告文件供人工检查和后续审计调度器只把“统计摘要”回传给大模型例如“共扫描5个文件2个文件检出手机号命中命中位置集中在Excel导出文本第3段”大模型拿到摘要后如果用户需要进一步处理再指定具体文件去读报告文件的具体片段。这就像选人面试简历详细版放在档案室面试官手里只有一页纸的关键信息。需要细节时再去翻档案而不是把整份档案背给面试官听。3.3 批次扫描与并发控制一次性把1200份文档全部丢给脚本执行内存和IO会先扛不住。调度器加入了批次控制逻辑默认每批处理50个文件使用线程池最大并发数限制在8。分批还能带来一个好处每批结束后都能输出进度日志和中间结果如果某批出现异常可以跳过该批继续跑不用全部推倒重来。这个机制从工程实践角度看非常必要。有一次测试中某批次文件里混入了一个超大PDF提取出300MB的纯文本单脚本处理耗时十几分钟。如果不分批整个扫描任务会被这一个文件卡死。后来加了一个文件大小阈值检查超过10MB的文件标记为“超大文件”单独走降级扫描流程问题才解决。4. 一句话触发与调度器实现4.1 指令解析意图识别加技能匹配“一句话完成扫描”的关键在调度器的指令解析层。我没有用复杂的Agent框架而是用了一个轻量模式先把用户指令做意图分类再有针对性地匹配技能组合。处理流程将用户指令传给大模型让它输出一个结构化意图比如“全量扫描”“敏感信息专项扫描”“格式体检”根据意图映射到一组脚本名称例如“全量扫描”对应全部27个脚本“敏感信息专项扫描”只对应敏感信息类加私密数据类共8个脚本调度器从指令里提取路径、文件类型等参数补全脚本调用入参然后执行。举例来说用户输入“扫描一下这批入库文档看看有没有敏感信息”大模型输出的是“sensitive_check”调度器自动匹配8个特定脚本。如果用户说“帮我看看这批文档有没有乱码、能不能用”意图就拆成“encoding_check garbled_check quality_score”对应3个脚本。4.2 调度器核心代码示例下面这份代码是从实际项目里抽出来的简化版本核心测通没有问题。整份代码包括技能注册、动态加载、线程池执行、报告汇总四块。import os import json import importlib from concurrent.futures import ThreadPoolExecutor, as_completed # 1. 技能注册表读取轻量描述 def load_registry(pathregistry.json): 加载技能注册表只包含技能名、描述、脚本路径 with open(path, r, encodingutf-8) as f: return json.load(f)[skills] # 2. 动态加载每个脚本 def load_scanner(skill_entry): 按路径导入扫描模块并返回可调用函数 module importlib.import_module(skill_entry[module]) func getattr(module, skill_entry[function]) return skill_entry[name], func # 3. 单文件扫描跑一个skill_list返回摘要 def scan_one_file(file_path, skill_entries, work_dir): 对单文件执行一组扫描技能输出完整报告和摘要 result_summary [] for entry in skill_entries: try: scanner_name, func load_scanner(entry) detail func(file_path) # 脚本自身返回完整JSON调度器只提取summary部分 summary detail.get(summary, 扫描完成) result_summary.append({ skill: scanner_name, status: detail.get(status, ok), confidence: detail.get(confidence, 0), summary: summary, }) except Exception as e: result_summary.append({ skill: entry[name], status: error, summary: f{type(e).__name__}: {e}, }) # 写完整报告到输出目录 report_path os.path.join(work_dir, reports, os.path.basename(file_path) .json) with open(report_path, w, encodingutf-8) as f: json.dump(result_summary, f, ensure_asciiFalse, indent2) return report_path, result_summary # 4. 批次调度接收意图参数执行批量扫描 def dispatch_batch(data_dir, skill_names, batch_size50, max_workers8): 按指定的技能名列表扫描指定目录下的所有文件 registry load_registry() skill_map {s[name]: s for s in registry} skill_entries [skill_map[name] for name in skill_names if name in skill_map] file_list [] for root, _, files in os.walk(data_dir): for f in files: file_list.append(os.path.join(root, f)) all_reports [] for i in range(0, len(file_list), batch_size): batch file_list[i:i batch_size] with ThreadPoolExecutor(max_workersmax_workers) as executor: futures { executor.submit(scan_one_file, fp, skill_entries, data_dir): fp for fp in batch } for future in as_completed(futures): report_path, summary future.result() all_reports.append({ file: futures[future], report: report_path, summary: summary, }) # 为一整批生成最终汇总报告 with open(os.path.join(data_dir, batch_report.json), w, encodingutf-8) as f: json.dump(all_reports, f, ensure_asciiFalse, indent2) return all_reports # 使用示例只跑敏感信息类扫描 if __name__ __main__: sk [ detect_mobile_phone, detect_id_card, detect_email, detect_inner_ip, detect_api_key, detect_db_conn_string, detect_cloud_credential, detect_test_account ] dispatch_batch(./data/input, sk)这份代码看起来不复杂但有几个点值得琢磨。load_registry每次都读JSON实际项目里我会在进程启动时只读一次缓存到全局变量避免每批扫描都重复IO。dispatch_batch里用了线程池但脚本本身如果是纯IO密集型的8个并发可以接近最优如果是CPU密集型的线程数要压到CPU核数否则会互相抢资源。这个我是用不同文件实测后才确定的。4.3 一次完整调度的实测过程记录我在项目里跑过这样一条真实指令“把data/raw目录下的文档做全量体检有问题的一起列出来”。调度器的执行过程如下第一步指令解析。模型输出意图是“full_scan”参数里带上了路径“data/raw”。第二步技能匹配。从注册表里把27个技能条目全部取出来。这里有个细节全量扫描也只需要把这些技能名从registry.json里读出来塞进dispatch_batch的skill_names列表不用大模型介入每次调用的内部逻辑。第三步批次执行。扫描过程中每处理完一批终端会打印进度例如“batch 5/24 done, 12 files flagged”。第四步汇总输出。执行完1200份文件后生成一份batch_report.json里面每个文件都带有各自的摘要信息。这整个过程中大模型只在第一步参与了一次意图解析剩下的工作全是确定性脚本完成的上下文没有被扫描数据污染。实际跑下来这次全量体检的耗时大约11分钟Token消耗等同于一次普通对话而不是以万为单位的扫描调用。5. 实测效果与数据对比5.1 测试数据集与核心指标为了验证这套方案我特意构造了一个控制变量测试集共1200份文档。数据集覆盖了PDF、DOCX、TXT、HTML、Markdown等格式其中约200份预设了问题包括乱码、敏感信息、编码混杂、重复内容、损坏文件等。指标不扫描直接入库人工抽检纯LLM扫描27脚本扫描处理耗时约3分钟只做切分4人天约26小时约11分钟大模型Token消耗00约8000万约1万问题检出率预设200份0%约70%约88%约96%问题误报率0%约5%约12%约3%表格里的数据是实际跑出来的。纯LLM扫描那一行我确实跑了虽然检出率接近88%但耗时和成本非常吓人。大规模应用时用LLM逐个文档扫描会拖垮整个入库管线不现实。5.2 不进上下文到底省了什么单独看“27脚本扫描”那行Token消耗只有约1万对比不扫描直接入库当然不算省但和纯LLM扫描比直接省下了约8000万Token。换算成时间线如果每天入库三批文档纯LLM扫描需要每次花费几小时而脚本方式只需要十几分钟相当于人力几乎不用等。进上下文最大的消耗来自“把扫描结果回灌给模型”这一步。我统计过如果扫描1200份文档时把每份的完整结果都回传给大模型token消耗会超过200万。把结果做成摘要后每批次只给模型几十句话token消耗就落在了万这个级别。这验证了“摘要化”是整套架构的支点。5.3 入库拦截效果哪些问题被真正拦住了1200份测试文档里脚本实际拦截了196份问题文档。按问题类型分布隔离出来的情况如下乱码与编码混杂78份占比约40%敏感信息命中43份占比约22%格式解析异常PDF无文本层、Word损坏41份占比约21%重复文档28份占比约14%其他质量问题6份占比约3%。这个分布和现实中“知识库存着存着就变脏”的节奏基本一致。乱码和编码问题占了大头说明许多企业导入历史数据时都遇到过编码兼容问题敏感信息占比也不低说明文档从各处汇总过来时很少有人会在入库前关心里面写了什么不该有的内容。6. 踩坑实录与排查清单6.1 技能描述写不好模型就会选错工具项目初期我把一个脚本的描述写成“检测文本质量”结果大模型面对“扫描这批次文档看看有没有问题”这种指令时只调用了这一个“检测文本质量”脚本其他26个完全没触发。后来把描述改成了“检测文本中的乱码与异常字符比例适用于内容打开后显示异常的情况返回乱码占比和可疑位置”模型立刻能判断出“这是一种乱码检测能力”不会再滥用了。所以我的经验是技能描述至少要回答三个问题什么时候该用、输入什么、输出什么。描述里带具体触发场景的词比抽象的功能名词可靠得多。6.2 中文编码误判差点把正常合同全标记成乱码第一次跑全量扫描乱码检测脚本把一家分公司导入的500多份合同全数标记为“高危”。排查后发现这批文档是GBK编码而脚本在UTF-8环境下读取文本时出现了大量“锟斤拷”这其实是正常的解码表现文本本身没有任何问题。修正方式是在乱码检测脚本里加了一层编码归一化先用编码识别脚本判断文本真实编码再按正确编码转成UTF-8后再做乱码检测。同时加了一个白名单字符集允许GBK常见的标点符号和生僻字。这样修改后编码正常的GBK文档不再误报真正乱码的文档照样能查出问题。6.3 脚本返回超大JSON调度进程直接崩溃有一批文件内容异常庞大扫描脚本把全文都塞进了details字段单个文件的JSON报告达到了几十MB调度器汇总时内存吃紧进程崩溃了好几次。后来在脚本层加了两道限制details字段最多保留50条命中记录超出部分只在summary里写“另有几百条命中已省略”single_file_report 最大不超过5MB超过则自动把details降级为“仅统计信息”。现在不管遇到多大文件调度器都不会因为单个文件内存暴涨而挂掉。6.4 敏感信息误报的精调策略手机号检测脚本在早期的误报案例里占比最高的场景是金额和序列号。比如“服务周期2.5个月”被解析成“29139”这类数字组合一旦连续13位就误判了。后来在正则基础上加了边界和号段校验误报率明显下降。身份证检测同样要精调。早期只要连续出现18位数字就报命中导致很多文本里的时间戳被误判。修正后改为先验证前6位地区码是否在合法范围内再校验最后一位校验码两者都通过才标记风险。这套规则虽然无法覆盖伪造号码但对于扫描入库文档来说已经足够拦下绝大多数真实身份证信息。6.5 常见问题排查速查表现象可能原因处理建议大模型只调用同一个脚本技能描述写得太泛模型无法区分职责重写描述加入具体触发场景正常中文文档被标乱码编码检测与乱码检测相互独立没有归一化先转码再做乱码判断增加字符白名单扫描批处理跑到一半中断单个文件过大内存或IO占满按批次执行对大文件走降级扫描敏感信息扫描误报率高正则边界未限制没有白名单校验增加边界判断和号段/地区码校验汇总报告太大打不开脚本把全部细节写入details限制细节条数超出部分只统计执行结果与预期不一致某个脚本抛异常调度未捕获每个脚本调用包一层try-except记录错误摘要如果你也要搭类似方案我的建议是先把脚本分类设计好再写调度器。注册表那一步别偷懒描述字段花时间多想想它能直接决定Agent会不会“听你的话”。从小到大先用50份文档验证脚本效果再铺开到几百上千份避免一上来就被海量文件打垮。最后再分享一个小技巧不要把27个脚本一次性全跑。日常入库时优先跑乱码检测、编码识别和敏感信息这三类因为它们是最影响检索质量和安全性的因素。格式探测、去重这些每周做一次深度体检就够了。这样既能保证文档健康又不会让扫描流程变成新的性能瓶颈。