mcpField
T
tuning-oak-query-indexes

tuning-oak-query-indexes

github.com/adobe数据与数据库31678 次安装今天更新
代码仓库

关于 tuning-oak-query-indexes

供 AI Agent 使用的 Oak 查询索引审查流程,根据查询和索引定义找出覆盖及作用域缺口并提出修改建议。它要求同时提供两类材料,并区分固定字段与依赖部署配置的字段。

主要功能

  1. 01对照查询字段与索引属性
  2. 02检查节点类型和路径作用域
  3. 03区分静态字段与动态字段
  4. 04评估必须提供索引定义
  5. 05条件允许时用 explain 验证计划

适用场景

  1. 01查明 Oak 查询为何遍历节点
  2. 02修改前审查索引定义
  3. 03审计字段名由配置决定的查询

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/adobe/skills --skill tuning-oak-query-indexes

安装后在 Claude Code 中输入/tuning-oak-query-indexes确认已加载。

附带文件references/ai-agent-indexing-guide.mdreferences/extracting-jcr-queries.md

详细介绍

调优 Oak 查询索引

概述

Oak 查询只会使用索引实际覆盖的部分。任何没有匹配的、标记正确的属性定义的 WHERE/ORDER BY/全文搜索字段,都会逐节点在内存中评估——节点类型或路径范围不匹配时也一样,即使每个字段都已建立索引。本技能提供一套流程,用于准确找出哪些字段/条件未被覆盖,以及需要如何更改索引定义来修复每一处问题。

以下所有说法的事实依据:AI Agent 索引指南。该指南与本技能一同打包,因此无论复制到哪个代码库中,均可按相同方式使用。需要确切的属性表、默认值、成本模型细节和边缘情况时,再按需阅读;本技能是检查清单,而非参考资料。

本技能对所用代码库不作任何假设,除了“你有一条查询和一个索引定义”之外。 不要求有 AEM 实例、正在运行的 Oak 存储库,也不要求检出 Oak 自己的 Java 源代码——这些条件只会在标注之处(步骤 6 和下方“explain 无法触达时”的后备方案)增强可信度,并非必需;核心流程——阅读查询、阅读索引定义、找出差距、提出修复方案——仅凭这两项材料即可完成。真实项目中的索引定义通常是 FileVault XML(位于内容包 /apps/.../install 下的 .content.xml,其中包含 jcr:primaryType="oak:QueryIndexDefinition" 等),而非原始 JSON——以相同方式读取即可;属性名称和语义完全一致,只有序列化格式不同。

流程

必需输入——索引定义。没有索引定义,本技能无法运行。 整个评估都是将查询与其可能使用的索引定义进行比较,因此索引定义是必需输入,而非可选辅助材料。如果用户未提供,请向其索取——接受以下任意一种:/apps/.../install 包中的 FileVault .content.xml、JSON 导出文件,或通过 GET /system/console/status-oak-index-defn.json 获取的实时转储(需要管理员身份验证)。如果无法获取任何一种,请跳过本技能并明确说明——不要仅根据查询生成覆盖情况评估。猜测哪些内容已建立索引,看起来像结论,也会被当作结论采取行动;相比错误结论,不作回答更好。(查询是另一项必需输入——请直接提供查询,或使用随附的提取 JCR 查询指南构建清单。)

