项目介绍:这是一个面向投标/评标场景的结构化抽取工具。支持上传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 聚合