金融PDF解析与知识库构建:债券术语、表格抽取及混合检索
2026/9/20 7:14:37 网站建设 项目流程

简介:这份《企业通用培训》债券交易员讲义以PDF单文件形式呈现,面向银行间市场新入职交易员、金融机构资金与债券业务人员以及备考本币交易员资格的从业者。全篇按十章展开:从金融同业市场概述、银行间外汇与本币市场的基本功能与风险防范,到市场准入与联网交易流程,再到信用拆借、质押式与买断式回购、债券买卖的定价原理、报价询价与成交流程、资金清算与债券结算,直至债券分销、货币经纪服务、中国货币网信息系统,以及借助F-系统开展市场分析与风险管理。压缩包内仅1个PDF文件,约601KB,正文附有培训教材目录与概念术语附录,便于按章节检索学习。目前已有67人学习。读者可借此理清银行间市场各交易品种的操作路径与风控要点,掌握报价撮合、清算结算与应急交易的处理思路,为通过交易员培训考核和日常资金头寸管理打下基础。

1. 从一份企业通用培训用的《债券交易员讲义.pdf》说起

内部知识库项目最常见的起点,是业务方直接甩过来一个几十页的《债券交易员讲义.pdf》,附一句"让新人能问它"。这份讲义通常是企业通用培训材料:中英文混排、"净价/全价"并列、久期和凸性带公式、后半段全是现金流表和报价示例。真正的难点不在 OCR,而在于把"修正久期"这个词和它旁边那个 4.37 正确绑到一起——版面一乱,数值就会挂到别的术语头上。适合读这篇的人:要把中文金融 PDF 变成可检索、可计算数据的后端与算法工程师,以及需要评估解析质量的数据同学。

2. 用 PyMuPDF 抽取《债券交易员讲义.pdf》的文本层与表格坐标

2.1 先判定讲义是文本型 PDF 还是扫描件

动手写代码之前先花两分钟做体检,路线选错了后面全是无用功。Poppler 工具链的三条命令足够:

pdfinfo 债券交易员讲义.pdf | head -20 # 页数、Producer、是否加密 pdffonts 债券交易员讲义.pdf | head -20 # 有字体列表说明存在文本层 pdftotext -f 1 -l 3 债券交易员讲义.pdf - | head -40 # 抽查前三页文本

pdffonts输出为空、或者pdftotext抽出来是满屏 CID 码和方块,说明这是纯图片页,得走版面分析加 OCR 的路线。而只要字体列表里能看到嵌入子集字体,pdftotext也能抽出成句的中文,就可以直接用坐标抽取,精度和速度都远好于 OCR。

现象判定处理路线
pdffonts 有嵌入字体,pdftotext 出正常中文文本型PyMuPDF 直抽 + pdfplumber 抽表
pdffonts 输出为空扫描件版面检测 + OCR + 表格结构还原
表格边框是矢量线有框表pdfplumber 的 lines 策略
表格只靠单元格底色分隔无框表text 策略 + 列坐标聚类

2.2 不要直接用 get_text(),先拿 block 和 bbox

page.get_text()会按内部阅读顺序把整页拼成一个字符串,遇到双栏排版或者表格,列会被串成一行,"净价 98.765 全价 100.011" 变成 "净价 全价 98.765 100.011"。稳妥做法是走dict模式,拿到块、行、span 三层结构,每一层都带 bbox。

import fitz # PyMuPDF doc = fitz.open("债券交易员讲义.pdf") page = doc[12] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: continue # type=1 是图片块,讲义里的截图示例都在这一类 for line in block["lines"]: for span in line["spans"]: x0, y0, x1, y1 = span["bbox"] print(round(span["size"], 1), span["font"], (round(x0), round(y0)), span["text"][:30])

拿到 bbox 之后,判断"这页是不是双栏"只要看行左边界 x0 的分布:有两个明显峰值的,按x0 < 页宽/2分成左右栏分别排序,再拼成阅读顺序。为 NULL 的 bbox 不用管,PyMuPDF 对文本型 PDF 一定给值。抽表格之前先按y0排序,能让后续"术语-数值"匹配的窗口更准。

2.3 用字号分布切出讲义的标题层级

