☰
证券知识库构建实战:文档结构化解析与检索优化
2026/10/5 3:16:45 网站建设 项目流程

简介:这是一份面向金融科技从业者、大模型应用开发者与知识库产品设计者的证券知识库构建与应用演示文稿,聚焦大模型与知识库结合的落地路径。内容围绕文档结构化解析、知识库构建与问答、应用案例及思考展开,涵盖跨页段落合并、无框线表格还原、跨页表格合并、单元格合并、表格内图片还原、多栏阅读顺序分割、扫描件签章覆盖文字识别及目录页眉页脚处理等版式元素识别难点,并涉及用户意图识别、文本切片策略、向量化Embedding与监督/弱监督微调等关键环节,还给出召回率提升的实测数据。资源包内含1个pptx文件,大小约14.69MB,以演示文稿形式系统梳理从布局检测、OCR识别到检索表存储的完整流程。目前已有52人学习,适合希望理解证券领域知识库架构、检索优化与智能问答实现思路的读者参考。

1. 证券知识库构建实战:从 400 万篇公告里把表格和段落捞干净

去年帮一家券商做智能投研助手,最头疼的不是模型选型,而是把一份 200 页的港股招股书喂进去之后,问答系统把「归属于母公司股东的净利润」和「少数股东损益」串成了同一行数据。翻回去看解析结果,跨页表格在 PDF 里被切成了两半,合并单元格的坐标全乱了。这件事让我意识到,证券知识库构建的核心瓶颈从来不在大模型本身,而在文档结构化解析这一层。深交所信息公司毛瑞彬在《证券知识库构建和应用》这份 PPT 里把整个链路拆成了背景、文档结构化解析、知识库构建及问答、应用案例、思考五个部分,覆盖了上市公司公告、债券、基金、法规、舆情、研报等数据源,每年公告量超过 400 万篇。这套东西适合正在做金融 RAG 落地、被 PDF 解析折磨过的工程师,也适合想了解证券行业知识库全貌的技术负责人。下面我按自己复现过的路径,把这份材料里的关键环节拆开讲。

2. 文档结构化解析:布局检测、阅读顺序与无框线表格还原

证券文档的解析难度远超普通 PDF。一份年报里可能同时出现多栏排版、无框线表格、跨页表格、签章覆盖文字、页眉页脚、公式角标。PPT 里把文档结构化解析拆成了布局检测、阅读顺序分割、无框线表格还原、跨页表格和单元格合并、目录识别、OCR 识别几个模块。这一章重点讲前三个最影响后续检索质量的环节。

2.1 布局检测:CSP 骨干网络加注意力机制的选型逻辑

布局检测的目标是给每一页文档打上区域标签——正文、标题、表格、图片、页眉页脚。PPT 里给出的方案是:输入图片后经过 Focus 层做切片,再进入 CONV 卷积层提取特征,NECK 部分用 CSP 结构做多尺度特征融合,SPPF 做空间金字塔池化,最后 Detection 模块输出预测框。这套结构本质上是在 YOLO 系列检测器上做了针对文档版式的适配,额外引入了自注意力机制来处理长距离依赖——因为文档里标题和正文的区分往往依赖上下文位置关系,纯卷积的感受野不够。

我实际复现时用的是 PaddleOCR 的 PP-Structure 作为 baseline,但 PPT 里这套自研方案在研报和港股 PPT 版式上的表现更稳。关键差异在于训练数据的标注粒度:证券文档的布局标注不能只标「表格」和「正文」,还要细分「无框线表格」「跨页表格起始页」「跨页表格续页」,否则后续合并逻辑没有依据。

# 布局检测推理伪代码,基于 PPT 描述的 Detection 模块 import cv2 import numpy as np def layout_detect(image_path, model, conf_thres=0.45, iou_thres=0.5): """ image_path: 单页文档图片路径 model: 加载好的布局检测模型 conf_thres: 置信度阈值,证券文档建议 0.4-0.5,太低会误检页眉为正文 iou_thres: NMS 阈值,表格密集页面建议 0.5,太高会漏检相邻表格 """ img = cv2.imread(image_path) h, w = img.shape[:2] # 预处理:PPT 里 Focus 层做切片,实际推理时直接 resize 到 1024x1024 blob = cv2.resize(img, (1024, 1024)) blob = blob.transpose(2, 0, 1)[np.newaxis, ...].astype(np.float32) / 255.0 # 前向推理,输出 [x1,y1,x2,y2,conf,cls] outputs = model(blob) # NMS 后处理 keep = nms(outputs, iou_thres) results = [] for det in keep: if det[4] < conf_thres: continue # 坐标映射回原图尺寸 x1 = int(det[0] * w / 1024) y1 = int(det[1] * h / 1024) x2 = int(det[2] * w / 1024) y2 = int(det[3] * h / 1024) results.append({ "bbox": [x1, y1, x2, y2], "label": CLASSES[int(det[5])], # 正文/标题/表格/图片/页眉页脚 "score": float(det[4]) }) return results

