Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A 股、港股、美股,不能只用一套向量库

第 10 篇聊 Rerank 时,结尾把镜头拉远问过:单市场能跑,三个市场呢?我当时的答案来得很快——港股的财报 RAG 跑通之后,我做的第一件事,就是把 A 股年报、美股 10-K 也塞进去,同一条管道,同一个向量库。demo 阶段看起来成了:前 20 个测试问题,答对约 18 个。

真实使用的第一周,连出三件事。

有人问“比亚迪的收入“。比亚迪 A 股、H 股两地上市:A 股年报,人民币、CAS 口径;H 股年报,港币、IFRS 口径。两份文件两个口径,同名“收入“后面的数字对不上;系统检索到哪份就答哪份,答案里既不说币种,也不说口径。

第二件,表格被按文本切散。模型读到“流动资产 200 应收账款 80 存货 60“——数字是读到了,哪一年、哪个币种、哪个口径,全丢在 chunk 边界外面。

第三件最难看。“对比 50 家半导体公司过去 3 年的资本开支”,这本质是条 SQL,被逼着用 RAG 答,产出一段流畅的、错的、且无法验证的总结。

三件事,三个病根:口径与 Schema、存储分层、查询路由——这一篇就按这三条,把重建架构时的判断摊开讲。

一、先把三地的差异摊在桌上

三地的差异不是“语言不同“一句话能概括的。把六个维度列出来,每一行都对应一个工程决策:

维度A 股港股美股
语言简体中文中英双语英文
会计准则CASHKFRS / IFRSUS GAAP
币种CNYHKDUSD
报告类型年报、半年报、季报年报、中期报告10-K、10-Q、8-K、20-F
主流格式PDF(证监会统一格式)PDF(公司自定排版)HTML(SEC filing)+ XBRL + PDF
数字单位千元 / 万元 / 亿元百万元millions / billions

(港股报告类型以“年报 + 中期报告“为准——主板季报并非强制披露,GEM 另有季报要求。美股那栏顺带说一句:10-K 和 20-F 不会同时出现在一家公司身上,10-K 是美国本土公司的年报,20-F 是外国私人发行人的年报;而且外国发行人可以直接按 IFRS 申报 20-F,不强制转 US GAAP。另外,只在 OTC 市场挂 ADR 的公司(比如腾讯的 TCEHY)不是 SEC 申报主体,10-K、20-F 都不用交。)

逐行说后果。

语言决定 embedding 必须中英都盖得住,候选是多语言模型(BGE-M3、multilingual-e5 一类)。具体怎么选是第 12 篇的事,这里只把约束立住。

准则决定同名指标要建映射表。“营业收入“在 A 股、“Revenue“在美股、“Turnover“在港股——三个词文本完全不同,金融语义是同一个指标。反过来,“净利润“和“归属于母公司股东的净利润“文本上很像,口径却是两回事。纯向量检索对文本相似敏感、对会计语义无感,这两类它都分不开。

币种决定要不要统一换算,以及原币种必须进 metadata。报告类型决定 metadata 字段怎么设计,保证跨市场查询不串库。主流格式决定解析路径——这是最关键的一行,下一节展开。

表之外还有一层更要命的判断:同一个 embedding 模型,对繁中、简中、英文的编码质量不可能一致。用 multilingual-e5-large 这类多语言模型,中文段落和英文段落的向量分布天然存在偏移——在同一个向量空间里,英文文档的同类 query 会系统性地排在中文文档前面或后面【待补:同库混检的偏序幅度缺实测量化,此处保持定性表述】。这意味着“一个库 + filter 区分市场“在检索质量这层就不成立,不只是工程洁癖。

回头把开场三件事对进表里:第一件对应币种、准则、报告类型三行;第二件对应主流格式——表格解析的欠账;第三件不在表里,它是问题类型的问题,第五节讲。

二、四步管线,和“存储必须分层“的具体含义

重建后的架构一句话:统一的是 Schema 和查询路由,分层的是存储。管线分四步,每步负责一种“对齐“:

原始文件(A 股 PDF / 港股 PDF / 美股 HTML + XBRL + PDF)
        │
        ▼
