Nhost 仓库中 zapx v13 的 ZAP 索引文件格式解析:全文搜索 Segment 的磁盘布局、写入路径与读取路径
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
本文以 Nhost 仓库中内置(vendored)的zapx/v13模块说明文档为主线,完整讲解 ZAP(Zipped Inverted Postings)索引文件的二进制布局:文件从后往前的分区顺序、固定 44 字节的 footer、stored fields/postings/dictionary/doc values 各区的编码细节,以及 mmap 打开后“字段名 → FST 字典 → 倒排列表”的读取主路径。读完本文,你将能够独立理解一个 ZAP segment 文件的每个字节区域是如何写入与被定位的,并能在源码层面印证这些格式约定。
一、zapx v13 是什么,它在 Nhost 仓库中的位置
zapx 模块说明开篇交代了该模块的身份:zapx 是 blevesearch 的 zap 模块的 fork,保持文件格式完全兼容,但移除了对 bleve 主库的依赖,只依赖两个独立接口模块bleve_index_api与scorch_segment_api。在 Nhost 仓库中,它以第三方依赖的形式完整内置在vendor/github.com/blevesearch/zapx/v13/目录下,属于 Go 侧工具链的全文索引基础设施:ZAP 格式是 scorch 索引引擎持久化单个 segment 的磁盘表示,也是本文档(README.md 与 zap.md)所描述的主体对象。
从源码可以确认该版本的两个关键常量:
- 文件版本号为 13,定义于 build.go:
const Version uint32 = 13,并经由 plugin.go 的ZapPlugin.Version()对外暴露; - 标记“未做 doc values 反转”的哨兵值
const fieldNotUninverted = math.MaxUint64(同样定义在 build.go),后文 doc values 索引区会用到它。
这说明本目录下的实现只读写 v13 版本的 ZAP 文件;打开文件时若 footer 中的版本号不等于 13 会直接报错(见后文loadConfig)。
二、文件总体布局:逆序写入 + 尾部固定 Footer
README 第一段给出了整个格式最重要的两个设计决策:
文件按“典型访问顺序的逆序”写入。这样可以在一遍(one pass)内完成写入,因为文件后段的内容需要引用前段已经写下的偏移量。
整个文件通过 mmap 读取,crc-32 与 version 位于文件尾部的固定位置,footer 其余部分可随版本变化。
zap.md 的 Overview 图给出了完整的分区总览,从上到下依次为:
|--------------------------------------------------| | Stored Fields (原始存储字段数据) | |--------------------------------------------------| | Stored Fields Index (存储字段索引) | ← 偏移 SF |--------------------------------------------------| | Dictionaries + Postings + DocValues | |--------------------------------------------------| | DocValues Index ← 偏移 FDV |--------------------------------------------------| | Fields (字段表:字典地址 + 字段名) | |--------------------------------------------------| | Fields Index ← 偏移 F |==================================================| | D# | SF | F | FDV | CF | V | CC | (Footer) | |==================================================|其中D#是文档数,SF/F/FDV是三个关键索引区偏移,CF是 chunk factor(块因子),V是版本,CC是 CRC32。
这一布局在源码中的常量定义非常精确——write.go:
// FooterSize is the size of the footer record in bytes // crc + ver + chunk + field offset + stored offset + num docs + docValueOffset const FooterSize = 4 + 4 + 4 + 8 + 8 + 8 + 8即 footer 恒为44 字节。segment.go 的loadConfig()展示了打开文件时如何从尾部逆序解析出全部配置:先读len(mm)-4处的 crc(big-endian uint32),再依次向前读 version、chunkMode(各 uint32)、docValueOffset、fieldsIndexOffset、storedIndexOffset、numDocs(各 big-endian uint64),并在version != Version时返回unsupported version错误。随后Open()将mm[0 : len(mm)-FooterSize]作为数据体切片mem——footer 从此被彻底排除在数据区之外,这与 README “crc-32 和 version 在文件尾固定位置” 的描述一一对应。
footer 的写入则由 persistFooter() 完成,写序恰好与 README “footer 小节”列出的顺序一致:
- 文档数(big-endian uint64)
- stored field index 位置(big-endian uint64)
- field index 位置(big-endian uint64)
- field docValue 位置(big-endian uint64)
- chunk factor(big-endian uint32)
- version(big-endian uint32)
- 之前所有内容的 CRC-32(big-endian uint32)
注意persistFooter接收一个crcBeforeFooter参数并预置进 CRC 计数器——即CRC 覆盖“footer 之前”的全部内容,这与 README “write out file CRC of everything preceding this” 完全吻合。
三、读取主路径:字段名 → 字典 → 倒排列表
README 将“访问所有索引数据”的标准流程概括为六步,这是理解整个格式的核心心智模型:
- 先知道字段名 → 转换为字段 id;
- 导航到该字段的 term dictionary(部分操作到此为止,只做字典级操作);
- 用字典定位到某个 term 的 posting list;
- 遍历 posting list;
- 如需要,随遍历过程顺带读 posting details;
- 如需要位置信息,查询 location bitmap 判断其是否存在。
各步在源码中的对应实现:
第 1 步(字段名 → id):loadFields()在打开文件时(segment.go)一次性遍历 fields index,建立fieldsMap(字段名 → fieldID+1)与fieldsInv(fieldID → 字段名)两张表。注意fieldsMap故意存fieldID + 1,用非零值区分“字段不存在”(源码注释明确写了FieldsMap adds 1 to field id to avoid zero value issues)。
第 2 步(加载 FST 字典):dictionary()(segment.go)先用dictLocs[fieldID]取字典起始地址,再读一个 varint 得到 vellum 数据长度,随后vellum.Load(fstBytes)把 FST 完整加载进内存,并缓存在fieldFSTs中——每个字段的 FST 只从磁盘解析一次,之后常驻堆内存,正是 README “field data is processed once and memoized onto the heap so that we never have to go back to disk for it” 的落点。
第 4~6 步:posting list 的遍历与 details 定位由 posting.go 中的迭代器实现,其中还定义了几个与 v13 字典编码相关的位掩码常量(如FSTValEncoding1Hit),用于区分 FST 值中编码的“单命中”特殊 posting list。
四、Stored Fields 区:文档原始数据的压缩存储
README 的 “stored fields section” 小节定义了每个文档的写入格式,这里完整继承并展开:
准备阶段(per document):
- 生成一片 metadata 字节与一片 data 字节;字段按 field id 顺序产出;
- 每个字段值在 metadata 中记录以下 varint 序列:
- field id(uint16)
- field type(byte)
- 未压缩数据切片中该值的起始偏移(uint64)
- 字段值长度(uint64)
- 数组位置个数(uint64)
- 每个数组位置各一个值(uint64)
- 整个 data 切片用snappy压缩。
文件写入阶段(per document):
- 记住本文档的起始偏移;
- 写 metadata 长度(varint uint64);
- 写压缩后数据长度(varint uint64);
- 写 metadata 字节;
- 写压缩后的数据字节。
写入端实现在 new.go 的writeStoredFields(),其中有一个 README 未强调但源码明确存在的特殊优化:_id字段(fieldID 0)被特殊处理——它的值长度作为 metadata 的第一个 varint 写入,且其原始字节不压缩、直接放在记录开头,以优化DocID()/ExternalID()查询(源码注释:_id field special case optimizes ExternalID() lookups)。读取端visitStoredFields()(segment.go)同样先读这个 id 长度、取未压缩的 id 值交给 visitor,之后才snappy.Decode剩余部分,并逐个按 field/typ/offset/length/numap 的 varint 序列重建字段值。
Stored Fields Index:README 说明每个文档对应一个 big-endian uint64 的 stored data 起始偏移;有了这个索引和文档号即可直达存储字段数据。读取端的核心代码只有五行(read.go):
indexOffset := s.storedIndexOffset + (8 * docNum) storedOffset := binary.BigEndian.Uint64(s.mem[indexOffset : indexOffset+8]) // 接着连续解出 metaLen、dataLen 两个 varint即“索引区定位 → 记录内 meta 长度 → 数据长度 → 压缩数据”,三级跳转,与 README “access to stored data by doc number” 描述的路径完全一致。
五、Postings List 区:Roaring Bitmap 倒排列表
README 的 “postings list section” 小节:
- 准备阶段:把 roaring bitmap 倒排列表序列化成字节(以确定长度);
- 写入阶段(per posting list):
- 记住本 posting list 起始位置;
- 写 freq/norm details 偏移(varint uint64,来自先写入的 details 区);
- 写 location details 偏移(varint uint64);
- 写编码后 roaring bitmap 的长度;
- 写序列化的 roaring bitmap 数据。
写入侧对应 write.go 的writeRoaringWithLen():先r.ToBytes()序列化、PutUvarint写长度、再写 bitmap 本体;而 freq/norm 与 location 两个偏移则先于 bitmap 一起写出,因为这两个 details 区在文件中位于 postings 区之前(逆序写入的体现)。每个 term 的倒排集合本质是一组 docNum,用 RoaringBitmap 中导入的github.com/RoaringBitmap/roaring/v2表示——这也解释了为什么DocNumbers()能用OrInto(rv)把多个 id 的 posting list 直接并集成结果位图(segment.go)。
六、Posting Details 区:Freq/Norm 与 Location 的分块编码
这是 README 中最“块(chunk)”味浓厚的部分,两个小节的结构对称,完整继承如下:
posting details (freq/norm) section——per posting list:
- 准备阶段:生成一片包含多个连续 chunk 的字节切片(每个 chunk 是 varint 流);同时记录每个 chunk 起始偏移;
- 遍历 posting list 中每个 hit,当 hit 进入下一个 chunk 时,收尾上一个 chunk 的编码并记录下一个 chunk 起点;
- 每个 hit 编码:term frequency(uint64)+ norm 因子(float32);
- 写入阶段:
- 记住本 posting list details 起始位置;
- 写后续 chunk 数量(varint uint64);
- 写每个 chunk 的长度(各 varint uint64);
- 写包含全部 chunk 数据的字节切片。
posting details (location) section——结构相同,只是每个 hit 编码的内容不同:field(uint16)、field pos(uint64)、field start(uint64)、field end(uint64)、数组位置个数(uint64)、每个数组位置(各 uint64)。
两节结尾都有同一句关键结论:“若已知目标文档号,可用 docNum/chunkFactor 直接跳到正确 chunk,再在 chunk 内 seek 找到它。”这就是分块的意义——把“找某个 doc 的 term frequency/位置”的复杂度限制在“一次定位 + 最多遍历一个 chunk 大小的项”。
写入端实现在 new.go 的writeDicts():tfEncoder与locEncoder两个chunkedIntCoder按当前 chunkSize 依次Add(docNum, freq, norm)/Add(docNum, fieldID, pos, start, end, ...),最后writePostings()统一落盘。注意 freq 编码中还有个细节:encodeFreqHasLocs(freq, numLocs > 0)把“是否带位置信息”编进了 freq 值的一个标志位,使读取端无需额外元数据即可判断该 hit 是否有 location 明细。
chunkFactor 的取值规则是 v13 值得单独讲清的点。chunk.go 定义:
// LegacyChunkMode 原始 chunk 模式(恒为 1024),doc values 至今仍用它 var LegacyChunkMode uint32 = 1024 // DefaultChunkMode 最新改进的 chunk 模式,应默认使用 var DefaultChunkMode uint32 = 1025getChunkSize()的逻辑:
chunkMode <= 1024:固定按该值分块(legacy 行为);chunkMode == 1025(默认):若该 posting list 的基数 ≤ 1024,则整个列表放入一个 chunk(return maxDocs),因为反正一次遍历也不会超过 1024 项,分块纯属浪费;否则仍用 1024;- 其他取值直接报错
unknown chunk mode。
源码注释把这一“理论”讲得很直白:分块的目的只是给Next()的最大调用次数设上界——一次跳到正确 chunk,再最多遍历 chunk-size 个项。低基数 term 全部塞进单 chunk 既保留该上界又省掉分块开销。
七、Dictionary 区:Vellum FST 把 term 映射到倒排列表
README “dictionary” 小节:
- 准备阶段(per field):用 vellum FST 编码字典数据,每个 term 指向其 posting list 的文件偏移(该偏移在写 postings 区时已记住);
- 写入阶段:
- 记住本 persistDictionary 的起始位置(即后文 fields 区的 “dictionary address”);
- 写 vellum 数据长度(varint uint64);
- 写 vellum 数据本身。
写入端对应 new.go:每个字段循环结束后builder.Close(),记录dictOffsets[fieldID] = w.Count(),PutUvarint写长度、写入vellumData,随后builder.Reset复用。读取端则如前文所述,用dictStart + 长度 varint精确切出 FST 字节并vellum.Load(segment.go)。FST(Finite State Transducer)是前缀压缩的有向无环结构,使得字典查找、前缀枚举等“纯字典操作”都无需展开整个词表——这正对应 README 主路径第 2 步中 “some operations stop here and do dictionary ops”。
八、Fields 区与 Fields Index:唯一“无长度”的区域
README 最后两个结构性小节:
fields section——per field:记住起始偏移;写 dictionary address(varint uint64);写字段名长度(varint uint64);写字段名字节。
fields idx——per field:写每个字段起始偏移的 big-endian uint64。
并且 README 特别标注了一个 NOTE:
目前不记录(也不知道)fields index 的长度。我们依赖的是:它紧邻一个大小已知的 footer这一事实。
这一点在源码中体现得非常具体。loadFields()的终止条件不是读一个长度字段,而是直接拿len(s.mem)(即去掉 footer 后的数据体末尾)当上界(segment.go):
// NOTE for now we assume the fields index immediately precedes // the footer, and if this changes, need to adjust accordingly ... fieldsIndexEnd := uint64(len(s.mem)) for s.fieldsIndexOffset+(8*fieldID) < fieldsIndexEnd { ... }字段数量由此被反推出来:F# = (len(file) - len(footer) - F) / 8,这正是 zap.md “Fields” 一节给出的公式。写入端persistFields()(write.go)先逐个字段写(dictLoc + nameLen + name),再在w.Count()处写下每个字段的起始偏移构成 fields index,footer 紧随其后。
九、Fields DocValue 区:列式存储与 DocValues Index
README “fields DocValue” 小节:
- 准备阶段(per field):生成一片由多个连续 chunk 组成的字节切片,每个 chunk = meta 段 + 压缩后的列式字段数据;同时记录每个 chunk 的长度;
- 写入阶段:
- 记住第一个 field DocValue 偏移(最终写进 footer 的 FDV);
- 写后续 chunk 数量(varint uint64);
- 写每个 chunk 长度(各 varint uint64);
- 写全部 chunk 数据。
README 的 NOTE 说明:每个 chunk 内部的 meta header 是定位某个 docID 数据偏移与大小的线索,所有读操作都依靠这份 meta 信息从文件中抽取特定文档的数据。zap.md 的 DocValues 图进一步展示了 chunk 内部结构:Doc# in Chunk | Doc1 | Offset1 | ... | DocN | OffsetN | SNAPPY COMPRESSED DATA,以及 DocValues Index 本身是F#对 varint(每字段一对 start/end)。
两个源码级细节补全了 README 未展开的部分:
- doc values 至今仍使用 legacy chunk 模式(固定 1024),而非倒排区用的 DefaultChunkMode——new.go 中
getChunkSize(LegacyChunkMode, 0, 0)处有注释NOTE: doc values continue to use legacy chunk mode,chunk.go 的LegacyChunkMode注释也确认了这一分工; - 未启用 doc values 的字段,其 DocValues Index 中的 start/end 对被写为
fieldNotUninverted(math.MaxUint64哨兵值);读取端loadDvReaders()(segment.go)遇到docValueOffset == fieldNotUninverted或空文档集时直接跳过,否则逐字段读 varint 对并建立fieldDvReaders缓存。
十、写入全流程串联:一遍写盘的逆序编排
把 README 各小节按 new.go 的convert()串起来,就是 ZAP 文件的完整生产流水线:
- 字段表:
_id固定为 fieldID 0,其余字段按名字排序分配 fieldID(sort.Strings(s.FieldsInv[1:])); prepareDicts():遍历所有文档的分析结果,为每个 (field, term) 分配全局 posting list id,同时统计 freq/norms 与 locations 总数并预分配缓冲区;processDocuments():把每个文档的 docNum 加入对应 term 的 roaring bitmap,并累积interimFreqNorm{freq, norm, numLocs}与interimLoc{fieldID, pos, start, end, arrayposs}(norm 计算为1/sqrt(字段分析长度));writeStoredFields():先写全部 stored fields 数据与 stored fields index(见第四节);writeDicts():写 freq/norm details、location details、postings list、vellum 字典、doc values,最后写 DocValues Index(见第五~九节);persistFields():写 fields 区与 fields index;persistFooter():写 44 字节 footer(含全程累计的 CRC-32)。
整个过程中所有字节都通过CountHashWriter(定义于 count.go)写入——它同时承担计数偏移与计算 CRC 两个职责,这就是为什么“后段引用前段偏移”能在单遍写盘中成立:写到哪一段时,前面所有段的偏移与累计 CRC 已经就绪。而写入完成后,InitSegmentBase() 直接以内存中的字节序列构造只读SegmentBase,落盘文件与其 mmap 读回的内容格式一致,读路径(第三~九节)与写路径完全对称。
小结
zapx/v13 的说明文档以极简的“准备阶段 / 文件写入阶段”条目描述了 ZAP segment 的全部磁盘结构,而配套源码把每个条目都落到了精确的字节操作:逆序单遍写入靠CountHashWriter的偏移计数与 CRC 累计实现;44 字节 footer 是打开文件的唯一入口;“字段名 → FST 字典(内存 memoized)→ roaring bitmap 倒排列表 → 分块 details”构成读取主路径;1025 号默认 chunk 模式则用“低基数单 chunk”策略在保持遍历上界的前提下压缩了分块开销。对于需要理解(或解析)scorch 系索引引擎 segment 文件的读者,这份“README 条目 + 对应源码位置”的对照是完整的格式依据。
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考