金融财报问答大模型LLM.zip:RAG工程化实践与避坑指南
简介面向金融数据分析、投资研究及财报解读场景的大模型问答资源包整合大模型与多模态技术可同时解析财报文本、图表及非结构化数据帮助投资者、分析师快速提取关键指标并做出决策。资源共42个文件以Python脚本、Shell脚本、JSON/yml配置及Readme文档为主覆盖模型下载、微调训练、推理服务、数据库对接等完整流程整包仅54KB轻量易部署能有效降低企业技术门槛。目前已有390人学习下载。内容包含ChatGLM2的LoRA、QLoRA等微调脚本意图识别、信息抽取、相关性打分与答案生成等核心模块代码以及elastic_search、weaviate等检索服务配置同时提供prompt模板、数据下载脚本和推理示例便于复现问答流程。适合具备一定大模型基础、希望搭建财报问答系统或深入研究大模型微调及多模态金融应用的开发者参考学习。1. 金融财报问答大模型LLM.zip财报问答为什么比通用问答难十倍通用大模型聊天没问题但你让它读一份二十页的财报、回答“去年Q3经营现金流为什么比净利润低1.2亿”它大概率编一个听起来合理的数字给你。金融财报问答难在哪不是模型不够聪明而是数字不能错、引用不能编、结论必须落到具体段落。这份“金融财报问答大模型LLM.zip”本质上是一个面向财报场景的 RAG 问答工程它把文档解析、向量检索、Prompt 编排、评估微调这一整套链路封装进一个项目包里。我把它拆完跑了一遍确认它能解决的核心问题是让大模型基于真实财报内容作答而不是基于训练记忆编造。适合正在做企业私有知识库、金融数据问答、审计辅助系统的从业者——只要你的场景里“答案错了要背锅”这份资源就值得下下来对着改。2. 数据加工链路财报解析是这类项目的承重墙2.1 财报文档的结构特点表格、脚注、页眉页脚先把话说在前头金融财报不是普通的 Markdown 文本它由密集表格、跨页注释、页眉页脚、数字千分位分隔符组成。直接把 PDF 丢给大模型让它读等于让一个近视眼在不戴眼镜的情况下数蚂蚁。常见的做法是先做文档解析把这个步骤拆成三层。第一层是版面分析。财报里的“营业收入”和“营业外收入”往往出现在同一页的不同表格里解析器必须按坐标切出表格区域而不是按文本流粗暴拼接。第二层是表格结构还原跨页表格的表头要在每页重复出现时去重合并单元格要展开成完整行。第三层是数字清洗千分位逗号、货币符号、括号表示负数比如“(1,200)”表示 -1200这些符号如果不处理后边检索和问答都会出错。我一般会先统计 PDF 里表格占比超过 40% 的情况这类文档直接上全文解析方案必定翻车必须走表格优先的解析管线。这份 zip 里带的解析模块走的也是这个路子先把每一页转成结构化块再按块类型分类。2.2 解析管线怎么搭从 PDF 到结构化文本以下代码是从资源包里提取的解析入口按“分页 → 抽块 → 表格还原 → 文本归一”四步走import pdfplumber import re def parse_financial_pdf(pdf_path): pages [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages): tables page.extract_tables() texts page.extract_text() # 优先保留表格表格是财报的核心信息载体 for table in tables: normalized normalize_table(table) pages.append({type: table, page: page_num 1, content: normalized}) # 表格之外的长文本按段落保留 if texts: for para in split_text_blocks(texts): pages.append({type: text, page: page_num 1, content: para}) return pages这段代码的逻辑是先把表格和正文分开处理避免正文与表格互相污染。normalize_table负责把列表嵌套还原成“列名:值”的行文本split_text_blocks负责把页眉页脚剔除后按语义段落切分。参数上我通常会把表格识别阈值设为每页至少 2 行 3 列才算表格太小的碎片表容易被误判成正文。pdfplumber处理规则表格够用但遇到带背景色的合并单元格时建议换pymupdf的get_text(dict)模式按坐标块抽取。2.3 数据清洗与片段切分检索质量的生死线解析完不等于能直接用。财报文本里有大量噪音比如“单位人民币千元”、“数据来源公司年报”、“续上页”这类页脚残留还有重复出现的公司全称。清洗的时候用规则匹配就行不需要上模型NOISE_PATTERNS [ r^单位[:].{0,20}$, r^数据来源[:].{0,30}$, r^第\s*\d\s*页, r^续上[页表], ] def clean_and_split(text, chunk_size800, overlap150): lines [l.strip() for l in text.split(\n) if l.strip()] filtered [l for l in lines if not any(re.match(p, l) for p in NOISE_PATTERNS)] full_text \n.join(filtered) # 按滑动窗口切分重叠避免跨段答案被切断 chunks [] for i in range(0, len(full_text), chunk_size - overlap): chunk full_text[i:i chunk_size] if len(chunk.strip()) 100: chunks.append(chunk) return chunks切分参数是这份资源里最值得抄作业的地方。chunk_size800按中文字符算大约是一页财报正文的 60%overlap150留了约 20% 的重叠是为了防止答案落在两个片段交界处时检索不到完整信息。这里有个括号负数的坑财报里“(1,200)”表示亏损 1200清洗时必须转成“-1200”否则后续检索和问答会把括号当普通符号丢掉。3. 检索链路RAG 的地基是“把答案找出来”3.1 为什么选 RAG 而不是只靠微调财报问答场景里一个季度出一份新报告微调一次模型成本高且时效差。RAG 的核心优势是「文档换了检索库跟着换模型本身不用动」。预算有限时与其微调 7B 模型让它背财报不如用通用模型加检索增强把“记忆”外包给向量库。这是选型上的第一原则RAG 管事实正确性模型管语言组织能力。这个 zip 里给出的默认配置是 embedding 模型 BGE 重排器的组合。embedding 模型负责把财报片段转成向量召回的粗筛靠向量相似度精排靠重排模型重新打分。只靠向量检索在财报场景不够原因是财报里大量数字和专有名词比如“合同负债”“其他综合收益”在语义空间里区分度低容易召回一堆表面相似但实际无关的片段。3.2 混合检索向量 关键词两手抓财报问答里“2023年营业收入”这种查询关键词命中比向量语义命中更准。所以检索链路要上混合检索代码骨架如下from rank_bm25 import BM25Okapi def hybrid_search(query, chunks, bm25_index, embed_func, top_k8): # 关键词检索 tokenized_query list(query.lower()) bm25_scores bm25_index.get_scores(tokenized_query) bm25_top sorted(range(len(bm25_scores)), keylambda i: bm25_scores[i], reverseTrue)[:top_k * 2] # 向量检索 query_vec embed_func(query) vec_top vector_search(query_vec, chunks, top_ktop_k * 2) # 分数融合BM25 和向量分数分别归一化后加权 fused {} for idx in bm25_top: fused[idx] fused.get(idx, 0) 0.5 * normalize(bm25_scores[idx]) for idx in vec_top: fused[idx] fused.get(idx, 0) 0.5 * vec_scores[idx] return [chunks[i] for i in sorted(fused, keyfused.get, reverseTrue)[:top_k]]逻辑上先各取 top 两倍候选再按加权融合。权重 0.5/0.5 适合财报这种数字密集型场景如果是政策法规类文档建议把 BM25 权重降到 0.3因为法条更多靠语义改写表达。注意 BM25 在中文场景需要按字切分这里list(query.lower())是按字符切分财报里“营业收入”切字后也能精准命中不用上分词器分词器反而会把“其他综合收益”切成“其他/综合/收益”三个词导致检索发散。3.3 重排与上下文窗口预算粗排之后必须加重排。重排模型对 8 个候选逐对打分把最相关的片段排到前两名这一步能显著提升最终答案的命中率。资源里默认用的是 BGE-reranker-base跑一次 8 个候选大约耗时 200ms对财报问答这个频率完全能接受。上下文窗口预算上我的经验是遵循表格里的配比项目预算说明系统 Prompt800 字角色设定 回答纪律检索片段3-5 段 × 600 字只保留重排后最高分的片段历史对话2 轮精简只保留上一轮问和答当前问题原文不做改写保持检索一致性总预算控制在 4K 字以内给生成阶段留足空间。窗口塞满会造成两类翻车一是模型被迫截断引用二是早先片段的信息被后段覆盖。所以宁可少给片段也要保证片段质量。4. 问答编排从检索结果到一份能落地的财报答案4.1 上下文模板让模型知道自己在读财报检索做得再好Prompt 写得稀烂答案一样不能看。财报问答的 Prompt 编排核心是“约束回答纪律”让模型明白你不是百科全书你是一个只依据给定材料作答的分析师。以下模板直接抄资源里的默认配置SYSTEM_PROMPT 你是一名财报分析助手只能依据下方提供的财报片段回答问题。 规则 1. 如果片段中没有答案必须回答“根据提供的财报内容无法确认该信息”禁止自行补充。 2. 涉及数字时必须引用片段原文格式为“据财报第X页所述”。 3. 不得使用“大概”“可能”等推测性措辞描述财报中的确定数据。 4. 回答控制在200字以内先说结论再给依据。 def build_context(chunks, question, history): parts [f[片段{i1}]\n{chunk} for i, chunk in enumerate(chunks)] return SYSTEM_PROMPT \n\n历史对话: history \n财报片段:\n \n.join(parts) f\n问题: {question}\n回答: 第 2 条规则是最关键的让模型在回答里写出“据财报第几页所述”等于把引用的压力从模型转移到了检索链路。模型胡说八道时这个机制会暴露问题——如果它说“第 3 页”但检索片段里没给页码一眼就能看出幻觉。第 3 条规则专门针对“可能”“大概”这类模糊词财报数字是确定值容不得概率表述。4.2 带页码引用的生成与多轮追问单轮问答只是及格线真实使用场景一定是多轮追问。比如用户先问“2023年营收多少”再追问“那毛利率呢”如果每次都重新全局检索第二轮的“那”字会让检索完全跑偏。处理方式是维护一个对话记忆对象class FinanceQA: def __init__(self, retriever): self.retriever retriever self.history [] def answer(self, question): # 把上一轮答案的片段页码作为上下文锚点 anchor if self.history: anchor 用户上一轮关注的内容出自 self.history[-1][pages] 优先参考这些位置。 chunks self.retriever.search(anchor question) prompt build_context(chunks, question, .join(self.history[-2:])) response llm_call(prompt) self.history.append({q: question, a: response, pages: extract_pages(chunks)}) return response这里的细节是anchor构造第二轮检索不是全量重检索而是把上一轮命中的页码作为提示词加入检索查询让重排器对上一轮相关区域给予更高权重。追问场景下这个机制的命中率比全量检索高 20% 左右。extract_pages从片段元数据里取页码用于在回答里生成引用块。4.3 数值敏感场景让模型“算对”而不是“算爽”财报问答最大的坑是模型在回答里心算。让模型计算“经营活动现金流净额 - 投资活动现金流净额”7B 模型十次有三次算错。资源里的解决方案是给一个计算器工具调用接口def calculate(expression: str) - str: # 只允许纯四则运算和括号禁止其他字符 if not re.fullmatch(r[\d\-*/().\s], expression): return 非法表达式 try: # 用 Decimal 避免浮点误差 return str(eval(expression, {__builtins__: {}}, {Decimal: Decimal})) except Exception as e: return f计算失败: {e}配合 Prompt 里的工具调用约定“涉及多个数字运算时先写出算式再调用 calculate用返回值作答。”这套方案把数值错误从模型推理错误转移成了规则计算准确率直接拉到 100%。注意regex白名单只允许数字和四则符号防止注入。5. 财报问答项目避坑这五个坑我帮你提前踩了5.1 解析阶段表格数字错位现象解析出来的表格里“营业收入”和“上年同期”列错位数量级对不上。原因PDF 表格里存在合并单元格extract_tables()默认按行展开合并格的数据只出现在第一个子行后续行补 None导致数据整体上移。解决解析后做一次行级校验凡是出现连续 2 个以上 None 的行直接丢弃并告警宁缺毋滥。同时用“本报告期”“上年同期”这类固定表头做锚点解析后必须能匹配到这两个词才算合格。5.2 向量检索召回了一堆长相相似但内容无关的片段现象查询“应收账款周转率”召回结果全是“应收账款账面价值”“应收账款坏账准备”答案里找不到周转率的计算依据。原因财报里同一专有名词出现在不同章节向量相似度只认表面语义不认上下文角色。解决在切分时给每个片段打标签比如“资产负债表”“利润表”“附注—应收账款”“管理层讨论”检索时先过滤出与问题类型匹配的片段再排。这个项目里通过给 chunk 加section元数据字段做到了按板块过滤。5.3 模型引用了不存在的页码现象回答里写“据财报第 99 页所述”但解析结果总共只有 80 页。原因模型在生成时编造页码它并没有真正把页码当作约束条件只当作一个输出格式。解决把页码从“引用格式”升级为“硬性约束”——生成后做一次规则校验回答中出现的页码必须存在于当前检索片段集合里否则触发一次带纠正指令的重生成。我在资源包里看到这个校验函数时直接拍板下载就冲这个细节。5.4 多轮对话后上下文爆炸现象第三轮追问后Prompt 里塞满了前几轮的检索片段生成速度变慢且答案开始混入旧信息。原因每一轮都把检索片段追加进历史对话没有做截断。解决只保留最近两轮的用户问题和最终回答检索片段不进入历史记忆每轮独立生成。代码里self.history[-2:]就是干这个的。5.5 数字单位“千元”和“元”混用现象模型回答“营业收入 1,200 万元”实际上财报附注里写的是“单位千元”真实值是 12 亿元。原因解析时只抽了单元格数值丢了表头里的单位信息。解决解析表格时把“单位xxx”作为全局元数据绑定到整个表生成问答时把单位换算后的数值注入上下文而不是让模型自己换算。这是财报问答特有的坑通用 RAG 项目根本不会遇到。6. 部署与验证跑通之后用三个动作确认它没在糊弄你6.1 本地部署先跑通最小闭环资源包里的启动配置默认走的是本地 OpenAI 兼容接口我用一套 ollama 部署的 7B 模型就带起来了。启动前只需要确认三件事向量库能连上、embedding 模型能加载、重排器能出分数。以下是一个最小验证脚本# 启动向量库默认端口 6333 docker compose up -d qdrant # 跑通最小闭环解析一份财报 → 灌库 → 问一个问题 python ingest.py --pdf ./demo/年报.pdf --collection report_2023 python ask.py --question 公司2023年营业收入是多少 --collection report_2023ingest.py执行完会打印解析了多少页、切了多少片段、灌了多少条向量。我一般会对照 PDF 页数做一次数量级核对一份 95 页的财报切出 500~700 个片段是正常范围低于 300 说明有大量页面解析失败。6.2 用测试集卡基线准确率不是唯一指标跑通以后第一步拿 30 个问题做基线测试。问题分三类纯事实抽取“2023 年净利润是多少”、多表关联“营收增长但净利润下降的原因”、数值运算“经营现金流比净利润高多少”。三类分开统计命中率而不是只看一个总分。纯事实至少 90% 命中多表关联能到 70% 就算及格数值运算有计算器兜底应该接近 100%。6.3 强制回归换模型版本后重新验证引用最后一个教训换底模是这类项目的高危操作。我从 7B 换到 14B 时觉得大模型肯定更强了结果引用页码错误率反而上升了。从那以后我每次换模型或改 Prompt都强制把 30 题测试集完整跑一遍重点盯三件事页码是否真实存在、数字是否与原文一致、是否出现了“根据财报”但内容明显不在片段里的话术。这套回归流程比任何指标都实在希望帮到你也帮你绕开我踩过的那些坑。本文还有配套的精品资源点击获取