证券投研知识图谱构建:多模态文档解析与DeepSeek实体抽取实战
2026/9/19 18:16:33 网站建设 项目流程

简介:面向证券投研、NLP与知识图谱方向的从业者与研究者,这份192页方案系统讲解如何基于DeepSeek-VL2多模态文档理解技术,从券商研报中自动抽取实体关系并完成知识图谱构建,适用于投研数据中台、智能研报解析与金融知识服务等场景。文档共51个大章节,覆盖本体设计、多模态数据预处理、表格与图片信息抽取、数据标注与质量控制、Prompt语义扩充、增量预训练、分布式环境搭建、长尾实体损失优化、关系抽取联合训练、微调方案对比及跨券商泛化测试等内容。方案不仅给出技术选型依据,还包含训练监控、梯度优化、Prompt模板设计与蒸馏分析等工程细节,便于读者直接复用到同类证券文本处理任务中。资源共1个PDF文件,压缩包大小约11.15MB,目前已有98人学习下载。

1. 为什么证券投研需要"可计算"的知识图谱

一份券商深度研报通常在30到100页之间,少数行业框架报告会冲到192页以上。真正决定信息价值的部分,往往不在摘要和正文里,而在跨页的财务表格、产业链示意图、产品对比图和脚注的风险提示中。过去做研报知识图谱,卡在两道墙:PDF版面结构无法批量还原,实体和关系只能靠规则模板硬抽。多模态文档理解把第一道墙拆掉,DeepSeek这类生成式模型则把实体关系抽取从特征工程变成指令工程。这套证券投研知识图谱构建方案,主线是研报从PDF变成结构化JSON,再变成带置信度的三元组,经过实体链接写入Neo4j,最终服务于投研问答和产业链传导分析。适合正在做投研系统、量化因子库或企业知识中台的工程师。

2. 多模态研报解析:把PDF版面中的表格、图表和正文变成统一JSON

2.1 研报PDF为什么难解析:先分清三类内容

研报PDF不是干净的文本文档,问题集中体现在三个层面:双栏或三栏排版导致文本阅读顺序错乱,带框线表格跨页后表头重复且列对齐失败,产业链图和景气度曲线以图片形式嵌入导致数字完全不可见。若直接对整页做纯文本抽取,"碳酸锂价格走势"这类关键图表里的数据会全部丢失。

多模态文档理解在工程上的落地方式,并不是只靠某个视觉大模型把整页读一遍,而是把版面拆成文本流、表格结构、图表图像三轨,分别解析后再融合成统一JSON。这样做的理由是每一条解析路径都可独立调试、独立缓存,并且失败时可以降级。文本层用pdfplumber提取,表格层需要指定抽取策略,图表层则先把页面渲染成图片,再交给OCR或视觉模型接管。

| 内容形态 | 解析工具 | 输出结构 | | 正文文本 | pdfplumber extract_text | 纯文本加坐标 | | 表格 | pdfplumber extract_tables | 二维数组转markdown | | 图表图片 | PyMuPDF渲染加OCR | 图片路径加文字识别结果 | | 页眉页脚 | 版面过滤规则 | 丢弃或标记为噪声 |

券商研报的版权页、评级说明和免责声明集中在首尾页,通常与投研实体无关。解析阶段直接按页号范围丢弃这两部分,既能减少噪声,也能降低后续LLM抽取的token消耗。

2.2 一套可复现的PDF解析流水线

我一般会把解析分成三个函数:extract_text、extract_tables、render_pages,最后按页合并。下面这个实现基于PyMuPDF和pdfplumber,用pip安装后即可运行。

import fitz import pdfplumber from pathlib import Path def parse_research_report(pdf_path: str, out_dir: str) -> list: pages = [] render_dir = Path(out_dir) / "pages" render_dir.mkdir(parents=True, exist_ok=True) doc = fitz.open(pdf_path) with pdfplumber.open(pdf_path) as pdf: for idx, page in enumerate(pdf.pages): # 文本层:保留相对坐标用于后续按栏切分 text = page.extract_text( x_tolerance=1.5, # 字符间超过1.5pt视为列边界 y_tolerance=3.0, # 垂直方向的字符合并容差 layout=False # False得到带换行的纯文本 ) # 表格层:优先按线条切分,研报表格通常带完整框线 tables = page.extract_tables( { "vertical_strategy": "lines", "horizontal_strategy": "lines", "snap_tolerance": 3, } ) # 图像层:150DPI渲染整页,供视觉模型或OCR兜底 pix = doc[idx].get_pixmap(dpi=150) img_path = str(render_dir / f"{idx:04d}.png") pix.save(img_path) pages.append({ "page_no": idx + 1, "text": text, "tables": [t for t in tables if t], "image_path": img_path, "source": pdf_path }) doc.close() return pages