这段代码里conf_thres和iou_thres是两个需要按文档类型调的参数。公告类文档版式规整,0.45 够用;研报和招股书版式复杂,建议降到 0.4 并配合后处理规则——比如页眉区域固定出现在页面顶部 8% 高度内,可以直接按位置过滤,不必完全依赖模型。

2.2 阅读顺序分割:竖切线检测与跨页段落合并

布局检测只告诉你「哪里有什么」,阅读顺序要解决的是「先读什么后读什么」。PPT 里给出的流程是:布局检测结果 → 判断是否存在跨多行的公共竖切线 → 有则按竖切线分割页面 → 无则返回原顺序。这个逻辑针对的是多栏排版,比如学术论文式的双栏研报。

实际操作中,竖切线检测用投影法就能做:对页面二值化后做垂直投影,统计每一列的黑像素数量,连续多列投影值低于阈值的区域就是栏间空白。但证券文档的坑在于,有些表格内部也有竖线,会被误判为栏分割线。我的做法是先排除表格区域再做投影,PPT 里没有明确写这一步,但不做的话双栏研报里的表格会被切碎。

跨页段落合并是另一个高频翻车点。一份公告的「重大事项提示」段落从第 3 页底部延续到第 4 页顶部,如果按页切分,检索时用户问「重大事项是什么」,只能召回半句话。合并逻辑是:检查上一页最后一个文本块的 bbox 底边是否接近页面底部,且下一页第一个文本块的 bbox 顶边是否接近页面顶部,同时两段文本的字体大小和行距一致,则判定为跨页段落。

def merge_cross_page_paragraphs(prev_page_blocks, curr_page_blocks, page_height, margin=50): """ prev_page_blocks: 上一页文本块列表,按阅读顺序排列 curr_page_blocks: 当前页文本块列表 page_height: 页面高度像素值 margin: 判断是否接近页边距的阈值,A4 扫描件建议 50px """ if not prev_page_blocks or not curr_page_blocks: return curr_page_blocks last_block = prev_page_blocks[-1] first_block = curr_page_blocks[0] # 上一页最后一块接近底部,当前页第一块接近顶部 last_near_bottom = last_block["bbox"][3] > page_height - margin first_near_top = first_block["bbox"][1] < margin # 字体大小一致(用 bbox 高度近似) last_font_size = last_block["bbox"][3] - last_block["bbox"][1] first_font_size = first_block["bbox"][3] - first_block["bbox"][1] font_match = abs(last_font_size - first_font_size) < 5 if last_near_bottom and first_near_top and font_match: # 合并:把当前页第一块的内容拼到上一页最后一块 last_block["text"] += first_block["text"] last_block["bbox"][3] = first_block["bbox"][3] # 扩展 bbox return curr_page_blocks[1:] # 移除已合并的块 return curr_page_blocks

margin这个参数需要按扫描件分辨率调。300 DPI 的 A4 扫描件,页边距大约 100-150px,设 50 太保守会漏合并,设 200 又会把正常段落误合。我一般先用 10 份样本测一下页边距分布再定。

2.3 无框线表格还原:从单元格合并到跨页拼接

无框线表格是证券文档里最恶心的东西。上市公司公告里的财务数据表经常只有横线没有竖线,或者干脆全无线,靠对齐关系表达列结构。PPT 里提到的处理包括:无框线表格还原、跨页表格合并、单元格合并、表格内图片还原。

无框线表格还原的核心思路是:先检测文本块,再根据文本块的水平对齐关系和垂直间距推断表格结构。具体做法是,对同一区域内的文本块按 y 坐标聚类成行,再按 x 坐标聚类成列,如果行间距和列间距呈现规律性,则判定为表格。单元格合并的识别靠的是 bbox 跨度——如果一个文本块的宽度跨越了多个列,说明它合并了单元格。

跨页表格拼接的难点在于表头识别。一份表格从第 5 页延续到第 6 页,第 6 页可能没有表头,也可能重复了表头。拼接逻辑是:检测上一页最后一个表格的列数,与当前页第一个表格的列数比对,如果列数一致且当前页表格顶部没有表头行,则直接拼接;如果当前页有重复表头,则跳过表头行再拼接。

