招投标文件结构化:为什么不要全文直抽?先切块再按模块定义输入输出(附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",
headers=headers,
params=params,
content=file_bytes,
)
这里的关键点只有两个:
- 返回值里最重要的是
result.markdown
可以把它理解成下面这个输入输出契约:
输入:
Headers:
- x-ti-app-id
- x-ti-secret-code
- Content-Type: application/octet-stream
Query:
- 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
Body:
- PDF 文件的原始字节流
输出:
{
"code": 200,
"result": {
"markdown": "# 第一章 招标公告\n...",
"pages": []
}
}
如果你做的是 Web 工具,通常会在本地后端再包一层 /api/parse 方便浏览器上传和鉴权隔离;但那只是工程封装,不是上游解析接口本身的协议。
四、为什么中间层必须是`markdown+pages`
这一步决定了后面能不能把长文档拆稳。
markdown 的价值在于:
- 保留标题层级
- 保留段落结构
- 表格可以以 HTML 或 Markdown 形式继续消费
- 便于按标题、按章节做切块
pages 的价值在于:
- 保留分页语义
- 为页面预览、页码回跳、证据定位留接口
- 后续如果要做高亮溯源,不需要重新回到 PDF 二进制层
也就是说,解析完成之后,整个系统处理的对象就不再是 PDF,而是 markdown+pages 这个统一中间层。
五、长文档不要全文直抽,先按标题切块
招标文件最难的地方不是字段多,而是篇幅长、章节多、不同章节关心的问题完全不同。如果直接把全文塞给一个总 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 聚合
浙公网安备 33010602011771号