简介面向证券投研与知识图谱构建的技术人员提供一份基于DeepSeek-VL2多模态文档理解的研报实体关系自动抽取与链接完整方案。文档共192页、51个大章节涵盖从本体设计、多模态研报数据采集预处理到模型选型、表格与图片信息抽取、数据标注体系、小样本增强、增量预训练、损失函数与关系抽取训练策略、梯度优化、监控指标、微调方案及跨券商泛化验证等全流程内容。其中表格结构解析、图表元素实体映射、Prompt模板设计、分布式训练环境搭建等关键环节均有逐步展开适合作为智能投研场景的技术预研与项目落地的蓝图参考。资源包为单个PDF文件大小11.15MB支持目录章节跳转和书签大纲快速定位便于按模块查阅。目前已有98人学习适合从事文档智能、NLP工程化与金融科技方向的读者系统学习。1. 为什么研报知识图谱要从多模态文档理解开始一份券商研报几十页到上百页文字、表格、图表、公式混排。标题里那本 192 页投研文档真正的增量信息往往不在段落里而在营收预测表、毛利率趋势图和机构评级变化中。传统方案先把 PDF 抽成纯文本再跑 NLP等于把目标价、营收预测这些硬数据连同图表一起扔了。DeepSeek 的多模态文档理解能力把链条起点从 OCR 文本升级成版面感知再配合实体关系自动抽取与实体链接技术才能在 Neo4j 里构建出可查询、可追溯的证券投研知识图谱。这套方案适合已经跑过基础 RAG、想往结构化知识走的工程师和量化研究人员从 DeepSeek API 调用、抽取 Schema 设计到链接和图谱落地的参数与坑下面全部按可复现的方式展开。2. 多模态文档理解研报 PDF 如何变成结构化内容2.1 投研版面为什么不能直接整体交给模型投研 PDF 最常见的三种版面坑双栏排版导致阅读顺序错乱表格跨页导致列头丢失图表内标签文字密度高、字号小普通 OCR 漏字率极高。如果只把整页 OCR 文本丢给大模型token 会浪费在页眉、页脚和风险提示上实体抽取的精度会被噪音直接拖垮。我一般会把 PDF 每页转成高分辨率图片让多模态模型先做版面区域分类再对不同区域走不同的抽取策略。这和把整页图直接丢给模型、一次输出全部实体相比每个区域只需要做读这一件事上下文更干净token 消耗也更可控。192 页研报按 200 DPI 转图单页约几十 KB配合区域裁剪后整体调用量可以接受。2.2 调用 DeepSeek API 做版面区域分类识别 title / table / chart / bodyimport base64 import json import requests MODEL_NAME deepseek-vl # 多模态模型名按部署端点实际情况填写 def page_layout(png_path: str, api_key: str, base_url: str) - list: with open(png_path, rb) as f: b64 base64.b64encode(f.read()).decode() payload { model: MODEL_NAME, messages: [{ role: user, content: [ { type: image_url, image_url: {url: fdata:image/png;base64,{b64}}, }, { type: text, text: ( 你是研报版面分析器。把图片内容按区域识别 返回 JSON 数组每项含 bbox(左上角x,y和宽高 全部用图片宽高百分比) 与 type type 取值 title/table/chart/body/header/footer 并给出 reason。不要把图表里的文字拆进 body。 ), }, ] }], temperature: 0, response_format: {type: json_object}, } resp requests.post( f{base_url}/chat/completions, headers{Authorization: fBearer {api_key}}, jsonpayload, timeout90, ) data resp.json() if choices not in data: raise RuntimeError(fAPI error: {data}) return json.loads(data[choices][0][message][content])参数说明temperature0保证版面分类结果可复现response_format设为json_object后模型强制输出 JSON后续解析不用做复杂的字符串清洗。bbox 用百分比而不是像素PDF 渲染 DPI 怎么变裁剪坐标都能直接复用。base_url指向 DeepSeek 开放平台本地用 vLLM 起了多模态权重时改 base_url 和 model 两个字段即可请求结构不变。2.3 表格、图表与正文的分路抽取版面识别完成后对表格区域让模型输出 Markdown 并保留表头层级对图表区域先让模型整理图例数值键值对单位单独成字段正文区域直接输出干净文本。注意先裁剪再请求def extract_region(png_path: str, bbox: tuple, region_type: str) - dict: # 用 PIL 按 bbox(百分比) 裁剪原始页面图再 base64 编码 from PIL import Image import base64, io left, top, width, height bbox with Image.open(png_path) as img: w, h img.size crop img.crop((left*w/100, top*h/100, (leftwidth)*w/100, (topheight)*h/100)) buf io.BytesIO() crop.save(buf, formatPNG) b64 base64.b64encode(buf.getvalue()).decode() if region_type table: prompt 输出该表格的 Markdown 表示保留原表头表头缺失时用括号标注推断列名。 elif region_type chart: prompt 识别图表中所有文字标签与数值输出 JSON{label: value, unit: 单位}。 else: prompt 提取正文文本不改写、不总结、不补全。 # 组装 image_url 请求逻辑与 2.2 的 page_layout 相同返回 JSON 解析结果图表区域经常省略坐标轴单位例如净利润(百万元)只出现在纵轴标题里。如果 prompt 不强调单位字段后面数值归一化时1,505.6和1505.6会因口径不同被误判为两种数据。这也是表格、图表必须单独设计 prompt、不能复用正文模板的原因。提示表格跨页是研报高频问题。把当前页底部识别为 table 且没有表头区域时把它并入上一页的表格上下文让模型补全列名后再转 Markdown能省掉后面大量去重工作。3. 实体关系自动抽取Schema 约束下的 DeepSeek 三元组产出3.1 证券投研的实体与关系体系先定清楚直接让大模型抽所有实体和关系一定会产出大量无效三元组。先定义 Schema实体和关系才有边界。第一版建议用下面这套体系跑完一版后再按业务反馈增删实体类型典型示例关键属性Company 公司贵州茅台stock_code、market、industryIndustry 行业白酒、半导体wind_code、景气度关键词Product 产品/品牌飞天茅台所属公司、营收占比Indicator 财务指标营收、毛利率、EPS数值、单位、报告期Institution 机构中金公司、华泰证券牌照类型Rating 评级买入、增持、中性目标价、评级机构、报告期关系类型同样要收敛。常见的有belongs_to_industry、has_product、issued_rating、sets_target_price、supplies_to、competes_with。每条关系必须带evidence(证据句原文) 和confidence没有证据支撑的三元组在实体链接阶段无法校验宁可不入库。3.2 Few-shot Prompt 与 JSON 输出约束SYSTEM_PROMPT 你是证券研报信息抽取器。只输出与实体类型和关系类型匹配的三元组。 输出 JSON{triples:[{head:, head_type:, relation:, tail:, tail_type:, evidence:, confidence:0.0}]} 规则 1) 不推测原文没有的不写 2) 同一实体的不同写法不做合并交给实体链接阶段处理 3) 数值型 tail 必须带单位 4) confidence 只取 0.5/0.7/0.9 三档。 FEW_SHOT_INPUT 贵州茅台2023年营收1505.6亿元同比增长18%中金公司给予买入评级目标价2100元。 FEW_SHOT_OUTPUT ( {triples:[{head:贵州茅台,head_type:Company, relation:achieves_revenue,tail:1505.6亿元, tail_type:Indicator,evidence:2023年营收1505.6亿元, confidence:0.9}, {head:中金公司,head_type:Institution, relation:issued_rating,tail:买入,tail_type:Rating, evidence:中金公司给予买入评级,confidence:0.9}]} ) def extract_triples(chunk: str, api_key: str, base_url: str) - list: messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: FEW_SHOT_INPUT}, {role: assistant, content: FEW_SHOT_OUTPUT}, {role: user, content: chunk}, ] # 复用 2.2 的 OpenAI 兼容请求封装temperature0 # response_format 设 json_object取返回 JSON 的 triples 字段 data chat_completion(messages, api_key, base_url) return json.loads(data)[triples]Prompt 里最重要的不是抽取两个字而是不推测、不合并。实体合并属于实体链接技术的职责如果抽取阶段让模型顺手做了错误会传播到下游且无法定位。3.3 阅读顺序重排、chunk 切分与两级去重多模态文档理解阶段产出的区域按从上到下、从左到右重排成阅读顺序文本再按 800~1200 字切 chunkchunk 间保留 5% 重叠降低实体跨 chunk 截断的概率。表格转出的 Markdown 单独成 chunk不跟正文混装否则大段表格会稀释正文里的关系密度。3.3.1 文本级去重headrelationtail完全一致就合并confidence 取最大值。这一步直接用 Python 字典就能完成不需要模型参与执行成本最低。3.3.2 语义级去重数值单位统一后再次合并1.5亿和15000万归一化到同一口径视为相同。语义级去重可以用规则也可以把一组候选丢给 DeepSeek 批量判断后者在中文表达多样时效果更好缺点是每次调用要带上候选上下文token 消耗翻倍。4. 实体链接技术让茅台准确指向贵州茅台节点4.1 为什么抽取结果不能直接进图谱知识图谱数据模型的基础是节点不是字符串。抽取结果里茅台、贵州茅台、600519指向同一家上市公司如果直接入库会产生三个孤立的公司节点后面查机构覆盖了哪些公司这类路径会直接断裂。实体链接的作用就是把 mention(指称) 映射到 canonical_id(规范实体 ID)这一步决定 Neo4j 构建知识图谱的质量上限。4.2 候选生成别名词典加向量召回两级漏斗先用别名词典把高频简称直接命中全称这是零成本的一步能覆盖 60% 以上的常见指称import faiss import numpy as np ALIAS { 贵州茅台: Company:600519.SH, 茅台: Company:600519.SH, 600519: Company:600519.SH, 五粮液: Company:000858.SZ, } # entity_ids 与实体库一一对应embedding 维度必须与检索模型一致 def candidates(mention: str, topk: int 10) - list: hit ALIAS.get(mention) if hit: return [{entity_id: hit, source: alias}] vec embed(mention) # 返回 1 x dim 的向量 scores, idx faiss_index.search(np.asarray(vec, dtypefloat32), topk) return [ {entity_id: entity_ids[i], source: vector, score: float(s)} for i, s in zip(idx[0], scores[0]) ]向量召回前要把实体库的 canonical name 和常用别名都塞进索引否则茅五这类集合简称召回不到。Embedding 模型换过之后索引必须重建维度对不上会直接报错这是切换模型时最常见的坑。4.3 DeepSeek 消歧决策候选排序与 NIL 判定把原文句子、mention 和候选列表一起丢给 DeepSeek让它做最终决策def disambiguate(sentence: str, mention: str, cands: list) - dict: prompt f句子{sentence} 指称{mention} 候选实体{json.dumps(cands, ensure_asciiFalse)} 输出 JSON{{entity_id: 选中ID或null, confidence: 0.0到1.0}} 规则上下文明显匹配才选否则输出 null不能选多个不能改 ID。 # 调用 OpenAI 兼容接口temperature0返回解析后的 dict返回 null 表示该 mention 指向实体库外的新实体需要走新增流程并生成新的 canonical_id返回 ID 但 confidence 低于 0.7 的进入人工审核队列。消歧 prompt 必须显式允许输出 null否则模型会硬选一个最像的候选造成误链。4.4 链接后的一致性校验链接完成不等于收工。新三元组写入图谱后要跑一致性规则同一机构在同一报告期不能对一个公司同时给出买入和卖出。这个冲突用 Cypher 就能扫出来MATCH (i:Institution)-[r1:RATED_BY]-(c:Company), (i)-[r2:RATED_BY]-(c) WHERE r1.period r2.period AND r1.rating r2.rating AND id(r1) id(r2) RETURN i.name, c.name, r1.rating, r2.rating, r1.period目标价与当前股价偏离超过 100% 也要标记异常canonical_id 相同的节点 name 属性冲突时告警。这些规则全部用 Cypher 定期全量跑一遍成本很低但能把错误拦截在查询入口之前。5. Neo4j 构建知识图谱图模型、批量导入与投研查询5.1 图模型设计与唯一约束节点标签用第 3 章 Schema 的实体类型Company、Industry、Product、Indicator、Institution、Rating。关系类型直接用RATED_BY、HAS_PRODUCT、IN_INDUSTRY这类原生类型不要把关系种类塞进属性否则查询性能和可读性都差。用 Docker 起 Neo4j 时内存参数直接通过环境变量传docker run -d --name neo4j \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTHneo4j/changeMe \ -e NEO4J_server_memory_heap_max__size2G \ neo4j:5环境变量里双下划线对应配置文件里的点号NEO4J_server_memory_heap_max__size就是server.memory.heap.max_size。实体量在百万级以内2G 堆够用。接着建唯一约束防止批量导入出现重复节点CREATE CONSTRAINT company_canonical IF NOT EXISTS FOR (c:Company) REQUIRE c.canonical_id IS UNIQUE; CREATE CONSTRAINT institution_canonical IF NOT EXISTS FOR (i:Institution) REQUIRE i.canonical_id IS UNIQUE;5.2 批量导入UNWIND 加 MERGE 的写法和参数用 Python 驱动批量写入时不要一行一条 MERGE应该按批次 UNWINDfrom neo4j import GraphDatabase # rel_name/head_label/tail_label 只从 Schema 映射表取值不直接拼模型输出 def load_triples(driver, rel_name: str, head_label: str, tail_label: str, rows: list): cypher f UNWIND $rows AS t MERGE (h:{head_label} {{canonical_id: t.head_id}}) ON CREATE SET h.name t.head_name MERGE (tail:{tail_label} {{canonical_id: t.tail_id}}) ON CREATE SET tail.name t.tail_name MERGE (h)-[r:{rel_name}]-(tail) SET r.period t.period, r.evidence t.evidence, r.confidence t.confidence with driver.session() as session: for i in range(0, len(rows), 500): session.run(cypher, rowsrows[i:i500])注意 MERGE 的顺序先 MERGE 两个节点再 MERGE 关系属性用 SET 叠加。如果把整个路径放进一条 MERGE关系属性里的 period 每次变化都匹配不上会把同一条关系重复建出来。rel_name和两个 label 在传入前必须查白名单映射表防止把模型输出直接拼进 Cypher 造成注入。5.3 投研场景的 Cypher 查询评级时间线与产业链查某公司最近一期各机构评级及目标价MATCH (c:Company {canonical_id: Company:600519.SH})-[r:RATED_BY]-(i:Institution) WITH c, i, max(r.period) AS p MATCH (i)-[r2:RATED_BY]-(c) WHERE r2.period p RETURN i.name, r2.rating, r2.target_price, p ORDER BY r2.target_price DESC查共享产品线的公司用来做产业链映射MATCH (a:Company {canonical_id: Company:600519.SH})-[:HAS_PRODUCT]-(prod:Product)-[:HAS_PRODUCT]-(b:Company) RETURN b.name, collect(prod.name) AS shared_products LIMIT 20第一次跑这类查询之前先确认关系方向。图模型里方向是从机构指向公司查询用-[:RATED_BY]-方向写反会返回空结果这是 Neo4j 新手最容易翻车的地方。5.4 增量更新按报告期做版本化研报每天都会发新的评级和预测。常见做法是每条关系带period属性批量导入时全部携带报告期更新某机构某期的评级时先删旧期边再写新边MATCH (i:Institution {canonical_id: $inst})-[r:RATED_BY]-(c:Company {canonical_id: $comp}) WHERE r.period $old_period DELETE r;这样同一份研报被重复解析时图谱里不会出现同一机构对同一公司两个时期评级同时存活的情况。大版本间合并重复实体用 APOC 的apoc.refactor.mergeNodes但先要确认哪边是保留节点否则属性会互相覆盖溯源信息就丢了。6. 用验证集量化抽取质量实体识别的 F1 与链接准确率6.1 三十页手工标注样本建立基线从 192 页研报里抽 30~40 页覆盖正文段落、表格、图表三类版面手工标注三元组和实体链接结果。不需要全量关键是覆盖三个层次实体识别是否找全、关系是否判对、mention 是否链接到正确 canonical_id。指标分别计算实体 F1、关系 F1(head、relation、tail 全中才算一个正确)、链接准确率。def evaluate(gold_triples: list, pred_triples: list, gold_links: dict, pred_links: dict) - dict: def tripleset(items): return set((t[head], t[relation], t[tail]) for t in items) gs, ps tripleset(gold_triples), tripleset(pred_triples) correct len(gs ps) precision correct / len(ps) if ps else 0.0 recall correct / len(gs) if gs else 0.0 f1 2 * precision * recall / (precision recall) if precision recall else 0.0 link_hit sum(1 for m, eid in gold_links.items() if pred_links.get(m) eid) link_acc link_hit / len(gold_links) return {relation_f1: round(f1, 3), link_accuracy: round(link_acc, 3)}6.2 三个收益最明显的调优手段第一chunk 重叠率从 0 加到 10%实体边界截断导致的漏识别明显下降。第二表格与图表区域的数值三元组强制带单位抽取后统一归一化再入库关系 F1 通常能提升 5% 以上。第三链接阶段把候选数从 5 提到 20NIL 率会下降但必须把置信度阈值提到 0.85 以上压住误链阈值太低时向量召回里的近义实体被误消歧的风险远大于漏链。最后一个对自动化流程最有用的技巧DeepSeek 的抽取与链接请求统一设temperature0并在请求层固定随机数 seed让同一份研报多次重跑的输出保持一致这样每个调参动作的效果才能被准确定位而不是被模型随机性干扰。本文还有配套的精品资源点击获取