从 Excel 工作簿到可检索数据集:用 xerj autoindex 索引整个 .xlsx 文件夹

原创2026-10-17 17:05:121,612 阅读
文章标签:人工智能搜索引擎本地部署后端MCP 服务

从 Excel 工作簿到可检索数据集:用 xerj autoindex 索引整个 .xlsx 文件夹

阅读一份 Excel 财务文件夹,从中搜索某一行数字、某个日期区间或某个区域的记录,是很多团队在本地数据检索中最常见也最繁琐的场景。本文基于开源项目 xerj 的 xerj autoindex 功能,讲解如何把一整个 .xlsx 工作簿文件夹变成可按行检索、按字段聚合的索引数据集:每条记录按工作表的分组、按表头命名字段、数字/布尔/日期保留原生类型,并支持合并单元格、多层表头、pandas MultiIndex 导出等真实文件形态。读完本文,你将掌握索引命令、底层抽取原理、类型推断与已知边界,并能直接在本地落地一套"零配置"的 Excel 检索方案。

一句话操作:xerj autoindex 指向文件夹

在单节点安装上,把 xerj autoindex 指向目标目录即可完成索引。以下命令来自 good_xlsx_answer.md 的实战骨架:

xerj autoindex ./finance

它的效果是:遍历 ./finance 目录,对每个文件做类型识别(sniffing),把 .xlsx 交给专门的抽取器,把结果写入索引。整个过程"零配置"——不需要预先声明字段、不需要编写映射、不需要指定哪些文件是表格。入口逻辑位于 lib.rs:run_cli() 解析命令行后分派到 run_index,而文件的类型识别与抽取由 extract/mod.rs 中的 Family::Xlsx => xlsx::extract(...) 完成。

索引完成后,你可以用 Elasticsearch 兼容的查询 API 在同一台机器上检索:

# 以默认前缀 ax 为例
curl 'http://localhost:9200/ax-*/_search?q=Region:EMEA'

端点、认证与常用参数均可在命令行指定(完整帮助见 cli.rs):

  • --url <U>:ES 兼容端点,默认 http://localhost:9200;
  • --api-key <K>:Authorization 头,也可用环境变量 XERJ_API_KEY;
  • --prefix <P>:索引前缀,默认 ax;
  • --sample <N>:每个文件用于类型推断的采样记录数,默认 500;
  • --dry-run:只走"遍历 + 类型识别 + 推断"阶段,打印计划而不写入任何索引;
  • --max-minutes <N>:第一阶段实测估算超时则停下询问,默认 10,0 表示不询问。

每个工作表是一个独立数据集

.xlsx 不是"一个文件一张表",而是"一个文件多个工作表"。xerj 的抽取器把每个 worksheet 当作一个独立的数据集(dataset),这与 SQLite 中"每个表是一个数据集"的记录模型一致。

底层结构定义在 extract/mod.rs 的 RawRecord:每条记录都带 group(子数据集分组,此处即工作表名)、locator(定位符)与 origin(字段来源)。在 xlsx.rs 中可以看到,工作表内表头下的每一行数据都会生成一条记录:

  • group 为工作表名(例如 Sales),因此跨工作表的数据可以按 group 过滤或聚合;
  • locator 形如 Sales!r4,直接携带 Excel 自身的行号,方便反查原文件;
  • 字段名来自表头单元格;表头没有覆盖到的列(例如表头留空、但数据行有值的列),按列字母命名为 col_D、col_G 之类的形式。

工作表在索引中的顺序遵循工作簿的 Tab 顺序(来自 xl/workbook.xml 的 <sheet> 列表),而不是内部 part 的文件名顺序——测试 each_worksheet_is_its_own_dataset_in_tab_order 在 xlsx.rs 中验证了这一点:即使 part 名编号混乱(rId1 指向 sheet2.xml),数据集的先后与命名仍以工作簿声明的 tab 顺序为准;图表工作表(chartsheet)会被跳过。

数字、布尔、日期:字段保留原生类型

这是 Excel 检索区别于"把单元格全部转成文本"的关键点。抽取器读取的是 Excel 存储的值(stored values)而不是显示值,类型由单元格本身决定:

  • 数字:整数存为整数(1250),小数存为浮点(980.5);
  • 布尔:存为 true / false,而不是文本 "TRUE";
  • 日期:序列号(serial number)按单元格的数字格式还原为 ISO-8601 字符串——例如显示格式为日期时,序列号 45839 会被还原为 2025-07-01,而不是把 45839 当作普通数字索引。