讲义章节结构不用猜,字号就是最可靠的信号。先统计全文字号分布:

from collections import Counter sizes = Counter() for p in doc: for b in p.get_text("dict")["blocks"]: if b["type"]: continue for l in b["lines"]: for s in l["spans"]: sizes[round(s["size"], 1)] += len(s["text"].strip()) print(sizes.most_common(10)) # 典型输出:[(10.5, 41230), (12.0, 6200), (15.0, 900), (18.0, 260)]

出现次数最多的那个字号就是正文,往上数第二档大概率是节标题,最高档是章标题。企业培训讲义的字号梯度一般就是 18/15/12/10.5 这四档。

2.3.1 中文字体加粗判断的坑

不能只看span["font"]里有没有 Bold。很多中文字体根本没有真正的粗体字形,排版软件用的是描边或者替换字体来模拟加粗,字体名里不带 Bold,视觉上却是粗的。更稳的判定是三个条件投票:字号大于正文 2pt 以上、整行只有这一句话没有标点结尾、上下各有一段空白。三条中命中两条就判为标题,误判率能压到很低。

2.4 表格抽取:pdfplumber 的参数怎么调

债券讲义里最关键的两类表是现金流表和久期凸性对照表,抽错一列,后面算出来的 YTM 全错。pdfplumber 的extract_table默认参数在扫描件和 Word 转出的 PDF 上表现不同,需要按表格线的实际形态调。

import pdfplumber with pdfplumber.open("债券交易员讲义.pdf") as pdf: page = pdf.pages[20] # 有矢量边框的表:走 lines 策略 table = page.extract_table({ "vertical_strategy": "lines", "horizontal_strategy": "lines", "snap_tolerance": 3, "join_tolerance": 3, "intersection_tolerance": 5, "edge_min_length": 10, }) for row in table[:5]: print(row)

参数含义和调整方向如下,调参时一次只动一个,否则无法归因。

参数默认值讲义场景下的调法
vertical_strategylines有框表用 lines;纯靠空格对齐的现金流表改 text
horizontal_strategylines同上,两张策略要保持一致
snap_tolerance3表格线轻微错位、行被拆成两行时调到 4~6
join_tolerance3虚线或点线边框容易断开,调到 6
intersection_tolerance5相邻单元格文字粘连时调到 8 再试
edge_min_length3页眉短横线被误判成边框时调到 15

