解药不甜SM

本节目标:把 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.companyopts.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 道裂缝——那就是后面整门课的地图。

1.3 最小问答

课件下载

本节暂无配套课件