代码逻辑说明:x_tolerance=1.5控制字符间距达到多大时允许换行。研报双栏场景下,同栏字符间距通常远小于栏间空隙,设1.5能在保留栏内完整句子的同时避免把两栏文本混在一起。y_tolerance=3.0控制垂直方向多近的字符会被合并为同一行,用来容忍行内轻微偏移。表格抽取采用lines策略,因为券商研报表格几乎都带框线,命中率高;如果遇到无框线表格,可以退回text策略按文本间隙识别。渲染150DPI是为了给视觉模型清晰输入,同时控制显存占用。

有一个容易忽略的点:extract_tables返回的是二维数组,跨页表格需要自己检测表头重复。常见做法是记录每页第一行并和上一页表头行比较,完全相同就丢弃本页表头,避免实体抽取阶段把"单位:亿元"当作数据行。

2.3 表格与图表内容如何进入知识抽取

pdfplumber拿到的表格是二维数组,不能直接塞给实体关系抽取。我习惯把表格转成markdown再拼进文本流,因为LLM对markdown表格的理解稳定性远高于裸数组,且能保留表头与列的对应关系。

def table_to_markdown(table: list) -> str: if not table: return "" lines = [] for row in table: cells = [str(c).replace("\n", " ").strip() if c else "" for c in row] lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines)

图表部分则是另一条路径。DeepSeek开放平台目前的对话模型以文本输入为主,所以生产环境里常见做法是OCR识别图内文字,再结合图注文本做一次摘要。把产业链图中的公司名、箭头关系、区域标签全部OCR出来后,按"节点名、连线、边方向"重新组织,就能补齐纯文本缺失的关系信息。

3. 实体关系自动抽取:DeepSeek从提示词到JSON的工程化配置

3.1 先定义Schema再写Prompt:投研图谱要抽什么

实体抽取之前必须先画清楚边界。投研知识图谱里的实体不是通用NER里的"人名地名机构名",而是业务对象:上市公司、集团母公司、行业、产品、原材料、区域产能、财务指标、风险事件。关系也不是泛化的"相关",而是要支撑后续推理的类型。

我一般用一张表固定schema,并把表里的定义写进system prompt,让模型每次输出都落在预设轨道上。

| 实体类型 | 别名覆盖 | 示例 | | 上市公司 | 股票简称、曾用名 | 宁德时代、CATL | | 集团 | 母公司、控股平台 | 华友钴业、浙江华友 | | 行业 | 申万或中信分类 | 动力电池、正极材料 | | 产品与原材料 | 规格、俗称 | 磷酸铁锂、六氟磷酸锂 | | 财务指标 | 报告期、口径 | 营收、归母净利润 |

关系类型固定为:实控、主营、采购、销售、竞争、产能布局、业绩驱动、风险传导。关系必须有方向,prompt里写死"主语在前,宾语在后"。没有方向的三元组在后续图查询里会变成全表扫描。

3.2 DeepSeek API调用参数与JSON提取模板

研报文本按1000到1500字切块,块与块之间重叠150字,避免跨块的三元组被拆散。切块前把OCR错误、表格噪声统一清洗,保留原文所在页码。抽取prompt里最关键的三个约束:只抽原文明确提到的信息,不推断不补全;输出严格JSON;每个实体带page字段。

以下代码兼容DeepSeek开放平台的OpenAI协议,设置好环境变量即可运行。

import json import os from openai import OpenAI client = OpenAI( api_key=os.environ["DEEPSEEK_API_KEY"], base_url="https://api.deepseek.com" ) SYSTEM_PROMPT = """你是证券研报信息抽取引擎。 只抽取原文明确出现的事实,禁止推测和补全。 实体类型限定为: 上市公司、集团、行业、产品、原材料、区域、财务指标、风险事件。 关系类型限定为: 实控、主营、采购、销售、竞争、产能布局、业绩驱动、风险传导。 输出JSON格式: {"triples": [{"head": {"text": "", "type": "", "page": 1}, "relation": "", "tail": {"text": "", "type": "", "page": 1}}]} """ def extract_triples(chunk: str, page_offset: int) -> dict: resp = client.chat.completions.create( model="deepseek-chat", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"从以下研报片段抽取知识三元组:\n{chunk}"} ], temperature=0, top_p=0.1, response_format={"type": "json_object"}, max_tokens=2048 ) return json.loads(resp.choices[0].message.content)

