招投标文件结构化:为什么不要全文直抽?先切块再按模块定义输入输出(附GitHub项目地址)

张开发
2026/4/17 3:49:17 15 分钟阅读

分享文章

招投标文件结构化:为什么不要全文直抽?先切块再按模块定义输入输出(附GitHub项目地址)
项目介绍这是一个面向投标/评标场景的结构化抽取工具。支持上传PDF、Word或Excel格式的招标文件自动提取项目基础信息、投标资格、技术与商务要求、评标办法等关键条款并还原目录层级与跨页表格。输出结构化JSON/Excel适用于招标文件智能生成、AI辅助评标及招投标知识库建设。GitHub项目地址https://github.com/intsig-textin/xparse-sample-projects接下来我们主要讨论一件事如果目标是从一份很长的招标文件里稳定产出结构化结果系统应该怎么搭。重点不是场景背景而是中间层怎么定义、任务怎么拆、Prompt 为什么这样写。一、先把目标定义清楚如果只是让大模型“总结一份招标文件”实现并不难难的是把一份上百页的长文档稳定拆成可展示、可复用、可继续治理的结构化结果。这类工具真正要完成的是下面这条链路上传 PDF 招标文件调用 TextIn 把原始文件转成markdown pages按标题把长文档切成多个语义片段把片段路由到基础信息、资格要求、评审办法、投标递交、无效标风险、附件格式 6 个模块每个模块单独调用大模型输出固定 JSON前端直接按模块渲染后续也可以继续导出或复用所以这里的核心不是“抽字段”三个字而是先把长文档抽取问题拆成多个边界明确的小任务。二、架构应该怎么拆如果目标是长文档结构化推荐把链路拆成四层这四层分别解决不同问题解析层把 PDF 变成后续可计算的中间结构编排层把长文档拆成多个上下文更小的任务抽取层让每个模块只输出自己负责的 schema展示层按模块 JSON 渲染而不是再做一次自由解析三、先把解析层的输入输出定义对这里最容易写错。真正调用的不是form-data接口而是 TextIn 的二进制流解析接口POST https://api.textin.com/ai/service/v1/pdf_to_markdown请求头和请求体在代码里是这样组织的headers { x-ti-app-id: TEXTIN_APP_ID, x-ti-secret-code: TEXTIN_SECRET_CODE, Content-Type: application/octet-stream, } params { parse_mode: auto, page_count: 200, dpi: 144, table_flavor: html, apply_document_tree: 1, markdown_details: 1, page_details: 1, apply_merge: 1, paratext_mode: none, } resp await client.post( https://api.textin.com/ai/service/v1/pdf_to_markdown, headersheaders, paramsparams, contentfile_bytes, )这里的关键点只有两个返回值里最重要的是result.markdown可以把它理解成下面这个输入输出契约输入Headers: - x-ti-app-id - x-ti-secret-code - Content-Type: application/octet-stream Query: - parse_modeauto - page_count200 - dpi144 - table_flavorhtml - apply_document_tree1 - markdown_details1 - page_details1 - apply_merge1 - paratext_modenone Body: - PDF 文件的原始字节流输出{ code: 200, result: { markdown: # 第一章 招标公告\n..., pages: [] } }如果你做的是 Web 工具通常会在本地后端再包一层/api/parse方便浏览器上传和鉴权隔离但那只是工程封装不是上游解析接口本身的协议。四、为什么中间层必须是markdownpages这一步决定了后面能不能把长文档拆稳。markdown的价值在于保留标题层级保留段落结构表格可以以 HTML 或 Markdown 形式继续消费便于按标题、按章节做切块pages的价值在于保留分页语义为页面预览、页码回跳、证据定位留接口后续如果要做高亮溯源不需要重新回到 PDF 二进制层也就是说解析完成之后整个系统处理的对象就不再是 PDF而是markdownpages这个统一中间层。五、长文档不要全文直抽先按标题切块招标文件最难的地方不是字段多而是篇幅长、章节多、不同章节关心的问题完全不同。如果直接把全文塞给一个总 Prompt结果很难稳。更合理的做法是先按标题切块。代码里的切块入口就是function parseMarkdownToChunks(md: string): Chunk[] { const lines md.split(\n); const headerMatch line.match(/^#{1,2}\s(.*)/); }这一步做的不是“抽取”而是把文档转成一批更短、更聚焦的 chunk。切完之后再按关键词做模块路由例如const MODULE_KEYWORDS { basic: [招标公告, 项目概况, 联系方式], qualification: [资格, 资质, 财务, 联合体], evaluation: [评标, 评审, 评分, 分值], submission: [投标文件, 递交, 开标, 保证金], invalid_risk: [无效标, 否决, 废标条款], annex: [附件, 格式, 表单, 清单], };这样设计有三个直接收益每次发给模型的上下文更短稳定性更高每个模块只关注自己的问题不互相干扰后续新增模块时只需要新增路由和 Prompt不需要推翻整套架构六、Prompt 不是“让模型抽字段”而是定义模块契约这里最值得学的不是“用了大模型”而是 Prompt 把输入输出边界写得很死。以basic_prompt.txt为例开头先把约束写清楚你是一个“招投标文件基础信息basic抽取器”。 你的任务仅抽取【基础信息 basic】模块并输出严格合法的 JSON。 【核心硬性原则禁止捏造】 1) 你只能从输入的 Markdown 原文中抽取信息。 2) 如果原文没有明确出现某字段该字段 value 必须为 或 null。 6) 【溯源原子性原则——最高优先级】 - 每个 value 必须来自原文中一处连续段落/句子的逐字摘录。接着把输出骨架固定下来{ module_key: basic, module_name: 基础信息, sections: { bidder_agency: { title: 招标人/代理信息, blocks: [] }, project_info: { title: 项目信息, blocks: [] }, key_time_content: { title: 关键时间/内容, blocks: [] }, bid_bond_related: { title: 保证金相关, blocks: [] }, other_info: { title: 其他信息, blocks: [] }, procurement_requirements: { title: 采购要求, blocks: [] } }, missing_fields: [], warnings: [] }为什么要这么写而不是只写一句“请抽取基础信息”原因很实际module_key固定前端才能知道这是哪个模块sections固定页面才能直接按 section 渲染missing_fields固定后续才能做缺失项提示warnings固定后续才能挂冲突说明或风险提醒Prompt 里还进一步把block限定为table / kv / list / text四种。这个设计很关键因为招标文件天然是半结构化文档不同信息的最佳表达形式并不一样。例如联系方式更像table项目编号、预算金额更像kv公告媒介、平台地址更像list开标说明、答疑说明更像text这比把所有字段强行压成同一种平铺结构更稳。七、输入、Prompt、输出必须一一对应要让这套架构可维护至少要把三件事先对齐1. 模块输入是什么输入不是全文而是某个模块命中的 Markdown 片段。前端会把命中的 chunk 重新拼成模块输入moduleData[key].markdown \n\n### ${c.title_path}\n c.content;所以传给qualification模块的并不是整份标书而是“资格要求相关片段”。2. Prompt 定义什么结构以资格要求模块为例Prompt 直接固定了三个 section{ module_key: qualification, sections: { applicant_requirements: { title: 申请人资格要求, blocks: [] }, eligibility_review: { title: 资格性审查, blocks: [] }, compliance_review: { title: 符合性审查, blocks: [] } } }这意味着这个模块的职责只有三件事不会在一次抽取里又去掺杂评标办法或附件格式。3. 前端按什么结构展示前端模块配置也会定义同样的 section key。这样一来页面渲染不需要再猜字段只要按约定好的 key 读取结果即可。也就是说这里不是“先让模型自由返回再想办法接结果”而是先把输入范围、Prompt schema、页面结构统一好再让模型往固定壳子里填内容。八、和传统做法相比差别在哪里如果目标是做工程化工具而不是做一次性演示这套方案和传统方式有两个本质区别。1. 不是 OCR 文本加正则硬提纯文本加正则在字段非常固定时还可以用但招标文件章节名称、段落顺序、表格表达方式都经常变化规则一旦堆多维护成本会非常高。2. 不是全文加一个总 Prompt全文单 Prompt 很适合快速做一个效果展示但它很难同时解决下面几个问题模块边界不清输出结构不稳定某个模块要扩字段时会牵一发动全身很难做稳定展示和后续治理更稳定的方式是先解析成结构化中间层再切块再按模块分别抽取最后按模块 JSON 聚合

更多文章