前置条件:以下步骤 1 和 6 都提到了实时实例工具(explain、Felix InventoryPrinter、Oak 的 trunk 测试套件)——这些都不是必需项。只要有查询文本和索引定义,就可以跳过这些工具的子项,将步骤 2–5 和 7 作为纯静态分析来执行,并按照最后一节的要求报告,同时说明验证结果仅有推理依据、尚未证实。

  1. 获取查询及其可能使用的每一个索引定义——不要只看你认为会用到的那个。如果你手头还没有查询——例如,用户让你审计整个应用或代码包,而不是优化一条交给你的查询——请先按照随附的提取 JCR 查询指南,找出代码库实际发出的每一条 JCR/QueryBuilder 查询(包括通过封装代码、辅助代码或 DAO 代码以编程方式组装的查询,这些查询仅靠文本搜索会遗漏);其输出(构造位置 + 每一条触发/调用路径)就是本技能逐条评估查询的输入。如果无法访问运行中的实例,请依据实际提供的每一个索引定义进行分析,并在报告中说明(见“报告格式”)——此时只能推断候选索引,无法确认。如果可以访问运行中的实例,explain 会告诉你实际选用了哪个索引,这比仅根据定义进行推断更可靠;要枚举代码库中的每一个索引定义(包括按路径查找会遗漏的非根索引),请使用 Felix 的 InventoryPrinter 状态页面,该页面由 oak-core 自行注册——它适用于任何基于 Sling/Felix 的 Oak 部署,不限于 AEM:GET /system/console/status-oak-index-defn.json(管理员身份验证)。此页面会以 oak-run 的 --index-definitions-file 所用的相同类型前缀 JSON 编码(nam:/str:/dat:/...)导出每一个索引定义(仅配置,不包含隐藏的 :data/:index 存储节点)——发出一个请求,就无需逐个猜测候选索引名称。

  2. 分析前,先将每一条查询分为两个部分——A 部分(静态字段)和 B 部分(动态字段):

    • A 部分——静态字段:查询筛选、排序或搜索的属性路径/名称在源代码中是固定的——可以是字面字符串或具名常量——即使与之比较的值在运行时会变化(例如调用方提供的 ID、循环变量,或仅用作值的请求参数)。只要你能准确指出 WHERE/ORDER BY 中的字段名称,而无需猜测,该查询就属于 A 部分。
    • B 部分——动态字段:属性路径/名称本身在源代码中并不固定——它来自 OSGi 配置(如 detectMetadataField 一类的属性)、请求/谓词映射中的键、由内容编写的查询(dam:query 节点、Smart Collections),或脚本/原生查询字符串。没有外部输入,就无法确定索引需要覆盖哪个属性。

    完整评估 A 部分——这是主要交付内容。 对于 B 部分,不要猜测可能的或默认的字段名称,并把该猜测当作查询的真实目标来评估。 应逐条列出 B 部分的查询及其缺失的具体输入(例如“detectMetadataField 的 OSGi 配置值”或“传递给此 Servlet 的 type 请求参数”),并向用户索取这些信息。只有用户提供了其部署中实际使用的具体字段名称后,才能将 B 部分的查询纳入正式评估;此后将用户提供的信息视为与 A 部分事实相同。

    仍主张评估的借口实际情况
    “默认值可能是 X,我会注明这一限制”即使附带限制说明,猜测仍会被视为评估结果并据此采取行动——如果部署覆盖了配置,整张覆盖情况表就会出错。应说明缺少的输入并停止评估,而不是评估默认值。
    “部分评估仍然有用”不完整但错误的结果比不评估更糟——在报告中,它与经核实的发现无法区分。将其单独列入 Section B 并询问用户。
    “我已经在 Technique 1 中说明了默认情况”知道默认值不等于知道已配置的值。评估前先确认,不要推断。
    “提问会打断报告的流程”报告仍然完整——Section A 本身就是一份完整内容。用户一旦回答,Section B 就可以进行评估。
  3. 提取查询涉及的每个字段/结构:WHERE 中的相等/范围/IN/IS NULL/ IS NOT NULL、ORDER BY 列、contains(...)/全文搜索、rep:facet/rep:similar/rep:suggest/ rep:spellcheck、路径限制(ISDESCENDANTNODE///),以及查询自身的节点类型限制 (FROM [type]/XPath element(*, type))。

    • AEM QueryBuilder 谓词映射——字段名通常是值,而不是键。 对于 property、relativedaterange、daterange、boolproperty 和 similar 谓词类型,映射 键(1_property、5_relativedaterange.property 等)只是谓词类型标记加上组索引——它标识 哪个谓词,而非哪个 JCR 属性。索引需要覆盖的实际字段,是赋给该键的字符串值,例如 map.put("1_property", "jcr:content/hasValidMetadata") → 要检查索引覆盖情况的字段是 jcr:content/hasValidMetadata,应从值读取,而不是从字面文本 1_property 读取。同理, map.put("1_relativedaterange.property", "jcr:created") → 字段是 jcr:created。不要因此误判 第 2 步中的 Section A/B:这里的字段名仍是源代码中的硬编码字符串字面量(Section A),即使它 看起来不像 SQL2/XPath 条件中常见的 WHERE 子句字段——只有当该值本身是变量/请求参数而非 字面量时,才属于 Section B。
  4. 对每个字段,查明所需标志(见下表),并检查索引是否有 name 匹配且带有该标志的属性 定义。

  5. 先检查两个范围限定陷阱,再做其他检查——无论字段的索引覆盖得多好,这些情况都会导致索引 完全无法使用,也是看似正确的索引未被选中的最常见原因:

    • 节点类型范围限定:查询自身的 FROM/element() 限制是否指定了索引声明的类型(或其子类型)?如果 查询不受限制(裸 */隐式 nt:base),但索引只为一种类型声明了规则(属性索引上的 declaringNodeTypes,或 Lucene 上不包含 nt:base 的 indexRules),该索引对规划器不可见——成本为 Infinity,或者根本不会出现在候选列表中。应通过在查询中添加节点类型限制来修正,而不是修改 索引。
    • 路径范围限定(仅限 Lucene):includedPaths 和 queryPaths 是否相互匹配,查询实际的路径 限制是否位于其中?如果只缩窄 includedPaths,却让 queryPaths 保持默认值(/),后果比未选中 索引更糟——索引会在超出其实际覆盖范围时被选中,并悄然返回少于实际存在的结果。
  6. 用实测验证,不要只根据规范推断。 运行 explain <query>(或 AEM Query Performance 工具)。确认:(a) 预期索引出现在候选/成本日志中;(b) 其成本低于所有替代方案,包括遍历; (c) 计划中没有 traverse,也没有对你认为已覆盖的字段进行未索引的残余条件过滤。有限成本本身 并不能证明某个条件由索引原生处理——已通过实际环境确认:某个索引以看似正常的有限成本被选中 (由于节点类型/路径范围限定,其成本低于遍历),但其计划中的实际 luceneQuery 只是一个裸 *:*,这意味着对未索引属性的相等条件仍作为残余内存过滤条件执行,根本没有下推到索引中。务必查看日志中针对 你关心的具体字段的 luceneQuery/计划文本,不要只看是否有某个索引获得有限成本。

    • explain 只能告诉你选中了哪个索引以及对应的成本/计划——它无法说明那些不通过成本计划机制运行的功能是否 正常工作:建议(rep:suggest)、拼写检查(rep:spellcheck)、Facet 计数、相似度排序 (rep:similar)和 rep:native 都属于此类,需要单独验证,不能只检查 explain。有关获取实际验证依据 的两种方法(通过实时实例配置/MBean 交叉核对,或运行指定的 Oak trunk 单元测试),请参阅 索引指南中的“验证 explain 无法检查的功能”(附录 B)——如果这两种方法 都不可用,应在报告中明确说明,而不是猜测。
  7. 编写任何修改前,先确定修复应针对哪个现有索引。 绝不要凭空创建全新索引,也不要悄悄从多个合理候选中 选一个——目标索引会改变整份建议(它已有的属性、当前标签/权重/重新索引历史),所以选错也会导致第 8 步的 “修正后的索引定义”出错。

    • 可以访问实时实例:按原样对查询运行 explain(即使你预计存在缺口——此处需要的正是响应返回的候选列表和 各索引成本,而不只是通过/不通过的结果)。explain 的响应会列出它考虑的每个候选索引及其成本,而不只是 胜出的索引——应查看整个列表,而非只看第一行。符合第 5 步节点类型/路径范围限定条件且成本最低的索引,就是 目标索引。请写明你读取的成本数值,而不只是索引名称,以便他人核查选择依据。
    • 没有实时实例:列出你拿到的所有索引定义中,节点类型及路径范围限定条件(第 5 步)与查询匹配的索引—— 无论仓库中有多少索引文件,这些才是真正的候选项。然后:
      • 恰好有一个候选项匹配 → 以它为目标,继续。
      • 没有候选项匹配 → 没有可扩展的现有索引;请明确说明,并仅在最后不得已时建议新建索引(这是唯一允许凭空创建 索引的情况,而且仍须明确指出这是新建索引,不能悄悄地把它说成“修复方案”)。
      • 有多个候选项匹配(相互竞争的索引) → 不要悄悄选一个——这正是上方列表中“某个属性已建索引,但成本更低 的另一个索引仍然胜出”的问题,只不过现在你是在没有 explain 所能提供的成本数据时自行做选择。应并列展示 各个候选:每个索引已覆盖查询中的哪些字段、其 tags、weight(如已设置)、includedPaths/queryPaths, 以及其范围限定得有多严格——然后询问用户希望将修改应用到哪个索引。在用户选定之前,不要为任何索引提出修正 后的定义。
  8. 提出能让每个字段都具备所需标志的最小修复。 优先为每种节点类型使用一个完整一致的索引,而不是多个相互重叠的 单一用途索引。对此项修改设置 reindex: true,而不是 refresh: true;除非你能明确确认现有内容中没有任何 内容已带有所新增/变更属性的值——refresh: true 只会让新的属性定义可用于规划(explain 会将其显示为 候选项),不会回填该定义出现之前就已带有该值的既有文档。已通过实际环境确认:仅设置 refresh: true 添加的属性会立即出现在 explain 中,但查询既有文档的值时,在真正运行 reindex: true 之前返回的结果仍为零。 如果无法确定该属性确实对每份文档都是全新的,就执行重新索引。修改索引前,先检查收紧查询能否以更低成本 达到目的——查询约束越多,成本始终越低,也越安全,优先顺序如下:

    1. 添加/收紧节点类型限制(element(*, T)/FROM [T])——这是杠杆效应最大的一项修改;参见上面的节点类型范围限定 陷阱。已知实际目标类型时,绝不要使用裸 */nt:base 限制。
    2. 添加/收紧路径限制(根据用例允许的范围,尽可能严格地限定 ISDESCENDANTNODE///)——这样既能降低遍历回退的 成本,也能在索引有路径范围限定时确保索引仍符合使用条件。
    3. 添加/收紧属性条件(选择性更强的相等/范围条件会将更多工作推入索引,并减少从索引中读取的数据)。 只有当查询已按照用例实际允许的程度充分限定后,才扩大索引定义的范围——扩大索引是成本更高、影响更持久的修改 (需要更多存储和索引时间,也会影响命中该索引的其他所有查询),而收紧查询文本是免费的,且仅对当前查询请求生效。 生成修正后的索引定义(JSON/内容差异)不等于应用修改——可以主动写出供审阅,但实际推送到实时实例(Sling POST/ curl -F、提交 .content.xml,或任何其他写入操作)都是会修改仓库的变更,和其他变更一样,必须先获得用户明确许可; 这与索引指南附录 B 对创建/修改/删除命令的要求相同。
  9. 应用修改后(如适用,也要在异步周期结束后)通过 explain 重新验证——确认没有残余/内存过滤,并确认任何 ORDER BY 都由索引处理,而不是通过完整的内存排序。

Field 构造 → 所需索引标志

查询构造属性索引(type=property)Lucene 属性定义
相等 / IN在 propertyNames 中propertyIndex: true
IS NOT NULL在 propertyNames 中(属性索引没有专用的仅检查是否存在标志)propertyIndex: true,或仅使用开销更低的 notNullCheckEnabled: true,前提是你不需要对该值进行相等、范围或排序操作——见下方的开销说明
IS NULL不适用——type=property 不支持此机制nullCheckEnabled: true(单独使用,无需 propertyIndex)
ORDER BY不支持ordered: true + 显式 type
contains(prop, ...)不支持analyzed: true
contains(., ...)(节点范围)不支持对每个参与的属性设置 analyzed: true + nodeScopeIndex: true
rep:facet不支持facets: true
范围(<、>、<=、>=)如果在 propertyNames 中则可用propertyIndex: true + 显式 type
相对属性(a/b/@c)不支持name: "a/b/c"
函数(lower(...)、name()、path())不支持function: "..." + 按需设置 propertyIndex/ordered

始终为用于 ORDER BY 或范围条件的属性设置显式 type(Date/Long/Double)——如果不设置,值可能会按字典序比较(看起来像数字的字符串会按 "1"、"10"、"2" 排序;未解析的日期会排在所有有效日期之前,而不是报错或被排除)。

索引大小/存储成本注意事项

本技能中的每项修复都会通过扩大索引覆盖范围来加快查询速度,但这并非没有代价——当建议的改动触发以下任一情况时,务必说明权衡,不要只默默添加标志:

启用此项…………会产生以下代价
在规则的任何属性上启用 nodeScopeIndex为符合该规则的每个节点编制节点名称索引,而不只是被提升的属性——对于 nt:base 这类宽泛规则,索引会很大。启用前,应将规则限定为最窄的实际节点类型。
evaluatePathRestrictions每个文档的存储量会略有但确实增加(会存储祖先路径词项)。仅在确实需要原生路径限制评估的索引上启用(§4.2 中 queryPaths 的正确性场景),不要默认启用。
在某个属性上启用 ordered会增大索引(每个启用排序的属性都需要一个 doc-values 字段)。此选项仅适用于单值属性——但在多值属性上启用时不会直接报错:只会记录一条 WARN 并跳过该文档的 doc-values 字段,因此多值文档的 ORDER BY 会悄然退化为不可预测的位置,而非报错。只有真实 ORDER BY 会用到的属性才标记为 ordered;不要指望硬性报错来发现多值错误——请检查数据。
在一个宽泛索引中放入许多属性,以防万一索引更大、索引速度更慢;并且根据上述节点类型/路径范围限定方面的注意事项,如果查询没有请求这些属性,也不会带来选择上的好处。应优先采用范围狭窄、内容一致的索引,而不是一个包罗万象的大索引。
单个 Lucene 索引接近 ~2^31 个文档这是 Oak 所用 Lucene 版本的硬性上限——超过后,索引可能无法打开。应拆分为多个范围更窄的索引(按节点类型或路径限定;Solr 曾是绕过此限制的办法,但已在 Oak 1.82 中移除)。
长文本值未设置 maxFieldLength 覆盖值默认只会为每个字段索引前约 10000 个词项——大型文档字段会被悄然截断,而非完整索引,因此可能漏掉该边界之后内容的全文检索结果。
仅为 IS NOT NULL/IS NULL 使用 propertyIndex: true,从不需要相等、范围或排序查询会浪费存储空间——propertyIndex 会将每个不同的值作为独立词项编入专用字段。仅使用 notNullCheckEnabled/nullCheckEnabled(无需 propertyIndex)即可通过整个索引共享字段(:notNullProps/:nullProps)中的一个轻量、非存储词项完成相同的存在性检查,成本确实更低,并且已在实际环境中确认可以独立工作。反过来说,在属性很少设置的宽泛/通用节点类型上启用 nullCheckEnabled,本身就可能为几乎每个文档都创建一个条目(每个不含该值的节点一个条目)——应将范围限定为较窄的节点类型。

一般原则:查询的约束越多,提供服务的成本就越低,也越安全——指定具体、范围狭窄的节点类型和路径的查询,始终优于宽泛的 nt:base/未限定查询,不受索引具体实现方式的影响。建议先收紧查询(步骤 8),再建议使用索引来弥补查询范围过宽的问题。

常见错误(本技能开发期间均已逐一进行实时验证)

除了步骤 5 中的两个范围限定陷阱(查询没有匹配的限制条件时,按 nodetype 限定的索引会被完全跳过;includedPaths/queryPaths 不匹配时会静默返回较少结果):

  • 在 Lucene 属性定义上设置 sync/unique,但索引自身的 async 仍是单个字符串(而不是 ["async","sync"]/["async","nrt"])→ 静默地始终不同步,也始终不会强制执行。
  • 某个属性已编入索引,但另一个成本更低的索引仍胜出 → 检查 weight(值越低成本越低,默认值为 5),并再次检查上述范围限定陷阱,确认你预期胜出的索引没有问题。
  • 为了“以防万一”将许多无关属性编入同一个索引 → 优先为每个 nodetype 建立一个一致的索引;同一 nodetype 的重叠索引若设置了不同的 includedPaths/excludedPaths,会导致选择不明确。
  • rep:facet/rep:similar 也会遇到 nodetype 范围限定陷阱——rep:facet 会直接抛出异常("... can't be evaluated by traversal"),而不会像普通全文查询那样静默回退。只有实际运行查询时才会发生这种情况——对同一查询执行 explain 不会抛出异常;它会正常成功,并返回 traverse/estimatedEntries: Infinity 计划,很容易被误读为“没问题,只是慢”。不要因为 explain 成功就认定 rep:facet 查询可用——实际运行查询,或者将 facet 查询中的 Infinity 成本遍历计划视为执行时会失败的预警信号。
  • 原始 rep:native('idx', 'field:value') 查询字符串如果 field 是 analyzed 属性,会静默匹配不到任何内容——它实际的 Lucene 字段名是 full:field(对冒号进行转义:full\:field:value),而不是裸 JCR 属性名。只有未分析(仅 propertyIndex)的字段才使用裸名称。已通过 FieldNames.ANALYZED_FIELD_PREFIX 查明根因,并通过并排实时验证确认(未转义形式:0 个结果;使用 full\: 转义的形式:正确匹配,同一索引/内容)——尽管 explain 计划看起来正确,原生查询却“无法使用”的情况中,这是最常见的原因。

报告格式

按照步骤 2 中的两个部分组织报告——绝不可将它们合并:

A 部分 — 静态字段(完整评估)

对于每个 A 部分查询,提供:

  1. 一张表格:字段/结构 → 是否覆盖?→ 缺少什么。
  2. 对每个未覆盖字段说明根因(属于上表/常见错误中的哪种故障模式——包括两个范围限定检查,即使检查结果正常也要说明)。
  3. 说明查询本身是否可以进一步加上限制条件(先 nodetype,再路径,然后属性条件——参见步骤 8);即使最终答案是“否,当前范围已是该用例允许的最小范围”,也要明确指出。
  4. 指明修复针对哪个索引以及原因(步骤 7):由 explain 确认的候选索引列表及其成本、唯一静态匹配的候选索引,或者——如果多个索引相互竞争且没有可用的实时实例来决定胜负——并排比较各个竞争索引,并向用户询问应修改哪个索引;在用户回答之前,不提供任何索引的修正定义。
  5. 提供第 4 项所确定索引的修正定义(完整 JSON 或内容差异),并对每项更改给出一行理由;对于会触发上述任一陷阱的更改,必须明确指出存储/大小方面的权衡(不能因为它解决了查询问题,就不提大小成本)。
  6. 给出用于验证的 explain 命令,并说明通过时应看到什么——如果没有可用的实时 Oak 实例,请明确说明,并将静态分析表述为有理有据但未经验证,而不要悄悄跳过验证。
B 部分 — 动态字段(询问,不评估)

对于每个 B 部分查询,列出构造位置和确切缺失的输入(命名的 OSGi 属性、请求参数、配置键,或“任意由内容作者编写的查询 — 不存在固定字段”),然后向用户询问。对于 B 部分查询,在用户提供具体字段名称之前,不要提供覆盖范围表、根本原因或更正后的索引定义;用户提供后,针对该查询重新执行步骤 4-9,并将其移入 A 部分的报告格式。

评论

支持 Markdown