本节目标:把 1.1 的六步流水线真正跑起来。
三步代码走通「解析入库 → 检索 → 生成」,最后问出一个带页码的真实答案。
所有代码都来自配套仓库
fin-rag,文中标了文件路径,可对照阅读。
一、数据契约
动手之前,先定下入库的最小单元长什么样。
这个结构一旦定死,后面 Rerank、Agent、评估全都建在它上面,不再改:
// src/types/chunk.ts
interface ChunkMetadata {
company: string; // 公司,如 "maotai" —— 用于过滤
industry?: string; // 行业,如 "baijiu"
period: string; // 报告期,如 "2024"
sourcePage: number; // 原文页码 —— 用于引用溯源
isTable: boolean; // 是否表格块
}
注意两个字段:sourcePage 让答案能溯源,isTable 标记哪些块是表格(表格不能乱切)。
从第一行代码起,就为「可追溯」和「表格处理」埋好点。
这是金融 RAG 的命门。
二、解析入库
这一步离线做一次。
把 data/raw/ 下的 PDF 逐个走一遍:解析 → 分块 → 嵌入 → 写入向量库。
核心逻辑(src/ingest/index.ts,已精简):
async function ingestFile(file: string) {
const { company, year } = parseName(file); // 从文件名取公司/年份
const pages = await parsePdf(path.join(RAW_DIR, file)); // ① 调 MinerU 解析
const texts: string[] = [];
const metas: Array<ChunkMetadata & { text: string }> = [];
for (const pg of pages) {
for (const block of pg.blocks) {
// ② 分块:表格不切碎,正文递归切;每块都带上页码和"是否表格"
for (const text of await chunkBlock(block.text, block.isTable)) {
texts.push(text);
metas.push({ text, company, industry: INDUSTRY[company],
period: year, sourcePage: pg.page, isTable: block.isTable });
}
}
}
const vectors = await embedTexts(texts); // ③ 批量嵌入
const ids = metas.map((_, i) => `${company}_${year}_${i}`);
await store.upsert({ indexName: INDEX_NAME, vectors, metadata: metas, ids }); // ④ 入库
}
四个动作对应四个文件,各管一段、互不耦合:
| 动作 | 函数 | 文件 |
|---|---|---|
| ① 解析 | parsePdf() | src/lib/parser.ts(经 HTTP 调 MinerU) |
| ② 分块 | chunkBlock() | src/lib/chunk.ts |
| ③ 嵌入 | embedTexts() | src/lib/embedding.ts |
| ④ 入库 | store.upsert() | src/lib/store.ts |
两个真实的坑
这里藏着两个坑,都是后面章节的引子。
坑 1:表格不能乱切。 财务表格被随机切断,「营业收入」和它对应的数字会被劈到两个块里,语义就废了。所以 chunkBlock 对表格和正文区别对待——表格块先整体保留,只有正文才走递归切分(MDocument 递归分块,overlap=40)。
坑 2:嵌入有长度上限。 BGE-large-zh 单条最多约 512 token,超了会被端点拒绝(413 错误)。所以最后用 hardSplit 给每块兜底硬切到 MAX_CHARS=400 字以内,包括那些很长的表格。
这两个坑在 MVP 里都用最朴素的办法先绕过去(整表保留 + 硬切),做得还不够好。
正是第 2 章「数据层」要深入的「结构感知分块」。
chunkBlock 的完整实现与局限,留到 2.2 拆开细讲。
跑起来:
npm run ingest
你会看到类似输出:
✓ maotai_2023.pdf: 2962 chunks
✓ maotai_2024.pdf: 3122 chunks
...
入库完成
这是 MVP 版分块器的产物。
第 2 章会把分块升级成「表格结构感知切分」,同样这 6 份年报会切出更多块(约 25,159 个)。
在最新代码上跑到的就是升级后的数字,不必和这里逐一对上——数字变大,恰恰说明分块策略生效了。
三、检索
这一步每次提问走。
把问题嵌入成向量,在库里找最相关的 Top-K,同时用元数据过滤限定公司 / 年份(src/retrieve/index.ts):
export async function retrieve(question: string, opts: RetrieveOpts = {}) {
const queryVector = await embedQuery(question); // 问题 → 向量
// 元数据过滤:只在指定公司/报告期里检索,避免"茅台的问题检索到五粮液"
const conditions: Record<string, string> = {};
if (opts.company) conditions.company = opts.company;
if (opts.period) conditions.period = opts.period;
const res = await store.query({
indexName: INDEX_NAME,
queryVector,
topK: opts.topK ?? 5,
filter: conditions, // 向量相似度 + WHERE 过滤,一把梭
});
return res.map((r) => ({ text: r.metadata.text, score: r.score, meta: r.metadata }));
}
你可能会问:opts.company、opts.period 哪来的?
MVP 里加了一个最朴素的检索前路由(src/retrieve/parse-query.ts)——从问题文本里用关键词抠出公司、用正则抠出年份。
问「贵州茅台2024…」就自动带上 { company: 'maotai', period: '2024' } 去过滤。
这一步很简单,但它把「问茅台串到五粮液」这种低级错误挡在了门外。
更聪明的路由,留到第 4 章。
向量库的关键能力,是把相似度检索和条件过滤在一次查询里完成。
这正是选 Postgres + pgvector(本课用 Supabase 托管)的原因——一套库同时干「找语义相近」和「筛公司 / 年份」两件事。
四、生成
把检索到的片段拼成上下文,塞进一个约束很强的 prompt,逼模型只依据资料作答、并标页码(src/generate/index.ts):
function buildPrompt(question: string, chunks: RetrievedChunk[]): string {
const context = chunks
.map((c, i) => `【片段${i + 1}|${cn(c.meta.company)}·${c.meta.period}年报·第${c.meta.sourcePage}页】\n${c.text}`)
.join('\n\n');
return [
'你是严谨的财报分析助手。只依据下面的【上下文片段】回答问题。',
'要求:',
'1) 答案末尾标注引用,格式 (公司·年份年报·P页码);',
'2) 若上下文不足以回答,明确回答"未在所给资料中检索到",绝不编造数字。',
`# 上下文片段\n${context}`,
`# 问题\n${question}`,
].join('\n');
}
这个 prompt 做了两件防幻觉的事:
- 只准用给定片段;
- 查不到就老实说查不到。
这是金融场景最低限度的安全带。
五、问一个真实问题
npm run query -- "贵州茅台2024年的营业总收入和归母净利润分别是多少?"
真实输出(原样贴的,你在自己机器上能复现):
=== 检索过滤 ===
公司=贵州茅台 报告期=2024
=== 回答 ===
根据上下文,贵州茅台2024年营业总收入为 1,741.44 亿元,
归属于上市公司股东的净利润为 862.28 亿元。(贵州茅台·2024年报·P8)
=== 命中来源 ===
- 贵州茅台·2024·P1 (score 0.751)
- 贵州茅台·2024·P56 (score 0.735)
- 贵州茅台·2024·P55 (score 0.717)
- 贵州茅台·2024·P14 (score 0.710)
- 贵州茅台·2024·P14 (score 0.709)
- 贵州茅台·2024·P8 (score 0.707)
翻到茅台 2024 年报第 8 页(主要会计数据表)一核对——数字完全一致。
答案标了页码,你能翻回原文验证。
命中来源清一色是茅台 2024,没有串进五粮液或别的年份——这要归功于上一步那个朴素的检索前路由。
这里还有一个财报小细节值得说。
系统答的是「营业总收入 1,741.44 亿」。
如果你只问「营业收入」,它会给你 1,708.99 亿(P56)——两者在财报里是不同科目(总收入还含利息、手续费等收入)。
系统是照着你检索到的那页如实作答的。
这种「问得多精确、答得多精确」的特性,后面评估章会专门量化。
整条链路跑通了:
六、验收
对照这几条,全勾上才叫「跑通」:
- 问「X 公司 X 年营收」,答对且给页码
- 问一个不在文档里的问题,系统说「未检索到」,不胡编
- 加元数据过滤后,跨公司串味的检索消失
- 整条链路一条命令可复跑(为后续 A/B 与评估铺路)
小结
1、三步走通最小链路
解析入库(离线一次) → 检索(带元数据过滤) → 生成(防幻觉 + 标页码)。
2、每步一个独立小文件
高内聚、低耦合,方便后面单独替换 / 增强。
3、已经踩到两个真实坑
表格不能乱切、嵌入有长度上限——MVP 里先用最朴素的办法绕过去了。
4、答案数字正确 + 可溯源
这就是金融 RAG 的及格线。
但这个最小系统远不够好。
下一节,我们故意去「挑刺」,看看它的 5 道裂缝——那就是后面整门课的地图。