def merge_cross_page_tables(prev_table, curr_table): """ prev_table: 上一页表格,dict 包含 headers, rows, bbox curr_table: 当前页表格 返回合并后的表格 """ if not prev_table or not curr_table: return curr_table or prev_table # 列数一致才合并 if len(prev_table["headers"]) != len(curr_table["headers"]): return curr_table # 判断当前页表格第一行是否为重复表头 first_row = curr_table["rows"][0] if curr_table["rows"] else [] is_repeated_header = all( str(first_row[i]).strip() == str(prev_table["headers"][i]).strip() for i in range(len(first_row)) ) if is_repeated_header: # 跳过重复表头,直接拼接数据行 prev_table["rows"].extend(curr_table["rows"][1:]) else: prev_table["rows"].extend(curr_table["rows"]) # 更新 bbox 范围 prev_table["bbox"][3] = curr_table["bbox"][3] return prev_table

这段逻辑里is_repeated_header的判断依赖表头文本完全匹配,实际中经常遇到表头有细微差异的情况,比如「本期金额」和「本期发生额」。我的做法是加一层模糊匹配,用编辑距离小于 2 作为判定条件,PPT 里没有展开这一点,但不做的话跨页表格拼接会频繁失败。

3. 知识库构建与问答:文本切片策略、向量化微调与检索表设计

文档解析完之后,下一步是把非结构化文本变成可检索的知识库。PPT 里把知识库构建流程拆成了:布局识别 → 阅读顺序检测 → 目录识别 → OCR 识别 → 文本切片 → 向量化 → 检索。这一章重点讲文本切片策略的选择、向量化模型的微调方法,以及存储表的设计。

3.1 文本切片:按字数、按段落还是拆分合并

PPT 里对比了三种切片策略:

策略优点缺点
按字数顺序切片对解析要求低,操作简单切片混乱,段落不完整
按段落切片段落完整,对小标题/简短段落问答效果好向量存储占用空间大,检索后需回溯取数
拆分-合并切片无需回溯原文,向量存储占用小,检索稍快对简短问题效果略差

我实际用下来,证券知识库场景下「按段落切片」是默认选择,因为金融问答对段落完整性要求极高——把「净利润 3.2 亿」和「同比下降 15%」切到两个 chunk 里,检索出来就是灾难。但按段落切片的存储开销确实大,一份 200 页年报可能切出 800-1000 个 chunk,每个 chunk 都要存向量。

折中方案是「拆分-合并」:先把长段落按字数拆成子块,检索时命中子块后向前后扩展合并回完整段落。PPT 里提到这种方式「无需回溯原文」,意思是合并逻辑在检索阶段完成,不需要回数据库捞原始文本。实现上就是在存储时记录每个子块的parent_id和chunk_index,检索命中后按parent_id聚合。

def split_merge_chunking(paragraphs, max_chunk_size=512, overlap=50): """ paragraphs: 解析后的段落列表,每个元素是 {"text": str, "bbox": list} max_chunk_size: 子块最大字符数,中文建议 512 overlap: 子块间重叠字符数,防止边界信息丢失 """ chunks = [] for para in paragraphs: text = para["text"] if len(text) <= max_chunk_size: chunks.append({ "text": text, "parent_id": para.get("id"), "chunk_index": 0, "bbox": para["bbox"] }) else: # 长段落拆分子块 start = 0 idx = 0 while start < len(text): end = min(start + max_chunk_size, len(text)) chunks.append({ "text": text[start:end], "parent_id": para.get("id"), "chunk_index": idx, "bbox": para["bbox"] }) start = end - overlap # 重叠避免边界截断 idx += 1 return chunks

overlap这个参数设 50 是经验值,太小了边界信息会丢,太大了存储冗余。证券文档里数字和单位经常跨子块边界,比如「3.2」和「亿元」,重叠 50 个字符基本能覆盖。

3.2 向量化微调:BGE-M3 上的弱监督学习与 recall 提升

PPT 里给出的向量化方案是:基于 BGE-M3 做微调,用 C-MTP 做有监督和弱监督训练。有标签数据集做监督微调,无标签数据集做弱监督学习。具体数据是 10 万条语料训练,5000 条测试集验证,recall@10 从 62.7% 提升到 73.3%,recall@20 从 73.7% 提升到 83.9%。

这个提升幅度在金融领域是合理的,因为通用 embedding 模型对「归母净利润」「扣非净利润」「少数股东损益」这类金融术语的区分度不够。微调的关键在于负样例的构造——PPT 里提到「聚类增加负样例质量」,意思是先用基础模型对语料做聚类,同一簇内的不同段落作为难负样例,跨簇的作为易负样例。

