关于 Document corpus
mdcx 将文档集转换为经核验的 Markdown,并把段落、搜索索引和来源信息打包到加密归档中。它面向需要获取带来源答案、又不想将整个语料载入上下文的 Agent 和团队。
主要功能
- 01转换文档为 Markdown 并检查覆盖率
- 02将段落、索引和来源信息合并打包
- 03结合词语匹配与密集检索搜索
- 04返回带来源位置的段落
- 05支持加密及可选签名
适用场景
- 01让 Agent 查询大型文档集
- 02将答案追溯到源文档及位置
- 03分发加密且带索引的语料
- 04检查文档集的转换覆盖率
MCP 相关
可选的 MCP 服务向 MCP 客户端提供打包后的文档集,并返回带来源信息的相关段落。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
mdcx
将文档集合转换为经过验证的 Markdown,将其与搜索索引和来源信息一起打包到单个加密归档文件中,并通过 Model Context Protocol 提供给 Agent。
目录
- 范围
- 要求
- 安装
- 快速开始
- 转换
- 打包
- 查询
- 检索模型
- 校准
- 词汇表
- 转录恢复
- 表达式
- 跨越截断处的引文
- 附件
- 日期
- 通信
- 增量打包
- 常驻转换
- MCP 服务
- 来源
- 可移植路径
- 签名
- 加密
- 包格式
- 限制
- 测试
- 贡献
- 安全
- 发布
- 作者信息
- 引用
- 许可
范围
mdcx 解决一个限制。Agent 收到有关某个文档集合的问题时,只能将文档放入其上下文窗口(该窗口容量有限,且按 token 计费),或者查询持有索引的组件,由该组件仅返回与问题相关的段落。mdcx 实现了第二种方式。
三个特性定义了最终结果:
- 经过保真度检查。 每次转换都会与原始文档可提取的文本进行比较,并按文件记录覆盖率。对于无法提取文本的文件,会标记为无法验证,而不会报告为已完成。比较所能确认的内容因引擎而异,验证一节说明了具体情况。
- 语料库是一个单独的制品。 段落、索引和来源信息都存储在一个 AES-256-GCM 文件中,其头部无需密钥即可读取,并且可以进行签名。
- 答案可引用。 每个段落都带有其来源文档和所在位置,因此答案可以引用到具体位置。
该流水线分为三个阶段。转换会尝试使用能够读取每份文档且成本最低的引擎,仅在该引擎效果不足时才升级:直接提取文本,然后执行一次恢复绘制表格的处理,再进行版面分析;对于无法提取文本的文档,则使用光学字符识别。打包会将语料库、索引以及每个段落的来源信息写入一个文件。检索会通过互惠排名融合词语匹配和稠密检索;如果语料库中没有与问题相关的内容,则会明确说明,而不是返回最相近的段落。
要求
Python 3.11 或更高版本。查询包不需要其他组件。
最低版本为 3.11,因为包本身是一个 SQLite 数据库,打开时会从解密后的字节加载,而 sqlite3 正是在该版本中新增了所需的调用。转换和跨语言检索还需要额外依赖,详见安装。
安装
查询和转换是分开的,因为两者的需求相差约两个数量级。只读取软件包的接收方无需安装 Docling 或 PyTorch。
| 命令 | 提供功能 | 大致大小 |
|---|---|---|
pip install mdcx | 查询和读取 .mdcx 软件包 | 10 MB |
pip install "mdcx[mcp]" | 以上功能及 MCP 服务 | 50 MB |
pip install "mdcx[convert]" | 文档转换 | 1.4 GB |
pip install "mdcx[tables]" | 页面上未绘制出来的表格 | 1.2 GB |
pip install "mdcx[multilingual]" | 跨语言检索 | 2.5 GB |
pip install "mdcx[all]" | 以上所有功能,包括 OCR | 4 GB |
pip install "mdcx[all-gpu]" | 以上功能,但不包含 CPU 版 onnxruntime | 4 GB |
multilingual 附加组件提供嵌入模型,首次使用时下载一次,且仅在跨语言查询时需要。
tables 附加组件适用于未用线条绘制的表格——例如电子表格截图,或依靠对齐方式排布的布局。它只读取几何信息;单元格内容仍来自文档的文本层。没有它时,这些页面会交由布局引擎处理。
配备 CUDA 设备的系统
安装 mdcx[all-gpu],而不是 mdcx[all],并单独安装 onnxruntime-gpu。
onnxruntime 和 onnxruntime-gpu 发布的是同一个模块,不能共存:最后安装的那个会生效。因此,每次升级时,固定使用 CPU 版本的附加组件都会替换掉加速版本。all-gpu 是不包含该固定版本的 all。
pip 无法声明依赖可由这两个发行包中的任意一个满足,因此仅靠声明无法解决这个问题。mdcx-convert 会在启动时检查,并在机器配有设备但运行时无法使用该设备时发出提示。单独执行此检查的命令:
python -c "import onnxruntime as o; assert 'CUDAExecutionProvider' in o.get_available_providers()"
快速开始
pip install "mdcx[convert]"
mdcx-convert --input ./Documents --output ./Documents_md
mdcx pack --output ./Documents_md --target corpus.mdcx --key "passphrase"
mdcx search corpus.mdcx "where is the storage temperature stated" --key "passphrase"
转换
mdcx-convert --input ./Documents --output ./Documents_md
输出文件夹会镜像输入目录结构,每个源文档对应一个 .md 文件,并附带本次运行的索引。
| 选项 | 效果 |
|---|---|
--max-cores N | 同时运行的进程数 |
--gpu-workers N、--cpu-workers N | 覆盖计算得出的分配比例 |
--serial | 一次处理一个文档 |
--only PATTERN、--limit N | 限制本次运行的范围 |
--force | 忽略缓存并重新转换 |
--no-docling | 仅使用原生引擎 |
--formulas | 将公式转录为 LaTeX |
--no-gpu | 不使用设备 |
--no-lossless | 不写入备份 JSON |
--no-compact | 在 Markdown 中保留转换器的框架内容 |
--no-split、--split-threshold N | 控制长文档拆分 |
--sample-pages N | 为每个文档转换分散抽取的 N 页样本 |
--timeout SECONDS | 放弃处理超出此时长的文档 |
--recycle-after N | 每处理 N 个文档后替换工作进程 |
中断的运行可以继续:已记录的工作不会重复执行,除非指定 --force。
长文档会被拆分为多个章节,分别独立转换,并生成一个索引文档,记录这些章节与原文档的对应关系。章节是从文档摘取内容后转换的,其页码标记使用文档自身的页码,而非摘取内容的页码;其前置元数据会声明 first_page 和 last_page,因此可以将文段引用回其来源页。索引文档会声明 type: document_index,且本身不包含文本——文本位于各章节中。--sample-pages 会转换分散抽取的页面样本,而非整个文档;前置元数据会同时记录抽取的页面和文档总页数,因此不会将样本误认为短文档。
设置 --timeout 是因为有些文档无法在布局分析期间完成处理。若不设限,这类文档会让工作进程一直被占用到本次运行结束。设置 --recycle-after 可定期替换工作进程,因为放弃处理文档并不会停止它启动的线程。
公式
PDF 的文本层不包含公式的结构:分数线是绘制出来的笔画,上标则是另一行上的独立文本。因此,如实提取该文本层,只会将分式呈现为一行。并不是哪里出了问题——原本就没有可供提取的结构。
--formulas 会将公式转录为 LaTeX,这意味着要读取公式的图像,而不是文本。这属于识别,而非提取:它对文档作出了另一种判断,也可能出错,而提取不会出现这类错误,因此需要显式请求,不会默认启用。它需要结构化引擎——--no-docling 会将其关闭,运行时也会说明这一点——还需要先通过 docling-tools models download-hf-repo docling-project/CodeFormulaV2 下载一次模型;如果模型缺失,程序会在开始时拒绝运行,而不是等到遇到包含公式的页面时才失败。
这并非没有代价。在这台机器上对一篇 20 页论文的测量结果:不启用时耗时 17.7 秒,启用时耗时 348.7 秒;后者转录了 56 个公式,而文本层一个也没有提取出来。它对正文毫无帮助。
转录内容会添加在单独的标题下,而不会替换原有文本。在这篇论文中,结构化引擎转录了公式,却损失了 11% 的词语;而原生提取保留了正文,却不包含公式,因此二选一就会丢失其中一项。记录会将已转录的公式数量报告为 formulas。
验证
每次转换都会与原文公开的文本进行比较。索引会记录每个文件达到的覆盖率。对于不公开任何文本的文档——例如扫描图纸——会标记为无法验证,因为不存在可用于衡量的原文文本。
参考文本由一个独立于布局和 OCR 引擎的库读取,因此对于这些引擎,比较的是对文档的两种不同读取结果。而对于原生提取,情况并非如此:参考文本和提取结果来自同一次读取,比较衡量的是后续结构化和压缩过程保留了哪些内容。参考文本只读取一次,并同时用于两者。
打包
mdcx pack --output ./Documents_md --target corpus.mdcx --key "passphrase"
| 选项 | 效果 |
|---|---|
--key、--key-file | 密码短语;参见加密 |
--multilingual | 同时索引含义 |
--focus QUESTION | 指定此软件包旨在回答的问题;可重复指定 |
--dates FILE | 提供发布日期;参见日期 |
--date-from-mtime | 回退到文件修改时间 |
--shapes | 构建转录恢复索引 |
--expressions | 索引词规则丢弃的表达 |
--quotes document|boundary | 查找跨越切分边界的引文的方式 |
--reuse PACKAGE | 重用现有软件包中的向量 |
--issuer、--signing-key | 参见签名 |
--fast | 优先速度而非体积进行压缩 |
--preset 0-9 | 压缩级别,会覆盖 --fast |
--compression lzma|zstd | 压缩正文的算法 |
--output 接受一个 Markdown 文件夹、单个文件或 .jsonl 文件;后者每行包含
一条记录,因此可以直接打包记录目录,而无需先写入磁盘。
--fast 会选择较低的压缩预设,--preset 则可直接指定预设。密封是每次写入
都要支付的固定成本:供分发的软件包只压缩一次,却会被下载很多次;而每次扩充
语料库都要重新构建的软件包,付出的是时间而非字节。级别会随压缩流一同保存,
因此任何级别写入的软件包都能由任何读取器打开,软件包的其他部分均不会改变。
--compression 用于选择压缩正文的算法。标头会记录算法名称,读取软件包时会
使用标头中指定的算法,因此现有软件包不受影响;只要读取器安装了相应模块,两种
类型的软件包都能打开。LZMA 是默认选项,无需额外安装;zstd 需要 mdcx[zstd]。
哪种算法生成的文件更小取决于材料——在一个语料库上测得 zstd 更小,在另一个
语料库上则更大——而 zstd 的回读速度始终更快,这正是服务在回答问题前需要付出
的成本。
pack 会返回 seconds_index 和 seconds_seal,而 seconds_index_by_phase
会将前者拆分为读取文件夹、切分段落、统计词项、构建形状索引和编码所花的时间。
启用 --multilingual 后,编码耗时会比其他阶段高出两个数量级;其成本取决于
段落的长度,而不是段落的数量:模型按 token 计费。
因此,规划大型语料库时要参考的指标既包括速率,也包括测量该速率时的段落 长度。在一张 6 GB 显卡上,对不同长度的 600 个段落进行编码:
| 每段词数 | 段落数/秒 |
|---|---|
| 10 | 431 |
| 20 | 196 |
| 50 | 84 |
| 90 | 52 |
| 200 | 21 |
不注明段落长度的速率毫无用处;拿专门生成的文本进行测量,衡量的只是机器而非 实际工作:消费者将用虚构的 130 字符文本测出的每秒 440 个段落,与用自己的 942 字符段落测出的每秒 25 个段落进行比较时,可能会误以为这是此库的缺陷。 针对具体语料库,只有各阶段的耗时数据才能说明原因。
查询
mdcx search corpus.mdcx "the question" --key "passphrase"
mdcx info corpus.mdcx
mdcx export corpus.mdcx --target ./restored --key "passphrase"
| 选项 | 效果 |
|---|---|
--limit N | 返回的段落数 |
--only received|sent | 限定为往来通信的一方 |
--mode auto|lexical|semantic | 选择引擎 |
--prefer recent | 对可比的答案按新旧顺序排列,最新的优先 |
info 会读取软件包标头,无需密钥:格式、发行者、创建日期、文档数和段落数、
完整性、签名、语言、日期和校准信息。
--prefer recent 会作为第三种排序方式参与排名融合,而不是对分数应用衰减。它
只改变排序,不会过滤结果:即使较旧的文档回答得更好,也仍会返回。如果两个引擎
对最佳段落意见一致,日期不会改变其排名;如果意见不一致,则由日期决定。如果
无法应用此偏好——例如没有含义索引,或答案中没有带日期的段落——回复会明确
说明,而不会在不作说明的情况下原样返回。
检索模型
词语匹配和稠密检索回答的是不同问题,因此并列保留。词法索引知道文档中包含某个词;稠密索引知道文档表达的意思与某些内容相似。不同语言的查询无法通过词语匹配找到另一种语言的文档,因为它们没有共用的词语;能够跨越这道边界的是对含义的表示。
二者通过倒数排名合并,而不是通过分数合并。BM25 分数没有上限,且取决于用于计算它的语料库;余弦值范围为 0 到 1,并非如此。两者无法相加,而归一化又会引入毫无依据的权重。排名是两种引擎都认可的指标。
文档按整体排序,而非按孤立的段落排序,因此在多处提供答案的文档不会输给只提到一次相关词语的文档。
校准
如果语料库中没有与问题相关的内容,回复会明确说明,而不是把最接近的段落当作答案。判断所用的阈值取决于语料库,并在构建软件包时测量。
绝对阈值并不适用:语料库与其能够回答的问题有多接近,取决于语料库包含的内容,因此在一个集合上设定的值,在另一个集合上可能毫无意义。因此,每个软件包都会记录自身的覆盖范围,并据此作出判断。
pack --focus "<question>" 从软件包旨在回答的问题中获取覆盖范围,而不是根据段落估算。若段落与问题不相似,这种方式更合适,例如摘要目录,其中每条记录的修辞形式都相同。可以重复此选项以提供多个问题。info 会报告使用了哪种方式。
mdcx calibrate corpus.mdcx --key "passphrase" --question "..." --question "..."
calibrate 会测量已有的软件包,不需要其源材料:测量需要向量(它们在软件包中),也需要问题(它们来自外部)。软件包会根据更改后的测量结果重新写入——文档、段落和向量均保持不变——并将来源记录为 focus-after 而非 focus,因为打包时测量和之后测量描述的是语料库在不同时间点的状态。签名的软件包需要其签名密钥。
在此测量方式出现之前构建的软件包不包含测量结果,仍按之前的阈值进行判断,不作更改。
词汇
archive.vocabulary(connection) # term frequencies, and the rule
archive.unknown_terms(connection, text) # terms this corpus has never seen
archive.unfamiliar(connection, text) # the same, with the share and a flag
索引会记录三个或更多字符的词语,以及书写系统不以空格分隔词语时的单字符词语。因此,某个词语不在表中有两种含义——语料库从未出现过该词,或者索引本来就不会记录该词——调用方按稀有度为词语加权时必须区分这两种情况,因为缺失的词语会获得最高权重。unknown_terms 会应用此规则。
除此之外,不会移除虚词。哪些词语不承载信息取决于问题,而 die casting 中的 die 或 Les Misérables 中的 les 在所检索的语言中都承载含义。mdcx 能说明的是它从未记录过哪些内容。
键经过规范化处理——统一大小写、折叠重音符号——因此,调用方若使用 search.tokenize_text 进行分词,必须先规范化,再查找词语。unknown_terms 会执行此操作。
词语索引无法跨语言检索,而含义索引可以。跨语言查询时,unknown_terms 会返回文本中的所有词语,这反映的是语料库的语言,而非其内容。unfamiliar 会报告占比和 cross_language 标志;如果无法确定问题的语言是否与语料库语言一致,请优先使用它。
两种信号及其分歧
archive.assess(connection, text) 会同时返回两者。
余弦相似度无法区分同形词的不同含义,因为多语言嵌入会将它们放在一起:代数语料可能与一道关于图着色的问题接近,并返回关于函数图像的段落。字面词汇则能区分它们,因为问题所关注的那个词并不在其中。
assess 会报告这两种信号,但不会否定任何一种。一个陌生词是否应该导致查询被拒绝,取决于查询的用途;而且某个词可能与查询关系不大。
转录恢复
光学字符识别转录错误的词,是索引中不存在的词,字面匹配也就无法返回它。该段落仍在语料中,但问题无法检索到它;回复为空,也没有错误信息说明原因。
光学识别会混淆形状相似的字符。因此,按形状对字符分组,会将误读的词与原词归入同一组。这个分组过于粗糙,无法直接用于回答,因此只作为筛选器:它提出候选项,再由编辑距离从中作出判断。
mdcx pack --output docs --target corpus.mdcx --key "..." --shapes
mdcx shapes corpus.mdcx --key "..." # for a package written without it
索引是可选的,因为它会增大软件包体积,而且只适用于经过光学识别处理的语料。
这里有三项限制。该筛选器针对的是转录错误,而不是输入错误——它的优势来自字符的形状,边界也由此而来。它提出的候选项中,有相当一部分并非转录错误,因为对于拉丁字母,其形状类别较为粗略。此外,它只会在查询中没有任何词匹配时运行,因为只要语料中包含查询里的一个词,就足以返回段落。
如果某个段落是通过不同拼写找到的,回复会在 read_as 中记录该拼写。将这样的段落呈现为字面匹配,会让人误以为文档中包含一个实际上并不存在的词。
这项技术采用字符形状编码,由 Spitz 于 20 世纪 90 年代在 Xerox 描述,并在那里应用于文档图像,以避免进行完整的识别处理。此处则将其应用于已转换的文本,以处理转换遗留的错误。
附件
indexed: false 写在文档前置元数据中的文档会保留在软件包中——经过签名和加密,并存放在同一文件中——但不会纳入索引、向量或段落计数。它适用于随语料附带、但不作为搜索文本的材料,例如证书或坐标表。
export 会原样还原该文档,MCP document 工具则可按名称返回它;由于无法通过搜索找到它,这是访问它的唯一方式。
pack 会报告贡献段落最多的文档;如果某个文档贡献了三分之一或更多的段落,也会特别说明。
日期
软件包会记录每篇作品的年代,以及确定年代的依据。
| 来源 | 含义 |
|---|---|
source | 由出版方或目录提供 |
sidecar | 由调用方提供 |
front-matter | 从文档中获取 |
mtime | 文件的修改时间,而非作品的年代 |
除非通过 --date-from-mtime 请求,否则绝不会使用 mtime,因为每个文件都有该时间,且它描述的是文件,而非作品。如果找不到可靠信息,日期就会留空。
文档中印出的版权年份不会使用:重印本标注的是印刷年份,而非版本年份。
mdcx pack --output docs --target corpus.mdcx --key "..." --dates dates.csv
侧车文件的每一行格式为 path,date[,provenance]。info 会报告有日期的文档数量及其涵盖的时间范围;回复中的每个段落都会带有 dated 和 dated_from。
往来信件
如果一个文集包含往来信件,pack 会记录每份文档来自哪一方;search --only received|sent 则会将查询限制为其中一方。分类依据输入的目录结构确定。
增量打包
mdcx pack --output ./docs --target corpus-2.mdcx --key "..." \
--multilingual --reuse corpus-1.mdcx
文本没有变化的段落,其向量也相同。--reuse 会读取现有软件包中的向量,只对新增内容进行编码。向量从使用同一密钥加密的软件包中读取;不会创建中间存储,因为通过向量可以近似还原其代表的文本。
复用时也会沿用校准结果。使用 --focus 为软件包提供问题后,这些问题会传递给基于该软件包构建的软件包,因此语料扩充后仍保留原有的校准阈值。再次传入 --focus 会覆盖继承的设置,摘要会记录是否继承了校准结果。
压缩和加密作用于整个文件,因此无论新增一份文档还是重建整个语料,耗时都相同。pack 会报告 seconds_seal,方便频繁写入的调用方决定写入频率。
数据库直接在目标文件中构建,文档逐份读入;随后以分块方式一次性完成压缩、加密和哈希。因此,写入软件包的峰值内存由数据块大小决定,而非语料大小——在一个语料及其 4 倍大的版本上测量,峰值仅上升了 2%。
常驻转换
转换引擎在每个进程中只加载一次。调用方若按每份文档启动一个进程——这是分配工作和隔离故障的常用方式——就需要为每份文档支付这笔加载开销。
from mdcx.convert import resident
with resident.warm(report=print) as convert:
for path in queue:
record = convert(path, output_root)
写入的文件与 mdcx-convert 为该文档写入的文件相同,包括章节文件:长篇 PDF 会按章节拆分为多个 Markdown 文件,放在以文档命名的文件夹中,旁边还会有一个链接到这些文件的索引 Markdown。将 split=False 可要求将文档写为单个文件。
record 是 mdcx-convert 写入的记录。文档拆分时,它是索引记录,章节记录则位于 record["chapter_records"] 下。resident.convert_documents(paths, output_root) 与生成器等效,每份文档生成一条记录;resident.cost_of_starting() 会返回耗费的秒数,便于日志报告这些数据。多个这样的进程各自分摊自己的启动开销。
将整个文件夹交给 mdcx-convert 也能分摊加载开销,但会失去队列提供的排序、逐文档处理和故障隔离能力。
表达式
词法索引由单词构成,而判定单词的规则会丢弃符号。对于散文,这样做是正确的。对于通过语句进行查询的语料,这会抹去内容:pq | b(b+p+q) 和 pq | b(b-p-q) 只差一个符号,却会被简化为相同的项;单独查询时,它们甚至会被简化为没有任何项,因此无法检索到内容。
--expressions 会将表达式与单词一同保留,并为其建立独立索引。混有字母数字字符和符号的词元即为表达式;散文中不会出现表达式。如果问题中包含语料明确写出的表达式,系统就会用它作答;如果问题中的表达式未在语料中出现,系统则不返回任何内容,而不是返回仅仅讨论相关主题的段落。查找采用精确匹配,因为放宽匹配只会带来该索引旨在消除的混淆。
含空格的表达式不会被识别:在句子中判断公式的结束位置是另一个问题,若靠猜测,就会让索引充满散文片段。
跨分界处的引文
引文短语往往始于一段,终于下一段。默认的 --quotes document 会保留每份文档的规范化文本并在其中查找:引文长度没有限制,但这相当于再复制一份语料——在一个软件包上的测量结果显示,文件大小增加了三分之一。
--quotes boundary 则会为相邻段落之间的衔接处建立索引,并将索引(而非文本)保存在表中。跨越一个分界处的引文可以通过短语匹配找到;超过衔接处范围的引文则无法找到,而保存整份副本时仍可以找到。文件头会记录软件包采用的格式。
MCP 服务
MDCX_FILE=/path/to/corpus.mdcx
MDCX_KEY=passphrase
python -m mdcx.mcp_server
密钥通过环境变量提供,而不是通过命令行传入;否则密钥会显示在进程表中。
MDCX_FILE 和 MDCX_KEY 接受多个软件包,以平台的路径分隔符或逗号分隔,并作为一个语料库进行查询。一个密钥可用于所有软件包,也可以按相同顺序为每个软件包指定一个密钥。
提供三个工具:
| 工具 | 返回内容 |
|---|---|
search | 回答问题的段落,并附带来源信息 |
info | 语料库记录,不进行查询 |
document | 按名称或可移植路径返回某篇文档的全文 |
search 回复会附带段落及其来源和排名,并报告无法完成的事项:当语料库中没有与问题相关的内容时返回 warning;当段落是以不同拼写找到时返回 read_as;当无法满足所请求的偏好时返回 prefer_applied;unknown_terms 则按软件包列出该软件包从未见过的问题中的词语。
服务多个软件包时,similarity 会基于所有软件包一起计算,而 answerable_at 则取它们经校准的可回答范围中的最低值,因此生效的阈值来自范围最窄的软件包,而非段落所属的软件包。answerable_at_by_package 会报告各软件包的值。
客户端断开连接时,服务会结束自身进程;闲置达到 MDCX_IDLE_UNLOAD_MINUTES 指定的时间后,服务会释放嵌入模型。默认值为 30,设为 0 可禁用此功能。下次提问时会重新加载模型。
来源
mdcx 负责转换、打包和回答问题。它不会获取内容,也不依赖自身的网络连接。目录是一个插件,通过 mdcx.sources 入口点组声明,并遵循 mdcx.sources 中的契约:
[project.entry-points."mdcx.sources"]
oapen = "my_package.oapen:Catalogue"
有意不内置适配器。看似简单的 HTTP 客户端其实并不简单:不同目录在下载地址、分页方式和标识符构成方面各不相同,这些知识应由掌握它们的人负责。要针对现有软件包回答问题,并不需要这些内容。
以下功能与任何特定目录无关,均已提供:
python -m mdcx.sources --check <name>
sources.looks_like(data, "pdf") # by signature, not by declared type
sources.identify(data) # what the bytes are instead
sources.patiently(call) # retries RateLimited, honours Retry-After
sources.conforms(source) # the conformance check, as a function
Candidate.download 会记录文件将从哪个服务器获取(如果目录无需进一步请求即可获知该信息),Candidate.host 可读取该记录。符合性检查会报告是否有某个主机对应了超过一半的候选项。
tests/test_sources_kit.py 包含一个约 20 行的参考来源实现,不依赖任何网络。
可移植路径
每篇文档都带有形如 @/folder/document.md 的可移植路径,该路径相对于集合根目录。系统不会存储绝对路径,因此即使所在机器的目录布局不同,也能读取软件包。export 会根据这些路径重建目录结构。
签名
mdcx keygen
mdcx pack --output ./docs --target corpus.mdcx --key "..." \
--issuer "Organisation" --signing-key <hex private key>
mdcx verify corpus.mdcx --public-key <hex public key>
签名采用 Ed25519,对加密正文的摘要和标头共同签名,因此修改其中任意一项都会使签名失效。验证只需要公钥,无需口令。
加密
软件包正文使用 AES-256-GCM 加密,密钥由口令和每个软件包各自的盐通过 scrypt 派生。标头包含格式、签发者、计数、完整性摘要、签名、语言、日期和校准信息,位于加密正文之外,无需密钥即可读取,因此接收方可以判断是否值得打开软件包。
打包时不允许使用空口令。可通过 MDCX_KEY、--key-file 或 --key - 提供口令,其中 --key - 会从标准输入读取;--key 会在命令运行期间将口令置于进程表中。
包格式
.mdcx 文件由魔数、JSON 头部和加密正文组成。正文是一个压缩的 SQLite 数据库,其中包含:
| 表 | 内容 |
|---|---|
document | 每个文档一行:名称、可移植路径、来源、日期、规范化文本 |
passage | 每个段落一行,包含其在文档中的位置 |
passage_fts | 全文索引,声明在 passage 上 |
passage_vector | 每个段落的嵌入向量(在建立语义索引时) |
term_shape | 转写恢复索引(在建立时) |
attachment | 与语料一同保存但不纳入语料的文档 |
df | 每个词项的文档频率 |
meta | 语料记录:语言、校准、模型、计数 |
嵌入向量以半精度存储,读取时会重新归一化。较早版本写入的包会缺少此后新增的表;每个读取包的入口点都会先检查,因此旧包仍可正常响应。
限制
- 转换保真度根据文档公开的文本进行衡量。无法公开任何文本的文档无法验证,并会标记为此类文档。
- 跨语言检索需要
multilingualextra 及其模型。 - 在 1.27.0 之前写入的包会保留构建时使用的分词器;该分词器会在每个组合附加符号处切分那些以组合附加符号书写元音的文字系统。此类包可以正常打开并保持原有响应;重新构建后,这些词会作为整体建立索引。
- 转写恢复筛选器用于处理转写错误,而非打字错误;它提出的内容中有相当一部分并非错误。
- 整个包会解密到内存中。若语料大于可用内存,会将其存放为多个包并作为一个整体进行查询;服务器仅在查询到达某个包时才会打开它。
info会报告哪些包已打开及其内容。写入包不受此限制:包会在目标文件中构建,并分块封存。 - 服务多个包时,词法分数会基于这些包的合集计算,因此可以相互排序。直接对多个包调用
archive.query的调用方应向所有包传入相同的corpus——corpus_statistics_over可收集该语料——否则每个包的分数都会采用各自的尺度。 - 校准阈值按语料测量。对于在该测量机制出现前构建的包,会使用固定阈值进行判定。
测试
pytest
| 文件 | 覆盖内容 |
|---|---|
test_formats.py | 按格式选择引擎和升级处理 |
test_package_identity.py | 容器:架构、加密、签名、完整性 |
test_relevance.py | 排序,以及答案引用的段落 |
test_multilingual.py、test_encoder.py | 编码、融合和跨语言检索 |
test_answer_quality.py | 校准阈值和警告 |
test_dates.py | 日期、日期来源和近期偏好 |
test_shapekey.py | 转写恢复及其边界 |
test_expressions.py | 词语规则无法区分的陈述 |
test_quotations.py | 跨越段落切分处的引文 |
test_sources_kit.py | 来源约定及其符合性检查 |
test_card_sizing.py | 设备检测、通道尺寸和卡片上的回合数 |
test_resident_output.py | 常驻转换器写入的内容,包括章节 |
test_packing_cost.py | 打包所需内存和读取原始文件的成本 |
test_deep_documents.py | 深度超过解释器递归限制的文档 |
test_closed_package.py | 校准和扩展现有包 |
test_incremental.py | 在包之间复用向量 |
test_server_leaves.py | 服务生命周期和空闲释放 |
test_describes_itself.py | 每个已发布字段和参数均有文档说明 |
贡献
请参阅 CONTRIBUTING.md。缺陷和测量相关的问题模板位于 .github/ISSUE_TEMPLATE 下。
安全
支持的版本以及如何报告漏洞,请参阅 SECURITY.md。
发布
已发布到 PyPI、MCP 注册表(io.github.jorgell23-sys/markdown-document-search),并归档在 Zenodo。
CHANGELOG.md 记录了每个版本的变更内容。
作者
Jorge Ellena G. 第三方组件及其许可信息,请参阅 NOTICE。
引用
在学术作品中引用 mdcx,请使用 CITATION.cff 或 DOI:
https://doi.org/10.5281/zenodo.22015991
许可证
Apache License 2.0。请参阅 LICENSE。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。