
简介面向公司项目经理、高级程序员与产品专员的票据业务培训教材以单个doc文档交付压缩包约1.54MB。内容脱离具体软件系统从纯业务视角梳理商业汇票基础知识讲解银票与商票的分类差异以及票号、出票日、出票人、付款行、收款人、票面金额、汇票到期日、承兑协议号等票面要素。文中还串联出票、承兑、背书、托收、兑付的完整流转过程并针对票据管理系统开发提示票面信息录入、期限合理性校验、票据流转与兑付状态处理以及查询、跟踪和风险管理等设计要点。读者可借助这份教材快速建立票据业务概念框架理解票面要素在系统建模、字段校验和流程状态设计中的对应关系从而在需求评审与客户交流中更准确地表达业务规则。已有84人学习适合需要补足票据业务知识、减少需求理解偏差、提升客户沟通与开发效率的技术和产品人员参考。1. 一份 doc 培训教材直接喂给检索系统会翻车在哪新入职的票据岗同事拿着《票据业务培训教材.doc》问电子商业汇票的提示付款期到底是几天。有人把这本教材整份丢进向量库检索回来的却是「商业承兑汇票贴现准入条件」命中的段落还是附表里的利率表。问题不在模型在这本 .doc 的信息形态票据要素散落在正文段落时限规则藏在跨页表格票据号码和金额有大小写两种写法按固定字数硬切之后「贴现」和「转贴现」、「提示付款」和「提示承兑」被切进两个互不相邻的块。这份教材真正要变成的东西是一套可解析、可抽取、可检索、可增量的知识资产老 .doc 先无损转成 OOXML正文和表格一起抽出来再用规则把票号、金额、期限这类票据要素结构化最后按标题层级切块入检索库。下面按「格式解析 → 要素抽取 → 切块检索 → 增量维护」四段推进中间每一步都给能直接跑的命令和参数。2. 解析 .doc/.docx 票据培训教材格式判定与正文抽取的最小命令解析这类教材的第一刀不是写代码是判格式。教材在内部流转几轮之后常见的是「后缀写 .doc、实际是 docx」这种混装情况直接丢给 python-docx 会抛PackageNotFoundError直接丢给反病毒式的文本提取器又会把整本表格打成一串空格分隔的字。2.1 用魔数判定 OLE2 老 doc 还是 OOXML docx后缀不可信看前 8 个字节。老版 Word 是 OLE2 复合文档头是D0 CF 11 E0 A1 B1 1A E12007 之后的 OOXML 本质是 zip头是50 4B 03 04即PK。批量处理前先做一次分流后面才能选对工具链。# 批量判定目录下所有教材文件的真实格式 for f in ./教材/*.doc ./教材/*.docx; do [ -e $f ] || continue # 读前 8 字节并转成十六进制 magic$(head -c 8 $f | xxd -p | tr a-z A-Z) case $magic in D0CF11E0A1B11AE1) echo OLE2 老doc : $f ;; 504B0304*) echo OOXML docx: $f ;; *) echo 未知格式 : $f (magic$magic) ;; esac donehead -c 8只读头部几百兆的教材也是毫秒级xxd -p输出连续十六进制便于模式匹配。分流结果里标为「未知格式」的文件要单独看实际遇到过的有 RTF 改名、WPS 私有格式、以及被邮件网关截断的半截文件这些都不该进主流程否则提取出的正文会缺章节。真实格式头部魔数推荐提取路径主要取舍OLE2 老 docD0CF11E0A1B11AE1LibreOffice 转 docx保结构好需装套件OOXML docx504B0304python-docx 直读快文本框会漏RTF7B5C72746631pandoc 转 docx样式会简化截断/损坏其他退回原始文件人工确认不进自动流程2.2 用 LibreOffice 无头模式把老 doc 转成 docx老 .doc 不要自己写 OLE2 解析器用本机已装的 LibreOffice 做无头转换最稳它保留标题层级、表格和大部分编号。# 无头模式批量转换输出到 ./conv 目录 soffice --headless --norestore \ --convert-to docx:MS Word 2007 XML \ --outdir ./conv ./教材/票据业务培训教材.doc # 转换完成后校验转出来的 docx 是否真的可读 python -c from docx import Document d Document(./conv/票据业务培训教材.docx) print(段落数:, len(d.paragraphs), 表格数:, len(d.tables)) --headless表示不开界面适合放进定时任务--norestore避免上次异常退出留下的恢复对话框卡住进程--convert-to docx:MS Word 2007 XML显式指定输出为 OOXML而不是默认可能触发的其他过滤链。转换后的段落数和表格数要和原始文件肉眼比对一遍教材里作为票据要素载体的表格数量通常是十几张到几十张若只报出 1 到 2 张多半是表格被转成了图片或文本框这时要回头查原文是不是用了浮动表格。提示LibreOffice 同一时刻只允许一个实例持有用户配置目录并发转换要加-env:UserInstallationfile:///tmp/lo_$RANDOM否则第二批文件会静默失败。2.3 python-docx 抽取正文、标题层级与表格python-docx 默认只给doc.paragraphs会把表格内容整体跳过而票据教材里最关键的就是表格。要按文档流顺序取块得遍历 body 的子元素。from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph # 样式名到标题级别的映射中文版 Word 常见「标题 1」这种写法 HEADING_STYLE { Heading 1: 1, Heading 2: 2, Heading 3: 3, 标题 1: 1, 标题 2: 2, 标题 3: 3, } def iter_blocks(doc): 按文档流顺序产出段落或表格保证顺序不乱 for child in doc.element.body.iterchildren(): if child.tag.endswith(}p): yield Paragraph(child, doc) elif child.tag.endswith(}tbl): yield Table(child, doc) def table_to_markdown(tbl): 把表格转成 markdown保留行列结构给切块用 rows [] for r in tbl.rows: cells [c.text.strip().replace(\n, ) for c in r.cells] rows.append(| | .join(cells) |) if not rows: return # 表头分隔行列数取第一行 sep | | .join([---] * len(tbl.rows[0].cells)) | return \n.join([rows[0], sep] rows[1:]) doc Document(./conv/票据业务培训教材.docx) level, buf 0, [] for block in iter_blocks(doc): if isinstance(block, Paragraph): style block.style.name if block.style else text block.text.strip() if style in HEADING_STYLE and text: level HEADING_STYLE[style] # 记住当前章节层级 buf.append(f\n{# * (level 1)} {text}) elif text: buf.append(text) else: md table_to_markdown(block) if md: buf.append(md) # 表格紧跟它所属的章节标题 print(\n.join(buf))iter_blocks直接遍历body.iterchildren()遇到w:p走段落、遇到w:tbl走表格这样抽出来的顺序和 Word 里看到的一致章节标题和它下面的表格不会被拆散。table_to_markdown把每行每个单元格的文本拼成 markdown 行换行符替换成空格避免一个单元格里的多行说明污染表格结构。抽出来的 markdown 中间产物建议落盘保存后面做切块和抽取都基于它重跑时不用再动原文件。2.4 抽取时最容易丢的三类内容实际抽取下来丢内容集中在三处。文本框和形状里的文字不在 body 直接子元素里得额外遍历w:txbxContent节点页眉页脚里有教材版本号和修订日期这些是增量更新的判断依据值得单独抽一份元数据自动编号列表在 OOXML 里编号是渲染出来的paragraph.text拿不到序号若教材用编号列票据业务流程步骤需要读取w:numPr自己补序号。合并单元格也容易错位row.cells对横向合并会重复返回同一个单元格对象纵向合并会返回None。稳妥做法是先按tc元素的gridSpan和vMerge属性还原真实网格再去重。教材里的票据期限对照表经常有多层表头不还原网格的话抽取出的「期限」和「利率」会串行后续规则匹配全乱。3. 票据要素抽取从教材段落里拎出票号、金额与期限正文和表格都变成 markdown 之后下一步是把它从「一堆字」变成「有字段的记录」。票据教材里的知识大致分五类抽取方式完全不同混着做效率很低。知识类型教材里的典型形态抽取方式概念定义「贴现是指……」整段段落级按标题归属要素规则票号位数、金额大写规范正则 校验函数时限规则「自到期日起 10 日内」正则 上下文窗口业务流程编号步骤列表列表结构还原计算公式贴现利息、实付金额表达式解析 变量绑定3.1 票据号码、金额、日期用正则先扫一遍票据要素的书写相当规范化先用正则把它们全量捞出来形成候选集合再谈语义。import re from datetime import date PATTERNS { # 电子商业汇票票据号码常见为定长数字串 bill_no: re.compile(r\b\d{30}\b|\b\d{16}\b|\b\d{8}\b), # 金额大写只在含「元角分整」上下文时才算金额 amount_cn: re.compile(r[零壹贰叁肆伍陆柒捌玖拾佰仟万亿元角分整]{2,}), # 阿拉伯数字金额带千分位或小数点 amount_num:re.compile(r[¥]?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?\s?元), # 中文日期与短横线日期 date_cn: re.compile(r(\d{4})\s?年\s?(\d{1,2})\s?月\s?(\d{1,2})\s?日), date_iso: re.compile(r(\d{4})-(\d{2})-(\d{2})), } def scan(text): hits {} for name, pat in PATTERNS.items(): hits[name] pat.findall(text) return hits def check_bill_no(no: str) - bool: 票据号码的基本合理性校验非全零、位数在允许集合内 if set(no) {0}: return False return len(no) in (8, 16, 30) sample 票据号码 123456789012345678901234567890出票金额人民币壹佰万元整到期日 2025年3月18日。 hits scan(sample) print({k: v for k, v in hits.items() if v})bill_no用了三个备选长度因为教材里同时出现新版电子票据号码和历史纸质票据号码不区分长度会漏。amount_cn的中文字符类必须限定为金额用字否则「一、二、三」这类编号会被误判成金额后面还要加一层校验只有出现在「金额」「票面」「合计」这些词附近才算真金额。check_bill_no排掉全零串这类值在教材示例里出现频率很高是占位符而不是真实票号。3.2 时限规则要带上下文窗口不能只抽数字「10 日」「3 个月」这类数字本身没有意义必须把前后文一起带走。做法是在正则命中位置左右各取一段窗口再把窗口文本作为一条规则存下来。LIMIT_PAT re.compile( r(自|从)?(?Panchor到期日|出票日|承兑日|提示付款日|拒付日) r(起)?(?Pnum\d{1,4})\s?(?Punit日|天|个月|月|年) r(?:内|以内|之内)? ) def extract_limits(text, win30): rules [] for m in LIMIT_PAT.finditer(text): # 取命中区间左右各 win 个字符保留判断依据 ctx text[max(0, m.start() - win): m.end() win] rules.append({ anchor: m.group(anchor), # 起算点 value: int(m.group(num)), # 数值 unit: m.group(unit), # 单位 context: ctx, # 原文上下文便于人工复核 }) return rulesanchor是这条时限规则的起算点票据业务里同一个「10 日」挂在「到期日」和挂在「拒付日」后面含义完全不同必须一起抽出来。context字段是给人工复核用的抽出来的规则进知识库前最好抽样看一遍尤其是教材里有「特殊情况除外」「另有约定的除外」这类补充说明时只留数值会丢关键前提。注意单位「个月」和「月」要放在同一条分支里但用不同分组值区分若合并成同一个单位后续换算成天数时 3 个月和 3 天会被算成一样。3.3 规则兜不住的部分交给模型输出必须卡 JSON Schema规则能覆盖八成剩下的「贴现利率按票面金额与剩余期限加权计算」这类描述性内容靠正则抽不出结构化字段。常见做法是给模型一段章节文本要求按固定 schema 输出并用jsonschema校验。import json, jsonschema SCHEMA { type: object, properties: { clause_type: {enum: [定义, 时限, 公式, 流程, 风险点]}, keywords: {type: array, items: {type: string}, maxItems: 8}, formula: {type: [string, null]}, confidence: {type: number, minimum: 0, maximum: 1}, }, required: [clause_type, keywords, confidence], additionalProperties: False, } def validate(payload: str) - dict: 校验模型输出是否符合 schema不合格的直接丢弃重试 data json.loads(payload) jsonschema.validate(instancedata, schemaSCHEMA) return dataclause_type用枚举而不是自由文本是为了后续按类型分流定义类进概念库时限类进规则库公式类进计算模块。maxItems限制关键词数量防止模型把整段话拆成几十个词导致检索时关键词权重被稀释。additionalProperties: False很关键模型喜欢多加字段一旦有额外字段混进下游的入库逻辑字段名对不上就会静默丢数据。4. 从教材切块到问答chunk 参数、混合检索与票据术语词典抽取完要素教材还得回到「可被检索」的形态。切块和检索参数直接决定问答质量这部分调参的收益比换更大的模型明显得多。4.1 按标题层级切不要按字数硬切教材的天然边界就是标题。以第 2 章抽出的 markdown 为输入按#到####的层级切块内保留完整路径作为面包屑。import re def chunk_by_heading(md: str, max_len: int 1200, overlap: int 120): lines, chunks, path md.split(\n), [], [] cur [] for line in lines: m re.match(r^(#{1,4})\s(.*)$, line) if m: # 遇到新标题先把上一块收尾 if cur: chunks.append({path: .join(path), text: \n.join(cur)}) cur [] lvl len(m.group(1)) path path[:lvl - 1] [m.group(2).strip()] cur.append(line) # 单块超长才做二次切分并保留重叠 if len(\n.join(cur)) max_len: chunks.append({path: .join(path), text: \n.join(cur)}) tail \n.join(cur)[-overlap:] cur [tail] if cur: chunks.append({path: .join(path), text: \n.join(cur)}) return chunksmax_len按字符数控制中文教材 1200 字对应大约两三段正文加一张小表正好能装下一条完整规则。overlap只在超长块二次切分时生效用来兜住被切断的句子。path记录的是「第二章 票据要素 2.3 承兑与保证」这样的层级路径检索命中后连同路径一起返回用户能直接定位到教材位置比只给一段话可信得多。4.2 chunk 大小与重叠的取值参考参数建议取值放宽的后果收紧的后果max_len800 到 1200 字一个块混多个知识点命中精度降规则被切半答案不完整overlap100 到 150 字冗余块变多检索重复率高断句处信息丢失最小块长80 字大量标题碎片进库噪声大短定义被误删表格是否独立成块小于 15 行独立否则随章节大表撑爆上下文大表被切散行列错位表格的处理要单独说小于 15 行的表跟所属章节走超过的独立成块并在块里重复一次表头否则跨块检索到的表格片段看不出列含义。4.3 BM25 加向量混合检索票据术语要进分词词典票据领域有一批分词器不认识的词「转贴现」「再贴现」「票据池」「提示付款」「追索权」「承兑行」。不干预分词这些词会被切碎BM25 的召回直接塌掉。import jieba from rank_bm25 import BM25Okapi # 自定义词典票据业务高频术语词频给高一点 TERMS [转贴现, 再贴现, 票据池, 提示付款, 提示承兑, 追索权, 承兑行, 贴现利率, 实付金额, 票据到期日] for t in TERMS: jieba.add_word(t, freq10000) def tokenize(text: str): return [w for w in jieba.lcut(text) if w.strip() and w not in 。、] chunks chunk_by_heading(md) corpus [tokenize(c[text]) for c in chunks] bm25 BM25Okapi(corpus) def rrf_fuse(bm25_rank, vec_rank, k60): 倒数排名融合两路各取前 N 再合并 score {} for rank_list in (bm25_rank, vec_rank): for i, doc_id in enumerate(rank_list): score[doc_id] score.get(doc_id, 0) 1.0 / (k i 1) return sorted(score.items(), keylambda x: -x[1])jieba.add_word的freq给到 10000是为了让这些术语在分词时不再被切碎同时不会影响其他词的切分。rrf_fuse用的是倒数排名融合只依赖两路检索的名次而不依赖分数绝对值省掉了 BM25 分数和向量相似度量纲不一致的归一化麻烦。k取 60 是常见起点想让头部结果更集中就调小想让长尾多进来就调大。4.4 检索之后加一层重排并强制答案溯源初排取前 20用交叉编码器重排取前 5再交给生成环节。重排模型不必太大中文小模型就够重点是把「提示付款」和「提示承兑」这类近邻词区分开。def build_prompt(question, top_chunks): ctx \n\n.join( f[{i1}] 出处{c[path]}\n{c[text][:800]} for i, c in enumerate(top_chunks) ) return ( 只依据下面的培训教材片段回答禁止补充片段外内容。\n 每个结论后必须标注出处编号如 [1]。找不到依据就回答「教材未覆盖」。\n\n f{ctx}\n\n问题{question} )出处{路径}让模型在答案里能引用到章节用户点开就能核对原文。找不到依据就回答「教材未覆盖」这句必须有票据业务问答里编造期限的代价很高宁可让它说不知道。text[:800]是对单块做截断防止某一块过大挤掉其他块的位置。5. 教材改版后的增量索引与抽取质量校验教材每年修订全量重建索引既慢又容易把已经人工校订过的字段覆盖掉。可行的做法是给每个块算指纹只重建变化的部分。import hashlib, json def fingerprint(path: str, text: str) - str: 路径 正文一起做哈希任一部分变化都会导致指纹变化 h hashlib.sha256() h.update(path.encode(utf-8)) h.update(b\x00) h.update(text.encode(utf-8)) return h.hexdigest()[:16] def diff_chunks(old: dict, new: list): added, changed, removed [], [], [] new_map {c[path] | c[text][:40]: c for c in new} for key, c in new_map.items(): fp fingerprint(c[path], c[text]) if key not in old: added.append((fp, c)) elif old[key][fp] ! fp: changed.append((fp, c)) for key in old: if key not in new_map: removed.append(key) return added, changed, removed指纹把path和正文一起算标题改了但正文没改也算变化因为面包屑变了会影响检索展示。用path 正文前 40 字做键是为了在正文大改时仍能定位到「同一章节的同一块」避免整章被当成全新增量。真正上线时changed里的块要额外走一遍要素抽取因为教材改版通常正是为了改期限或改利率这类改动必须同步到规则库。校验抽取质量用一个小的金标集就够从教材里挑 30 到 50 条票据要素人工标注正确答案每次改版后跑一遍。校验项计算方式合格线不达标先查票号召回率抽出的真实票号 / 标注票号0.95号码位数分支是否漏金额准确率大小写金额一致的对数 / 抽样对数0.98上下文窗口是否太窄时限准确率起算点与数值都对 / 标注条数0.90单位分支分组是否合并表格完整率抽取行数 / 原文行数0.99合并单元格网格还原把每轮的校验结果和最新的指纹表一起落盘存成 JSON下次改版时先比对指纹、再跑校验两个都过了才切换索引。这样一本票据业务培训教材从 .doc 到可问答改版成本能压到只重建变化的那几块。本文还有配套的精品资源点击获取