参数说明:temperature=0让解码近似贪心,减少同一段文本反复抽取时的结果漂移;top_p=0.1进一步收窄采样空间,对抽取类任务比单独调temperature更有效。response_format强制JSON输出,避免模型把三元组写进markdown代码块。max_tokens=2048是因为一块研报文本最多可能抽出几十组三元组,太小会截断成非法JSON。

3.3 抽取结果的后处理:去重、合法性校验与置信度

模型返回的三元组不能直接入库。第一关是字符串校验:head和tail的text长度必须在2到60之间,relation必须在schema枚举内。第二关是重复合并:同一公司全称和简称在抽取结果里会出现多次,用上市公司简称表先做一次归一化。第三关是置信度近似:三元组在原文中能找到连续原文片段的记为高置信,只在表格中出现的记为中置信,两者都不满足的直接丢弃。

还有一个高频坑:研报正文里"公司"指代上一段的主语,模型经常把三元组主语抽成"公司"两个字。对策是在分块前做简单指代消解,把"公司"替换为离它最近的实体全称,或者把段落标题拼进句子开头再抽取。

4. 实体链接与Neo4j图谱构建:把抽取结果对齐到统一实体库

4.1 为什么要做实体链接:同名不同实体与异名同实体的边界

实体关系抽取得到的是文本片段,还不是知识图谱里的节点。同一家公司在不同研报里可能被写成"宁德时代""CATL""宁德";而"中信证券"既指证券公司又指在上交所上市的标的,在不同上下文里指代的对象完全不同。实体链接的任务,就是把每个抽取到的mention映射到统一实体库的唯一ID上。

简单做字符串匹配只能解决第一层问题。研报里大量简称变体会把精确匹配打穿,"湖南裕能"被写成"裕能股份"时,仅靠名称判等会全部落空。我采用的混合方案分两步走:候选生成阶段用归一化名称加编辑距离,候选排序阶段用embedding相似度加行业上下文特征。

| 问题类型 | 典型例子 | 处理阶段 | | 异名同实体 | CATL、宁德时代 | 别名表加embedding排序 | | 同名不同实体 | 中信证券作为券商与上市公司 | 行业上下文过滤 | | 简称截断 | 湖南裕能、裕能股份 | 编辑距离候选生成 |

4.2 向量加规则的混合实体链接实现

候选生成阶段要快。先做字符串归一化,把全角括号、空格、后缀统一,再查别名表。别名精确命中直接进入排序环节。没有别名命中的mention用编辑距离找候选,这一步控制在Top 20以内。候选排序阶段用embedding,把mention和候选实体的名称、行业、主营产品拼成同一段描述文本,分别向量化后算余弦相似度。

def link_mention(mention: str, candidates: list, embed_fn, top_k=5): """ mention: 抽取到的实体文本 candidates: 候选实体列表,元素格式为 {"id": "", "name": "", "seq": ""} embed_fn: 文本向量化函数,接收字符串返回向量 """ m_vec = embed_fn(mention) scored = [] for cand in candidates: c_vec = embed_fn(cand["seq"]) sim = cosine_similarity(m_vec, c_vec) scored.append((cand["id"], sim)) scored.sort(key=lambda x: x[1], reverse=True) return scored[:top_k]

注意,这里的candidates必须来自别名表和模糊匹配,不是全体实体库。全库检索在实体规模超过十万时会拖慢整条链路,而且相似度噪声大。行业上下文可以放在embedding拼接阶段:"云铝股份"和"云南铝业"在描述文本里的语义距离会因行业前缀趋近,比单纯比较名称更稳定。链接准确率至少要达到95%,否则错误三元组会污染图谱下游推理。

4.3 Neo4j写入:约束、MERGE与批量提交

知识图谱存储选Neo4j是投研场景的常见选择,Cypher在多跳关系查询上的表达能力最适合产业链分析。落库前先建唯一约束,这是幂等写入的前提。

CREATE CONSTRAINT company_name IF NOT EXISTS FOR (n:Company) REQUIRE n.uid IS UNIQUE;

写入用MERGE而不是CREATE。MERGE会先按uid查重,存在则跳过或更新属性,不存在才创建。关系同样需要一个去重键,否则同一份研报重跑会重复建边。常见做法是把关系的source_report、page、triple序号拼接成字符串,作为关系上的sig属性。

