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 股 | 港股 | 美股 |
|---|---|---|---|
| 语言 | 简体中文 | 中英双语 | 英文 |
| 会计准则 | CAS | HKFRS / IFRS | US GAAP |
| 币种 | CNY | HKD | USD |
| 报告类型 | 年报、半年报、季报 | 年报、中期报告 | 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》的事。