from sentence_transformers import SentenceTransformer, InputExample, losses from torch.utils.data import DataLoader # 加载 BGE-M3 基础模型 model = SentenceTransformer("BAAI/bge-m3") # 构造训练样本:正样例是同一问答对,负样例是聚类内的相似但不同段落 train_examples = [] for qa in labeled_data: # 正样例 train_examples.append(InputExample(texts=[qa["query"], qa["positive"]])) # 难负样例:同一聚类内的其他段落 for neg in qa["hard_negatives"]: train_examples.append(InputExample(texts=[qa["query"], neg])) train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=32) train_loss = losses.MultipleNegativesRankingLoss(model) # 微调,PPT 里用 10 万条语料,实际按显存调 batch_size model.fit( train_objectives=[(train_dataloader, train_loss)], epochs=3, warmup_steps=100, output_path="./bge-m3-finance" )

MultipleNegativesRankingLoss是句向量微调的常用损失,它把 batch 内其他样本自动当作负样例。epochs=3是保守设置,金融语料过拟合很快,超过 5 轮验证集 recall 就开始掉。PPT 里没有写具体超参,这是我复现时试出来的。

3.3 存储表设计:源数据表与检索表的分离

PPT 里提到两种表:源数据表按文档元素存储,包括段落、表格、图片;检索表按检索需求合并段落分块存储,包含 s3 链接、坐标、目录树结构。这种分离设计的好处是,源数据表保持原始结构用于回溯和展示,检索表做扁平化用于向量检索。

源数据表的关键字段包括:doc_id、element_type(段落/表格/图片)、content、bbox、page_num、s3_url。检索表的关键字段包括:chunk_id、parent_id、content、embedding、bbox、toc_path(目录树路径)。toc_path这个字段很有用,检索时可以按目录树过滤,比如用户问「第三章的营收数据」,直接限定toc_path LIKE '%第三章%'。

-- 源数据表:按文档元素存储 CREATE TABLE source_elements ( id BIGINT PRIMARY KEY, doc_id VARCHAR(64), element_type VARCHAR(16), -- paragraph/table/image content TEXT, bbox JSON, -- [x1,y1,x2,y2] page_num INT, s3_url VARCHAR(512), created_at TIMESTAMP ); -- 检索表:合并段落分块存储 CREATE TABLE retrieval_chunks ( chunk_id BIGINT PRIMARY KEY, parent_id BIGINT, -- 关联 source_elements.id content TEXT, embedding VECTOR(1024), -- BGE-M3 输出维度 bbox JSON, toc_path VARCHAR(256), -- 目录树路径,如 "第一章/1.2/1.2.3" doc_id VARCHAR(64), INDEX idx_toc (toc_path), INDEX idx_doc (doc_id) );

embedding字段用VECTOR(1024)是 BGE-M3 的输出维度,如果换模型要同步改。toc_path的索引在目录结构深的文档上能显著加速过滤查询。

4. 避坑与排查:证券知识库构建中的五个高频翻车点

这一章记录我在复现过程中踩过的坑,每条按「现象 → 原因 → 解决」写。

4.1 扫描件签章覆盖文字导致 OCR 漏识别

现象:上市公司公告的盖章页,公章覆盖了「特此公告」后面的日期,OCR 输出里日期缺失。

原因:OCR 模型对红色印章和黑色文字的重叠区域区分度不够,印章被当作噪声过滤掉了。

解决:在 OCR 前做颜色通道分离,提取红色通道单独处理印章区域,黑色通道处理文字。PPT 里提到「签章覆盖文字识别」但没有展开,我的做法是用 OpenCV 做 HSV 空间分割,红色印章区域单独跑一遍 OCR,再与黑色文字结果合并。

4.2 跨页表格拼接后表头错位

现象:合并后的表格列数对不上,第二页的数据被塞到了错误的列。

原因:第二页表格没有重复表头,但第一列是行标签(如「营业收入」),拼接时被当成了数据行。

解决:拼接前检查当前页表格第一列是否与上一页表格第一列语义相似,如果相似则判定为行标签而非数据。用编辑距离或简单的字符串匹配即可,PPT 里没有提这一点,但不做的话财务表格拼接基本不可用。

4.3 向量化微调后通用问答能力下降

现象:微调后的 BGE-M3 在金融问答上 recall 提升明显,但用户问「今天天气怎么样」这种通用问题时,检索结果完全乱套。