┌───────────────┐
│ 1. 识别分类    │  市场 + 报告类型 + 年份
└──────┬────────┘
       ▼
┌───────────────┐
│ 2. 解析抽取    │  按市场走不同解析路径
└──────┬────────┘
       ▼
┌───────────────┐
│ 3. 统一标准化  │  映射到统一 Schema
└──────┬────────┘
       ▼
┌───────────────┐
│ 4. 分层存储    │  结构化指标库 + 向量库
└───────────────┘

识别分类搞清楚“这是什么“,解析抽取搞清楚“里面有什么“,统一标准化搞清楚“它和别的报告怎么对得上“,分层存储搞清楚“它该住在哪里、由谁查询“。跳过任何一步,后面的查询都会出现“看起来对、其实错“的回答——财报系统最不能容忍的失败模式。

识别分类只讲思路,不贴代码:规则加启发式。内容里出现“深圳证券交易所““证监会“判 A 股,“香港交易所“判港股,“SECURITIES AND EXCHANGE COMMISSION”“Form 10-K“判美股;内容判不出的,用文件名模式兜底(六位数字代码、0700.HK、10-K 字样)。覆盖率通常在 95% 以上,但港股格式公司自定,判不准的一定存在,必须留人工校验的口子。

解析抽取是收益分化点。美股优先 XBRL,不是 PDF——SEC 强制所有上市公司提交 XBRL 数据,等于有人替你把表格结构化做好了。一个标签直接对应一条财务事实:

{
  "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax": {
    "value": 97625,
    "unit": "USD millions",
    "period": "2025-Q2",
    "company": "AAPL"
  }
}

(这是结构示例值,看个形式——一个标签、一条带单位和期间的事实。腾讯这种只在 OTC 挂 ADR 的公司不是 SEC 申报主体,吃不到 XBRL 这个红利;能吃到的公司,这条路比解析 PDF 省事太多。)

能直接拿到结构化事实,就不要走 PDF → OCR → 文本 → 表格抽取这条弯路。A 股和港股没有这个红利,老老实实走 PDF 解析(unstructured 的 hi_res 策略加表格结构推断)——那段血泪史第 2、3 篇讲过,不重复。

分层存储是四步里的核心决策。具体含义是:结构化指标进 DuckDB 一类列式库:

CREATE TABLE financial_metrics (
    stock_code     VARCHAR,   -- '002594', '1211.HK', 'BYDDF'
    market         VARCHAR,   -- 'A', 'HK', 'US'
    fiscal_year    INTEGER,
    report_type    VARCHAR,   -- 'annual', 'interim', 'quarterly'
    metric_name    VARCHAR,   -- 统一后的字段名
    metric_value   DOUBLE,
    currency       VARCHAR,
    accounting_std VARCHAR,   -- 'CAS', 'IFRS', 'US_GAAP'
    PRIMARY KEY (stock_code, fiscal_year, report_type, metric_name)
);

这个主键本身就是一句声明:一家公司、一个财年、一个报告期、一个指标,只许有一行。

叙述性文本——管理层讨论、风险因素、业务描述——进向量库,配 hybrid 检索(向量 + BM25 + metadata 过滤),payload 里带上公司、股票代码、市场、财年、报告类型、section、语言这些字段。

代价也要说在前面:两套存储就是两条写入路径,每次入库要维护一致性,解析和映射的工程量都前置了。这笔账划不划算,收尾统一算。

还有一条当时立下的规矩,原话照抄:不要把表格数字放进向量库,让它做算术。

三、统一 Schema:共同字段做锚点,市场字段各自扩展

Schema 分两层,别混。

指标层,一张映射表把同一指标的三地别名和 XBRL tag 收到统一字段下。贴 revenue 一条就够:

UNIFIED_SCHEMA = {
    "revenue": {
        "A":  ["营业收入", "营业总收入"],
        "HK": ["Revenue", "Turnover", "收入"],
        "US": ["Revenues", "Total Revenue", "Sales"],
        "xbrl_tag": "us-gaap:Revenues",
        "unit": "currency",
    },
    "gross_margin": {
        "formula": "(revenue - cost_of_revenue) / revenue",
        "unit": "percentage",
    },
}

