
Nhost 仓库中 zapx v13 的 ZAP 索引文件格式解析全文搜索 Segment 的磁盘布局、写入路径与读取路径【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost本文以 Nhost 仓库中内置vendored的zapx/v13模块说明文档为主线完整讲解 ZAPZipped 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.goconst Version uint32 13并经由 plugin.go 的ZapPlugin.Version()对外暴露标记“未做 doc values 反转”的哨兵值const fieldNotUninverted math.MaxUint64同样定义在 build.go后文 doc values 索引区会用到它。这说明本目录下的实现只读写 v13 版本的 ZAP 文件打开文件时若 footer 中的版本号不等于 13 会直接报错见后文loadConfig。二、文件总体布局逆序写入 尾部固定 FooterREADME 第一段给出了整个格式最重要的两个设计决策文件按“典型访问顺序的逆序”写入。这样可以在一遍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处的 crcbig-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 uint64stored field index 位置big-endian uint64field index 位置big-endian uint64field docValue 位置big-endian uint64chunk factorbig-endian uint32versionbig-endian uint32之前所有内容的 CRC-32big-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 步字段名 → idloadFields()在打开文件时segment.go一次性遍历 fields index建立fieldsMap字段名 → fieldID1与fieldsInvfieldID → 字段名两张表。注意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” 的落点。第 46 步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 iduint16field typebyte未压缩数据切片中该值的起始偏移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 IndexREADME 说明每个文档对应一个 big-endian uint64 的 stored data 起始偏移有了这个索引和文档号即可直达存储字段数据。读取端的核心代码只有五行read.goindexOffset : s.storedIndexOffset (8 * docNum) storedOffset : binary.BigEndian.Uint64(s.mem[indexOffset : indexOffset8]) // 接着连续解出 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 frequencyuint64 norm 因子float32写入阶段记住本 posting list details 起始位置写后续 chunk 数量varint uint64写每个 chunk 的长度各 varint uint64写包含全部 chunk 数据的字节切片。posting details (location) section——结构相同只是每个 hit 编码的内容不同fielduint16、field posuint64、field startuint64、field enduint64、数组位置个数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 模式恒为 1024doc values 至今仍用它 var LegacyChunkMode uint32 1024 // DefaultChunkMode 最新改进的 chunk 模式应默认使用 var DefaultChunkMode uint32 1025getChunkSize()的逻辑chunkMode 1024固定按该值分块legacy 行为chunkMode 1025默认若该 posting list 的基数 ≤ 1024则整个列表放入一个 chunkreturn 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.Loadsegment.go。FSTFinite State Transducer是前缀压缩的有向无环结构使得字典查找、前缀枚举等“纯字典操作”都无需展开整个词表——这正对应 README 主路径第 2 步中 “some operations stop here and do dictionary ops”。八、Fields 区与 Fields Index唯一“无长度”的区域README 最后两个结构性小节fields section——per field记住起始偏移写 dictionary addressvarint 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 indexfooter 紧随其后。九、Fields DocValue 区列式存储与 DocValues IndexREADME “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 modechunk.go 的LegacyChunkMode注释也确认了这一分工未启用 doc values 的字段其 DocValues Index 中的 start/end 对被写为fieldNotUninvertedmath.MaxUint64哨兵值读取端loadDvReaders()segment.go遇到docValueOffset fieldNotUninverted或空文档集时直接跳过否则逐字段读 varint 对并建立fieldDvReaders缓存。十、写入全流程串联一遍写盘的逆序编排把 README 各小节按 new.go 的convert()串起来就是 ZAP 文件的完整生产流水线字段表_id固定为 fieldID 0其余字段按名字排序分配 fieldIDsort.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 indexpersistFooter()写 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),仅供参考