调完还是抽不出来,用page.to_image(resolution=200).debug_tablefinder()导出带检测框的图,肉眼确认是横线没识别到还是竖线没识别到,比反复改参数快得多。此外记得开extract_table({"text_...})之外的兜底:对无框表改用extract_words()拿到每个词的 x0,按 x0 聚类成列,再按 y0 分行。

3. 债券术语与数值绑定:把久期、基点、净价全价抽成结构化字段

3.1 同一个数字在讲义里有多种含义

讲义里数字的语义密度极高。同样是 4.37,可能是修正久期,也可能是全价的一个分位;同样是 3.25,可能是票面利率,也可能是 3.25 个基点。脱离术语谈数字毫无意义,所以抽取的最小单位不是数字,而是"术语-数值-单位"三元组。这一步做扎实了,后面无论做检索还是做计算都是顺的。

3.2 用正则把术语和数值绑成三元组

术语表要按长度倒序匹配,否则"修正久期"会被"久期"抢先截断。

import re TERMS = { "修正久期": "modified_duration", "麦考利久期": "macaulay_duration", "久期": "duration", "凸性": "convexity", "到期收益率": "ytm", "应计利息": "accrued_interest", "净价": "clean_price", "全价": "dirty_price", "票面利率": "coupon_rate", } UNIT = r"(年|%|bp|元|个基点)" PAT = re.compile( r"(?P<term>" + "|".join(sorted(TERMS, key=len, reverse=True)) + r")" r"[::\s]*" r"(?P<value>\d+(?:\.\d+)?)" r"\s*" + UNIT + r"?" ) def extract(text): out = [] for m in PAT.finditer(text): out.append({ "field": TERMS[m.group("term")], "raw_term": m.group("term"), "value": float(m.group("value")), "unit": m.group("unit"), "span": m.span(), # 保留偏移,用于回原文定位和人工复核 }) return out

sorted(TERMS, key=len, reverse=True)是这段代码的关键,它保证长术语优先。span字段别省,抽错了能一眼看出是哪一段的哪几个字符。单位匹配用?表示可选,因为讲义里"到期收益率 3.4"经常省略百分号,这种情况要靠同页是否存在%号来补默认单位。

3.3 归一化:把 bp、百分比、元统一到可计算单位

抽出来的原始值必须归一,否则 3.25% 和 45bp 混在一起没法比较。

字段讲义原值归一化后注意
coupon_rate3.25%0.0325百分比一律转小数
ytm3.40.034缺 % 时按上下文补,别默认成 3.4
duration4.37 年4.37统一以年为单位
convexity21.621.6无量纲,不做换算
accrued_interest1.23 元/百元1.23面值基准固定为 100
spread45bp0.0045bp 除以 10000

3.4 讲义公式还原成可计算代码

PDF 里抽出来的公式往往是乱序字符,"D = Σ t·PV(CFt) / P" 可能变成 "D t PV CFt P Σ = /"。可行做法是先用 bbox 把公式区域圈出来(含希腊字母、上下标字符密集、x 间距异常),标成 formula 块单独处理,正文里只留公式编号和一句自然语言描述。公式本身用代码实现一遍,作为校验基准。

def macaulay_duration(cashflows, ytm, freq=2): """cashflows: [(期数, 现金流)],讲义第 2 章的公式直接映射到这段""" pv = [(t, cf / (1 + ytm / freq) ** t) for t, cf in cashflows] price = sum(v for _, v in pv) return sum(t / freq * v for t, v in pv) / price def modified_duration(mac_d, ytm, freq=2): """麦考利久期转修正久期,freq 为每年付息次数""" return mac_d / (1 + ytm / freq)

freq=2对应半年付息,这是国内债券讲义里最常见的默认值;ytm传入的是小数形式,所以 3.4% 要传 0.034 而不是 3.4。这两个参数写错,久期能差出 1% 以上,做校验时会一直报警。

4. 把《债券交易员讲义.pdf》切片进知识库:分块、向量化与混合检索

4.1 按标题层级分块,别按 500 字硬切

固定字数切分会把"久期"的定义和它的公式切成两块,检索到前半句答不出问题。用第 2 章抽出来的标题树做递归切分,每个块带上所属章节路径。

def build_chunks(blocks, max_chars=900, overlap=120): """blocks: 阅读顺序排好的 [{"level": 1|2|3|None, "text": str, "page": int}]""" chunks, h1, h2, buf, start_page = [], "", "", [], None def flush(): nonlocal buf, start_page if not buf: return text = "\n".join(buf) if len(text) <= max_chars: chunks.append({"h1": h1, "h2": h2, "text": text, "page": start_page}) else: cur = "" for p in text.split("\n"): if cur and len(cur) + len(p) > max_chars: chunks.append({"h1": h1, "h2": h2, "text": cur, "page": start_page}) cur = cur[-overlap:] + "\n" + p # 保留尾部重叠,避免切断定义 else: cur = (cur + "\n" + p) if cur else p if cur: chunks.append({"h1": h1, "h2": h2, "text": cur, "page": start_page}) buf, start_page = [], None for b in blocks: if b["level"] == 1: flush(); h1, h2 = b["text"], "" elif b["level"] == 2: flush(); h2 = b["text"] else: if start_page is None: start_page = b["page"] buf.append(b["text"]) flush() return chunks

入向量库时不要只送text,把标题路径拼在前面:f"{h1} > {h2}\n{text}"。这样"久期与凸性"这个节下的任何块都自带领域上下文,短查询的召回明显变好。

参数建议值理由
max_chars800~1000讲义单节正文通常 300~800 字,一块对应一节最贴
overlap100~150公式定义和结算规则常跨段
标题前缀h1 > h2提升"基点""净价"这类短词召回
表格块单独成块,每行补表头表转文本后失去列名,检索时对不上

4.2 混合检索:BM25 救短词,向量救同义

金融术语里有一批极短但极关键的词,比如"bp""净价""T+1",向量模型经常把它们糊成一团,BM25 的精确匹配反而稳。两路各取前 20,再用 RRF 融合。

import jieba from rank_bm25 import BM25Okapi # 自定义词典必须加,否则"修正久期"会被切成"修正/久期" for w in ["修正久期", "麦考利久期", "中债估值", "净价", "全价", "基点", "到期收益率"]: jieba.add_word(w) corpus = [list(jieba.cut(c["text"])) for c in chunks] bm25 = BM25Okapi(corpus) def bm25_search(q, k=20): scores = bm25.get_scores(list(jieba.cut(q))) idx = sorted(range(len(scores)), key=lambda i: -scores[i])[:k] return [i for i in idx if scores[i] > 0] def rrf(rank_lists, k=60): """rank_lists: 每路检索返回的 doc_id 有序列表""" score = {} for rl in rank_lists: for rank, doc_id in enumerate(rl): score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(score.items(), key=lambda x: -x[1])

k=60是 RRF 里常用的平滑常数,取值越大,靠后名次的权重衰减越慢,长尾文档更容易被捞回来;规程类问题建议保留 40~80 之间。BM25 前的分词质量决定上限,讲义里"净价"和"全价"只差一个字,词典不加,两者检索结果会互相污染。

4.3 用讲义自带的课后题造评测集

企业培训讲义通常每章末尾配了思考题,这是现成的高质量评测集,比人工编 query 靠谱。

eval_set = [ {"q": "修正久期和麦考利久期的换算关系", "gold_h2": "久期与凸性"}, {"q": "净价怎么换算成全价", "gold_h2": "债券报价与结算"}, {"q": "半年付息的债券怎么算到期收益率", "gold_h2": "收益率计算"}, ] def recall_at_k(ranks, eval_set, k=5): hit = 0 for item, rl in zip(eval_set, ranks): top = [chunks[i]["h2"] for i in rl[:k]] hit += int(item["gold_h2"] in top) return hit / len(eval_set)
指标计算方式合格线
Recall@5前 5 条命中目标节的比例≥ 0.85
MRR第一条正确答案名次倒数的均值≥ 0.7
页码命中率返回块页码与讲义原页一致≥ 0.95,用于定位引用

页码命中率低通常不是检索的问题,而是第 2 章分块时start_page记漏了,先回去查分块层。

5. 用讲义里的现金流表反推 YTM,校验解析结果

解析对不对,肉眼翻页看是查不完的,用讲义自己的数字做闭环校验最快。抽出现金流表和旁边印着的全价,反解 YTM,再跟讲义正文写的到期收益率比,误差落到 1bp 以内说明这一页抽对了。

def solve_ytm(cashflows, price, freq=2, lo=-0.5, hi=1.0, tol=1e-10): """cashflows: [(期数, 现金流)];price 为全价(按每百元面值)""" def pv(y): return sum(cf / (1 + y / freq) ** t for t, cf in cashflows) for _ in range(200): mid = (lo + hi) / 2 if pv(mid) > price: # 价格随收益率单调递减,据此缩区间 lo = mid else: hi = mid if hi - lo < tol: break return (lo + hi) / 2

二分法的搜索区间取[-0.5, 1.0]足够覆盖负利率到极端高息的所有情形,迭代 200 次远超实际收敛所需,配合tol提前退出即可。注意price必须是全价,传净价进去算出来的收益率会系统性偏高,恰好等于应计利息带来的偏差。

症状大概率原因定位手段
算出的 YTM 偏高几十个 bp把净价当全价用了检查该页是否漏抽应计利息
现金流期数总是奇数付息频率取成 1翻讲义页脚的付息频率说明
第 1 期现金流异常大含首期应计利息补偿比对起息日与结算日
某行少一列数据表格抽列丢失回到 2.4 用 debug_tablefinder
久期校验差 1% 左右修正久期和麦考利久期混用看公式分母是否除了 (1+y/f)

把 1bp 写成断言塞进 CI,每次讲义改版重新跑一遍解析流水线,哪一页的结构变了立刻报出来,比人工抽查省事得多。

assert abs(solve_ytm(cfs, dirty_price) - ytm_from_pdf) < 1e-4, "ytm 偏差超过 1bp,检查该页表格抽取"

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询