映射时的做法是按市场取候选别名逐个试,命中之后除了存统一字段名和值,还要把 original_label(命中的是哪个别名)和 original_currency 一起留痕——将来对不上账,全靠这两个字段往回查。

注意 gross_margin 这类比率指标不存原始值、存公式,用到时再算。比率要是在入库时算死,将来换一个口径就得重刷全库。还有一个坑顺手提一句:表里的“净利润“不要图省事直接并进 net_income,“归属于母公司股东的净利润“是另一个口径,这类陷阱第 7 篇专门讲过,映射表建的时候就要分开。

metadata 层,公共字段是跨市场对比的锚点,所有市场必须标齐:

{
  "stock_code": "0700.HK",
  "stock_name": "腾讯控股",
  "fiscal_year": 2025,
  "period_type": "FY",
  "currency": "HKD",
  "accounting_standard": "IFRS"
}

市场专属字段保证单市场查询的过滤粒度:港股标 is_audited(中期报告未必经审计),A 股标 disclosure_type(业绩预告和正式年报不是一类东西)。原则就这么定:公共字段求齐,专属字段求全。

四、货币和单位归一化:琐碎,但它是可比性的地基

港股百万元港币,A 股亿元人民币,美股 millions 美元。三个数字摆在一起,不能直接比。不显式记录的话,LLM 不知道 200 后面跟的是百万还是亿,它会默认输出一个“看起来合理但实际错误“的答案——这比答不上来更糟。

做法三句话:

  • 原始币种和单位进 metadata,原值永远保留;
  • 归一化发生在返回 LLM 做比较之前,不在入库时抹掉原值;
  • 归一到 query 指定的币种;query 没指定,就归一到该市场最常用的币种。

补一句边界:归一化解决的是“能比“,解决不了“该比“。CAS 和 IFRS 下同一个“营业利润“口径并不完全一样,这种差异汇率换算抹不掉——那是第 7 篇讲口径陷阱时展开过的事,这里不重复。

五、查询路由:问题类型决定查询路径

存的时候分开了,查的时候必须知道去哪查。路由层拿到 query 先解析四样要素:股票代码(“700”“腾讯”“0700.HK“要归一到同一个主体)、市场、时间、指标名,然后按问题类型分路。三类问题,三条路:

  • 精确指标:“比亚迪 2024 年营收” → SQL 直查结构化层,数字连同币种、口径一起返回;
  • 跨公司对比:“比亚迪和特斯拉的毛利率对比” → SQL 取数,LLM 只负责组织对比的语言,不让它背数字;
  • 叙述性检索:“比亚迪的风险因素有哪些” → hybrid 检索 + metadata 过滤,走向量层。

query 没指明市场的情况也得有个默认行为:优先返回用户常关注的市场,同时告知其他市场的数据可用——不要替用户做沉默的选择。

路由的核心就一层判断:问题类型决定查询路径,不是数据源决定用户能问什么。开场那件“50 家半导体公司“的事,病根就在路径错了——拿向量检索去干 SQL 的活,答案当然流畅、错误、不可验证。

还有一层本篇不展开:SQL 查到的数字怎么回向量层的原文段落验证,向量命中的段落怎么对上结构化事实。这层互相回查值得单独一篇,先标记在这。

收尾:复杂度花在哪

一套暴力向量库,把复杂度省在建设期、全部摊到查询期——每次回答都可能错得很难看,而且错得没法查。一套 Schema 多个库,把复杂度花在建库期:映射表、归一化、路由,样样都是 upfront 的活;换来的是查询期的可验证——每个数字有出处、有币种、有口径。

落地顺序压成一句:先做 metadata 层和解析路径,再建统一事实表(先映射约 50 个核心指标),然后 hybrid RAG 管叙述段落,agent 最后再说。最终判断不变:先做“结构化财务库 + Hybrid RAG“,不要先做纯向量库。

架构定了,被一路推迟的问题也浮了上来:三地三种语言,embedding 不能只用“默认的那个“。第一节只点了多语言编码偏移的现象,怎么选模型、怎么在自己的财报数据上验证选型,是下一篇《Embedding 模型选型:从 ada-002 到 Qwen3-Embedding》的事。