UNWIND $batch AS row MERGE (c:Company {uid: row.company_uid}) ON CREATE SET c.name = row.company_name, c.first_seen = row.process_time MERGE (p:Product {uid: row.product_uid}) ON CREATE SET p.name = row.product_name MERGE (c)-[r:MAIN_PRODUCT {sig: row.edge_sig}]->(p) ON CREATE SET r.source_report = row.report_id, r.confidence = row.confidence, r.page = row.page;

MERGE关系时,节点属性只写uid,其余属性放入ON CREATE SET,这样节点已存在时不会被反复覆盖。后端批量提交时,每个batch控制在500行以内,事务提交更稳,失败重试的成本也更低。

5. 图谱构建的评测与增量更新:Precision之外还要盯住三种错误

5.1 评测集与指标:谁算对,谁算错

没有评测集就谈不上优化。从研报库里按行业和年份分层抽样,取100段文本,每段标注实体边界、实体类型、关系方向、链接目标。标注规范要写清楚两个最容易扯皮的点:跨句关系算不算,隐含实体算不算。我的默认口径是:实体必须在原文出现,关系允许跨两个相邻句子,不允许跨段推断。

指标按三元组级别计算,正确性要求比纯文本抽取更严格。

| 指标 | 计算方式 | 合格线 | | 实体F1 | 边界和类型同时正确才算正例 | 0.85 | | 关系F1 | head、relation、tail全部正确 | 0.80 | | 链接准确率 | mention正确映射到标准实体ID的比例 | 0.95 |

5.2 三种高频错误与修正回路

第一种是边界错误。"动力电池龙头"被抽成实体时,模型常把"龙头"带上,或者只给"电池"。修法是在prompt里强调"实体字段必须是最小可独立指称的名词短语,不要修饰语"。

第二种是关系方向颠倒。"公司采购碳酸锂"被抽成"碳酸锂采购公司"。修法是把关系定义写成"主语是行为发出方,宾语是行为承受方",并在校验阶段检查三元组的头尾实体类型是否匹配关系定义。比如"采购"关系的head必须是公司或集团,tail必须是产品或原材料。

第三种是幻觉补充。模型依据研报标题补充了原文没有的结论。修法有两个层面:prompt里明确"没有找到就输出空数组";后处理里用原文回溯,若三元组在原文chunk里找不到任意两个词的共现,直接丢弃。

5.3 增量更新与回滚:研报库是流式的,不是一次性导入

投研场景中研报持续发布,同一个公司会被多家券商覆盖,旧报告也可能被撤回。图谱更新要有三个机制:按报告幂等写入、按时间戳回滚、按事件重算子图。

def rollback_report(graph, report_id: str): # 删除某份研报写入的全部关系,保留节点 query = """ MATCH (a)-[r]->(b) WHERE r.source_report = $report_id DELETE r """ graph.execute(query, {"report_id": report_id})

回滚时只删关系不删节点,因为同一节点可能被多份报告关联。节点孤岛清理放到夜间批任务中单独执行,避免正常业务被误删。

如果研报数据有合规要求,可以把DeepSeek开放平台接口替换成内网部署的推理服务,模型权重不变,抽取链路只改base_url指向,评测和回滚逻辑不用动。增量更新调度建议按report_date做水位线,每天只处理新导入的报告,并记录每个报告的处理版本号。

6. 图谱用起来:把Neo4j子图变上下文喂给DeepSeek的投研问答技巧

图谱构建完成后,价值体现在问答和传导分析。常见做法是图检索加文本检索混合:先用Cypher找到答案所在的子图,再把子图序列化成"证据文本"喂给DeepSeek。这个子图转上下文的环节决定回答质量。

我会把每个三元组转成一句带出处的证据链,保留source_report、page和原始句子。LLM回答时必须引用证据,不允许脱离上下文编造。

MATCH (c:Company {uid: $uid})-[r:SUPPLY]-(m:Material) RETURN c.name, type(r), m.name, r.source_report, r.page LIMIT 20;

查询结果在应用层拼成"根据某研报第X页,某公司采购某原材料",再把多条证据拼接成上下文块放进prompt。这个技巧能明显减少DeepSeek在不相关实体上绕圈子的概率,每个事实都能回溯出处。

传导分析走多跳查询,比如"上游碳酸锂涨价影响哪些正极材料公司"。把图查询结果按最短路径展开,逐段解释传导机制,本质是图谱推理加语言模型生成。如果回答延迟偏高,就把常用路径预计算成物化视图,查询直接走视图,让响应时间稳定在百毫秒级。

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

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

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

立即咨询