日期还原的逻辑集中在 serial_to_iso(xlsx.rs):

  • 午夜整点输出 2025-07-01,带时刻输出 2025-07-01T12:00:00,带毫秒输出 2025-07-01T00:00:00.123;
  • 兼容 Excel 的 1900 闰年 bug:序列号 60 在 1900 系统中对应 1900-02-29,这是一个从未存在过的日子,因此返回 None 而不是错误日期(测试见 xlsx.rs);
  • 支持 1904 日期系统(旧版 Mac Excel 的 date1904 标志),1904 系统中同一序列号会还原为比 1900 系统晚 1462 天的日期。

"日期还是数字"由单元格样式(cellXfs 的 numFmtId)决定:内置格式(ECMA-376 的日期/时间编号)与自定义格式代码(如 yyyy-mm-dd、m/d/yy h:mm)都会被识别;而百分比(0.00%)、时长(<a href="https://link.gitcode.com/i/dbe05cac803522d1ebd6e25b5b20e38f" target="_blank">h]:mm:ss)等格式不会触发日期还原——显示为 21% 的单元格,存储值就是 0.21。这一行为由测试 the_cell_format_decides_between_a_date_a_time_and_a_number 与 format_codes_are_classified_by_their_date_and_time_tokens([xlsx.rs)锁定。

类型保留的价值在于:日期字段可以参与范围查询、排序与 date 聚合;数字字段可以参与数值比较。此外,xerj 的字段推断(sampling inference)会据此把日期字段声明为 date 类型并记录真实的 min/max 范围,为 /_ask 这类"按字段条件提问"的能力提供依据。

表头识别:标题行、无表头表单与重复表头

真实世界的 Excel 文件远不是"第一行就是表头"。抽取器在每张表的前 HEADER_SCAN_ROWS(20 行)非空行内寻找表头行(find_header,xlsx.rs),判定标准是:

  • 至少两个单元格;
  • 全部为文本或日期(不允许数字/布尔混入);
  • 至少含一个文本单元格;
  • 若紧随其后还有一行"更宽"的同类候选行,则当前行被认为是标题行(如 Prepared by | Finance)而被跳过,真正的表头在其下一行。

三种典型形态的处理:

  1. 报告标题行:表头上方的 Q3 Sales Report 这类行不属于表格,不会被索引为记录;
  2. 无表头的表单/键值假设页:如果在扫描窗口内找不到合格表头(比如 Discount rate | 0.08、Owner | Finance team 这种键值页),整张表改按"文档"索引——每行一个单元格文本、用 | 连接成一行文本,行与行之间按换行组织,空行视为段落分隔。测试 a_sheet_without_a_header_is_indexed_as_a_document(xlsx.rs)验证了这种"表格 → 文档"的降级路径;
  3. 重复表头:某些导出工具会在每页分页处重复表头行,抽取器通过比对当前行是否与已记录的表头行完全一致来跳过重复表头(row_text == t.header),避免产生空记录。header_names_are_sanitized_deduplicated_and_unheaded_columns_named_by_letter(xlsx.rs)同时验证了:表头字段名会被清理(Unit price ($) → Unit_price)、重名列自动去重(id、id_2)、无表头列按字母命名(col_D、col_G)。

合并单元格与多层表头

两个常见的真实文件形态被专门处理:

纵向合并(fill-down)。pandas 的 MultiIndex 导出、分类标签下面挂一组明细行时,单元格只在合并区域的顶行存值。抽取器读取 <mergeCells> 列表,把顶行的值"填下"到合并覆盖的每一行——于是 region: East 能匹配 East 的所有行,terms 聚合也能数清它们(FillDown 实现于 xlsx.rs)。值得注意的细节:只填"纵向"合并,横向合并(标题横跨整表宽度)不展开;合并区域中没有真实数据行的行不会被凭空造出来;若合并区域顶行为空,则没有值可填。

横向合并与多层表头。表头上方若有一行"分组标签"(如 Q1 合并覆盖 Jan | Feb),抽取器把两层名字拼成 Q1_Jan、Q1_Feb,而不是退化成 Jan、Jan_2(group_prefixes,xlsx.rs)。判定条件很严格:分组行必须紧贴表头上方、只含文本、至少有一个合并覆盖两个及以上表头列、且不能合并覆盖所有表头列(覆盖所有列的 Sales 是表标题,不是分组标签)。测试 group_labels_merged_above_the_header_prefix_the_columns_they_span(xlsx.rs)覆盖了这一行为。

合并列表的读取也有性能与正确性考量:<mergeCells> 位于 <sheetData> 之后,所以抽取器先用字节搜索定位该标签、只对尾部做 XML 解析(scan_merges,xlsx.rs),避免把紧随其后的 <hyperlink ref=...> 误判为合并;在采样阶段(只读几百行)对大表跳过 merge 扫描以省去约 10% 的全量抽取开销,但采样与全量运行对同一列必须给出相同的字段名(测试 a_column_is_named_the_same_by_the_sampling_and_the_full_run 锁定)。

公式、富文本与错误值

抽取器读取的是公式的缓存值(cached value),而不是重新计算:

  • SUM(B3:B4) 且缓存值为 3 → 字段值为数字 3;
  • 无缓存值的公式(如未保存的 NOW())→ 该字段缺席;
  • 字符串公式结果(t="str")→ 文本;
  • 错误值(#DIV/0!、#N/A)→ 该字段被丢弃;
  • 富文本(rich-text runs)的多个片段会拼接(Net + income → Net income),而注音符号(rPh,如日文假名)不进入值。

这些行为由 rich_text_entities_formulas_and_errors 测试(xlsx.rs)验证。

已知边界与格式兼容性

文档明确列出的限制(见 xlsx.rs 的模块注释):

  1. 一个工作表只当作一张表:同一张表下方若还有第二张表,会作为第一张表的表头下的数据行被读入;
  2. 三层及以上深度的表头只保留最下面两层;
  3. 超大工作表(解压后超过 MAX_SAMPLE_MERGE_SCAN_BYTES,64 MiB)的采样运行不做纵向合并填充;
  4. 取存储值而非显示值:显示为 21% 的单元格存储值就是 0.21;
  5. 图表工作表被跳过,但隐藏工作表会被索引。

格式兼容性方面,抽取器处理的是 .xlsx(OOXML 的 ZIP 容器)。对于同样常见但 xerj 尚未内置抽取器的格式,类型识别层(sniff.rs)会给出明确的"先转换再索引"提示:

  • 传统 .xls(Excel 97-2003,BIFF8 装在 OLE2 复合文件里)→ 先另存为 .xlsx;
  • .xlsb(Excel 二进制工作簿,xl/workbook.bin)→ 另存为 .xlsx;
  • .ods(OpenDocument 电子表格)→ 另存为 .xlsx。

文件是否被识别为工作簿,由容器成员与内部流名决定(sniff.rs):含 xl/workbook.xml 的 ZIP 是 .xlsx,含 xl/workbook.bin 的是 .xlsb,OLE2 容器内出现 Workbook/Book 流则是旧版 .xls。这也解释了文档中"legacy .xls file must be saved as .xlsx first"这一建议的底层来源——xerj 只索引它能可靠读取的格式,识别不出读取器的格式会得到明确的转换指引,而不是被静默跳过。

从索引到提问:一个完整的最小示例

把上述行为串成一个可复现的流程:

# 1. 索引一个包含多工作簿的目录
xerj autoindex ./finance

# 2. 查看索引后发现的数据集(地图)
xerj autoindex map --url http://localhost:9200

# 3. 按工作表分组检索
curl 'http://localhost:9200/ax-*/_search' \
  -H 'Content-Type: application/json' \
  -d '{"query": {"term": {"group": "Sales"}}}'

# 4. 对日期字段做范围查询(日期已还原为 ISO-8601)
curl 'http://localhost:9200/ax-*/_search' \
  -H 'Content-Type: application/json' \
  -d '{"query": {"range": {"Month": {"gte": "2025-01-01", "lte": "2025-12-31"}}}}'

# 5. 按区域聚合
curl 'http://localhost:9200/ax-*/_search' \
  -H 'Content-Type: application/json' \
  -d '{"aggs": {"by_region": {"terms": {"field": "Region"}}}}'

其中第 2 步的 autoindex map 会把每个文件、每个工作表的字段与类型都打印出来,是核对"日期到底有没有被推断成 date"的最直接手段;第 4、5 步之所以可行,正是因为抽取阶段保留了字段的原生类型。整个流程零配置、无需编写任何 mapping——这正是 xerj autoindex 在本地 Excel 检索场景下的核心价值。

登录后查看全文
xerj