data-clean:AI 招标数据清洗管线 — ES 检索 + AI 提取 + 可视化报告
从 ES 检索招标公告到 AI 提取公司信息、金额、联系人,再到 MariaDB 入库和 ECharts 报告生成,一条完整的招标数据智能清洗管线搭建记录。
项目背景
data-clean 是 information-collection-s 的配套 AI 数据清洗管线,目标是解决招标公告数据中「非结构化内容 → 结构化公司信息」的自动提取问题。
上游的 information-collection-s 从各个招标网站采集了数万条公告数据,但这些公告的正文是 HTML 格式的非结构化文本,包含大量无关标签、样式代码和冗余信息。需要一条 AI 管线来自动清洗 HTML、提取中标公司、金额、排名、联系人等关键字段,最终写入 MariaDB 供业务系统查询。
技术栈
| 组件 | 版本 | 用途 |
|---|---|---|
| FastAPI | 0.104.1 | API 服务框架 |
| Elasticsearch | 7.10.1 | 公告数据检索 |
| Ollama | qwen2.5:1.5b | 本地 AI 提取模型(降级方案) |
| GLM (GPUStack) | qwen3.5-4b | 主 AI 提取模型 |
| MariaDB | — | 结构化数据存储 |
| BeautifulSoup | 4.12.2 | HTML 内容清洗 |
| ECharts 5 | — | 可视化报告生成 |
管线架构
整个数据清洗管线分 5 步:
ES 检索 → HTML 清洗 → AI 提取 → 数据库入库 → 报告生成
第一步:ES 语义检索
通过 es_service.py 调用 ES 索引 information-collection-dev-s-file,支持:
- 多字段匹配:title(权重3) + content(权重2) + realNoticeTypeName(权重2)
- 模糊搜索:
fuzziness: AUTO自动处理错别字 - 短语匹配:title 字段额外 boost=5 的 match_phrase 精准搜索
- 响应精简:只返回 title、content、budget、releaseTime、realNoticeTypeName、budgetAmount 等关键字段
第二步:HTML 内容清洗
招标公告的 content 字段存储的是完整 HTML 页面,包含大量无用标签。通过 BeautifulSoup + lxml 解析器做清洗:
# 核心逻辑:去除 script、style、nav、footer 等标签
# 提取纯文本内容 + 保留关键表格结构
清洗后的纯文本长度通常只有原始 HTML 的 10%-20%,大幅降低 AI 模型的 token 消耗。
第三步:AI 智能提取(核心)
通过 ollama_service.py 调用 AI 模型从非结构化文本中提取结构化公司信息。支持双模型:
| 模式 | 模型 | 部署方式 | 适用场景 |
|---|---|---|---|
| 主方案 | qwen3.5-4b (GLM) | GPUStack 远程调用 | 精度优先 |
| 降级方案 | qwen2.5:1.5b | 本地 Ollama | 稳定性优先 |
提取的 JSON 格式:
{
"companies": [
{
"company": "龙昊通用航空集团股份有限公司",
"rank": 1,
"amount": 49944800.00,
"is_winner": true,
"contact_person": "张三",
"contact_phone": "13800138000"
}
],
"total_amount": 49944800.00,
"company_count": 1
}
提示词工程要点:
- 针对 1.5b 小模型,提示词采用「示例驱动」策略——提供 5 个完整示例覆盖常见场景
- 金额单位自动换算:
"4994.48万元" → 49944800.00元 - 联系人提取规则:优先提取「联系人」「负责人」「经办人」后的姓名
- 电话号码提取规则:优先提取 11 位手机号,其次取座机号
- 自动降级:GLM 调用失败后自动切换到本地 Ollama
第四步:数据库入库
提取结果写入 MariaDB 的 tender_data 表:
CREATE TABLE IF NOT EXISTS tender_data (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_id VARCHAR(255),
title TEXT NOT NULL,
company_name VARCHAR(500),
amount DECIMAL(20, 2) DEFAULT 0,
is_winner TINYINT(1) DEFAULT 0,
rank INT,
contact_person VARCHAR(255),
contact_phone VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_record_company (record_id, company_name)
);
关键设计:
- 唯一约束:
(record_id, company_name)防止同一公告同公司重复入库 - 完整索引:对 record_id、title、company_name、amount、contact_person 等字段都建立了索引
第五步:可视化报告
每次提取完成后自动生成 HTML 报告,包含:
- ECharts 饼图:中标金额分布
- 公司列表:按中标金额排序,支持点击交互
- 中标详情:展示每个公司的中标项目明细
- 统计摘要:ES 检索总数、AI 处理数、成功率、涉及总金额
踩坑记录
1. 金额单位不一致
AI 模型有时输出万元(如 4994.48 万元对应 4994.48),有时输出元(如 49944800.00 元对应 49944800.00)。需要在后处理中做单位修正:
# 如果金额的小数点前有 5 位及以上,说明是元单位但 AI 误写成万元
if len(str(int(amount))) >= 5:
amount = amount / 10000
2. AI 返回字符串型 null
模型有时输出 "contact_person": "null" 而不是 JSON 的 null。需要在三个地方(Ollama 调用、GLM 调用、Ollama 重试)做转换:
if contact_person in ['null', 'None', '']:
company['contact_person'] = None
3. 数据库唯一约束过严
初始版本唯一约束包含了 rank 字段,但 rank 可能为 NULL(部分公告不排名),导致唯一约束失效。改为只使用 (record_id, company_name) 后解决。
4. 连接池未管理
初始版本的 MariaDB 连接没有上下文管理,异常情况下连接不会正确关闭。修复为 @contextmanager 模式,确保 commit/rollback 前后检查连接状态。
效果数据
以「飞行」关键词为例的一次提取:
| 指标 | 数据 |
|---|---|
| ES 检索总数 | 约 100 条 |
| AI 处理数 | 约 80 条 |
| AI 提取成功率 | ~85% |
| 提取中标公司数 | 20+ 家 |
| 涉及总金额 | 数亿元 |
| 单条处理耗时 | ~3-5 秒(Ollama) |
总结
这个项目的核心价值在于打通了「非结构化公告 → 结构化公司信息」的全流程自动化。
对上游的 information-collection-s 来说,采集的公告数据从「可检索的文档」变成了「可分析的数据库」,新增的联系人和电话字段可以直接用于业务系统的线索管理。对整个招标数据平台来说,data-clean 就是 AI 能力落地的那一层——用相对轻量的小模型 + 精心设计的提示词,替代了人工逐条录入的繁琐工作。
后续优化方向:将手动触发的提取流程升级为 ES 增量监听自动触发,以及用更大的模型(7B+)替换 1.5b 以提高提取准确率。