原因:微调数据全是金融语料,模型过拟合到了金融领域,通用语义空间被破坏。

解决:微调时混入 10%-20% 的通用语料做正则化,或者在推理时对非金融 query 走基础模型。PPT 里没有提这一点,但这是领域微调的通用坑。

4.4 目录识别把页眉当成章节标题

现象:目录树里出现了「第 3 页」「共 20 页」这样的条目。

原因:页眉区域的文本块被布局检测误判为标题,进入了目录抽取流程。

解决:在目录识别前加一层位置过滤,页面顶部 8% 和底部 5% 区域的文本块直接排除。PPT 里提到「页眉页脚识别」但没有给具体阈值,8% 和 5% 是我在 A4 文档上试出来的。

4.5 检索表 chunk 过大导致向量检索精度下降

现象:按段落切片后,有些段落长达 2000 字,向量检索时命中率很低。

原因:过长的 chunk 在 embedding 时信息被稀释,向量表示不够聚焦。

解决:对超过 512 字的段落强制走拆分-合并策略,检索时先命中子块再合并。PPT 里对比了三种切片策略但没有给长度阈值,512 字是中文 embedding 模型的常见推荐值。

5. 进阶技巧:用目录树结构做检索过滤与召回重排

PPT 里提到「大模型生成目录」和「目录树结构」存储,但展开不多。我在实际项目里发现,目录树是证券知识库检索质量提升最明显的杠杆之一。一份年报的目录结构本身就是高质量的先验知识——用户问「研发投入」,如果能在目录树里定位到「第四节 经营情况讨论与分析 / 研发投入」这个路径,检索范围可以直接缩小 80%。

具体做法分三步。第一步,在文档解析阶段抽取目录树,PPT 里给的流程是:布局识别 → 标题坐标抽取 → 原文目录抽取 → 数据清洗 → 大模型生成目录。大模型生成目录这一步是为了处理没有显式目录的文档,比如某些公告。第二步,在检索表里给每个 chunk 打上toc_path标签,标签的粒度到三级标题即可,太细了检索时匹配不上。第三步,检索时先用 query 做目录树匹配,如果命中某个目录节点,则限定toc_path范围再做向量检索。

def toc_filtered_retrieval(query, toc_tree, retrieval_table, top_k=10): """ query: 用户查询 toc_tree: 目录树,dict 结构 {"title": str, "children": [...]} retrieval_table: 检索表连接 top_k: 返回结果数 """ # 第一步:用 query 匹配目录节点 matched_nodes = [] for node in flatten_toc(toc_tree): # 简单关键词匹配,实际可以用 embedding 做语义匹配 if any(kw in query for kw in extract_keywords(node["title"])): matched_nodes.append(node["path"]) # 第二步:如果命中目录节点,限定 toc_path 范围 if matched_nodes: sql = """ SELECT chunk_id, content, bbox, toc_path FROM retrieval_chunks WHERE toc_path LIKE %s ORDER BY embedding <=> %s LIMIT %s """ # 对每个命中节点分别检索再合并 results = [] for path in matched_nodes: results.extend(db.execute(sql, (f"{path}%", query_embedding, top_k))) # 去重并按相似度排序 results = deduplicate_and_rank(results) return results[:top_k] # 未命中目录节点,走全量向量检索 sql = """ SELECT chunk_id, content, bbox, toc_path FROM retrieval_chunks ORDER BY embedding <=> %s LIMIT %s """ return db.execute(sql, (query_embedding, top_k))

这段代码里toc_path LIKE %s的前缀匹配是关键,path%能匹配该节点下的所有子节点。deduplicate_and_rank需要自己实现,按 chunk_id 去重后按向量相似度重排。实际测试下来,加了目录过滤之后,金融问答的 top-3 命中率能提升 15-20 个百分点,尤其是「某章节某小节」这类定位型问题。

还有一个技巧是召回重排。PPT 里没有展开重排,但证券知识库场景下,向量检索的 top-50 里经常混入语义相似但主体错误的 chunk——比如问「茅台营收」召回「五粮液营收」。我的做法是加一层轻量级 cross-encoder 重排,用 BGE-Reranker 对 top-50 做精排,取 top-10 返回。重排模型比 embedding 模型大,但只对候选集做推理,延迟增加 50-100ms,在可接受范围内。

从那以后我每次做金融知识库,都强制走一遍「目录树过滤 + 重排」的流程,哪怕文档没有显式目录,也要用大模型生成一个再走。这个习惯帮我省了至少三次线上事故。希望帮到你。

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

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

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

立即咨询