:商品列表页选项值过滤计数的工作原理)
Spree 5.4 析取式分面Disjunctive Faceting商品列表页选项值过滤计数的工作原理【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree本篇指南围绕 Spree 5.4 的设计文档 Disjunctive Option Value Faceting 展开讲清商品列表页PLP选项值过滤中选中 Blue 后Light Blue 的计数从 18 变成 1这一经典分面计数错误的成因与解法后端把选中的选项值按选项类型分组再针对每个选项类型分别计算排除本类型条件的析取式计数。读完你可以完整理解 Database 与 Meilisearch 两套 SearchProvider 的对应实现FiltersAggregator与merge_disjunctive_facets以及为什么整个方案不需要改动任何 API、SDK 或 Storefront 代码。问题本质分面计数不能对已被过滤的集合计算电商 PLP 侧边栏的选项过滤器颜色、尺寸等通常依赖facet计数展示每个候选值下还有多少商品。如果计数直接基于应用全部过滤条件之后的结果集计算那么一旦用户在 Color 下选中了 Blue侧边栏里同属于 Color 的 Light Blue、Red 等值的计数就会塌缩为 0 或 1——因为结果集中只包含蓝色商品。文档给出的典型症状是选中 Color 下的 Blue 后Light Blue 的计数显示为 1 而不是 18。正确的行为业界称为 disjunctive faceting与 Algolia 的 N1 分面模式同源是同一选项类型内部取析取OR在 Color 维度内Blue、Red、Light Blue 的计数互不压制各自回答若我改为选中这个值还剩多少商品不同选项类型之间取合取ANDSize 维度的计数仍然要反映 ColorBlue 的约束选了蓝色后S 的计数只统计蓝色 S 码商品。关键设计决策不改 API服务端按选项类型分组文档明确了三条不讨论不偏离的决策这也是理解整个实现的框架决策内容不变更 APIwith_option_value_ids保持扁平数组参数。后端用单条OptionValue.where(id:).group_by(:option_type_id)查询在服务端把值按选项类型分组Meilisearch 采用 N1 multi-search1 个命中查询 每个活跃选项类型 1 个分面查询该查询排除本类型值通过/multi-search单次 HTTP 调用批量下发Database 端由 FiltersAggregator 承担接收按类型分组的选项值针对排除本类型过滤的 scope 逐类型计算计数这个设计带来一个直接好处请求参数、SDK 的ProductListParams.with_option_value_ids、Storefront 的ActiveFilters.optionValues: string[]、过滤响应结构全部保持不变对 5.4 之前的客户端完全透明。参数形态例如q[with_option_value_ids][]optval_blueq[with_option_value_ids][]optval_small注意值使用带前缀的 IDoptval_ Sqids 编码解码统一走Spree::OptionValue.decode_prefixed_id。服务端分组一次查询得到{ option_type_id [option_values] }两个 Provider 共用的第一步是分组。Meilisearch Provider 中的实现位于 group_option_values_by_type# Group prefixed option value IDs by option type (single DB query). # Returns { option_type_id [optval_abc, optval_def], ... } def group_option_values_by_type(prefixed_ids) prefixed_ids prefixed_ids.flatten.compact.select { |id| valid_prefixed_id?(id) } return {} if prefixed_ids.empty? raw_ids prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) } Spree::OptionValue.where(id: raw_ids).group_by(:option_type_id).transform_values { |ovs| ovs.map(:prefixed_id) } end与文档草图相比实际实现多了前缀 ID 合法性校验valid_prefixed_id?匹配/\A[a-z]_[A-Za-z0-9]\z/并把值统一转回前缀 ID 以便直接拼进 Meilisearch 过滤条件。产物是{ option_type_id_1 [ov_a, ov_b], option_type_id_2 [ov_c] }这样的哈希——足以分别构造含全部类型的命中过滤与逐类型排除的分面过滤。Meilisearch Provider1 N 的 multi-search 与分面合并核心逻辑在 execute_search。当请求带有选项值过滤extract_and_delete(filters, with_option_value_ids)取出且需要返回分面时构造多路查询if return_facets grouped_options.any? queries [{ indexUid: index_name, q: query.to_s, **search_params }] option_type_ids_ordered grouped_options.keys option_type_ids_ordered.each do |option_type_id| without_this build_grouped_option_conditions(grouped_options.except(option_type_id)) queries { indexUid: index_name, q: query.to_s, filter: base_conditions without_this, facets: [option_value_ids], page: 1, hitsPerPage: 0 } end results client.multi_search(queries) ms_result results[results][0] facet_distribution merge_disjunctive_facets(ms_result, results[results][1..], option_type_ids_ordered) else ms_result client.index(index_name).search(query.to_s, search_params) facet_distribution ms_result[facetDistribution] || {} end三个要点主查询携带全部过滤条件类型内 OR、跨类型 AND负责命中列表与totalHits每个活跃选项类型追加一条分面查询filter用build_grouped_option_conditions(grouped_options.except(option_type_id))生成——即去掉本类型选中值、保留其他类型约束hitsPerPage: 0表示只要facetDistribution不要命中所有查询经client.multi_search(queries)一次 HTTP 往返完成这正是文档所说Single HTTP call via/multi-search把 N1 的 RTT 开销压成 1。分面结果在 merge_disjunctive_facets 中合并def merge_disjunctive_facets(ms_result, disjunctive_results, option_type_ids_ordered) main_ov_dist ms_result.dig(facetDistribution, option_value_ids) || {} # ... 解码各分面查询的 option_value_ids 分布建立 prefixed_id option_type_id 映射 merged_ov_dist main_ov_dist.dup disjunctive_dists.each do |option_type_id, dist| dist.each do |pid, count| merged_ov_dist[pid] count if prefixed_to_type[pid] option_type_id end end (ms_result[facetDistribution] || {}).merge(option_value_ids merged_ov_dist) end合并规则是只信任排除本类型的那条查询某个选项值属于哪个选项类型就用对应排除查询里的计数覆盖主查询的计数。主查询的option_value_ids分布因此只作为未选中任何值时的基线选中值之后会被逐类型覆盖——这保证了 Red 显示的是其他类型约束下含 Red 的商品数而不是Blue AND Red的数量。价格、库存、类目等其他分面不受影响继续来自主查询。两个工程细节值得注意单类型快速路径文档约束里提到若只有 1 个选项类型活跃可跳过 multi-search。从源码结构看实际条件是return_facets grouped_options.any?才走 multi-search而当只有单一类型活跃时主查询本身它没有对本类型做排除……严格地说单类型场景主查询的 facet 分布对其他类型无影响单条查询的分面计数对其他选项类型仍是准确的实现通过grouped_options.any?的分支天然覆盖了文档描述的优化意图跨类型 AND 必须落在同一个 variant 上build_grouped_option_conditionsL483-L490在轴数 ≥ 2 时会使用索引侧预写的option_value_combination_ids组合 token每整组组合一个 token而非成对避免蓝 S 与红 L 分属两个 variant 也能被 (Blue OR Red) AND (S OR L) 命中的跨 variant 假阳性。若索引中没有足够宽度的 token如自定义 presenter 未写 tokenoption_exact?判定失败search_and_filter会把这批 ID 存入inexact_option_value_ids随后用数据库侧的scope.with_option_value_ids精确收窄Meilisearch 只起预过滤作用。Database Providerscope_before_options与FiltersAggregatorDatabase 路径的实现分布在两处。先说入口Spree::SearchProvider::Database#filters 在应用文本搜索与 Ransack 过滤之后、选项值过滤之前截取了中间 scopedef filters(scope:, query: nil, filters: {}) # ... option_value_ids Array(filters.delete(with_option_value_ids) || filters.delete(:with_option_value_ids)) # 应用文本搜索 ransack 过滤不含选项值 scope_before_options apply_search_and_filters(scope, query: query, filters: filters) # 选项值过滤应用于最终 scope scope_with_options if option_value_ids.present? scope_before_options.with_option_value_ids(option_value_ids) else scope_before_options end filter_facets build_facets(scope_with_options, category: category, sort_order: collection.sort_order, option_value_ids: option_value_ids, scope_before_options: scope_before_options) # ... end注意这与文档草图中的apply_option_filters逐类型多次调用scope.with_option_value_ids不同实际实现复用了 Spree::Product.with_option_value_ids 这一统一 scope。该 scope 内部同样做了按选项类型分组并要求跨类型的 AND 落在同一个 variant 上——用GROUP BY variant.id HAVING COUNT(DISTINCT option_type_id) 分组数精确表达一个 variant 同时覆盖所有选中轴def self.with_option_value_ids(*ids) # ... grouped OptionValue.where(id: actual_ids).group_by(:option_type_id) return none if grouped.empty? matching_product_ids Variant.where(deleted_at: nil). joins(option_value_variants: :option_value). where(OptionValue.table_name { id: actual_ids }). group(Variant.arel_table[:id]). having(OptionValue.arel_table[:option_type_id].count(true).eq(grouped.size)). select(:product_id) where(id: matching_product_ids) end相关测试 验证了三层语义同类型 ORBlue OR Red 命中全部 3 个产品、跨类型 ANDBlue AND S 只命中同时具备两者的产品、混合(Blue OR Red) AND S 排除 BlueL。这也印证了文档Constraints一节的第一条with_option_value_ids的 Ransack 兼容 scope 必须对直接 Ransack 使用场景继续有效——它现在是独立于 SearchProvider 的模型级能力。析取式计数的核心在 Spree::Api::V3::FiltersAggregator。构造函数新增两个参数L11-L18option_value_ids当前选中的前缀 ID与scope_before_options未应用选项值过滤的 scope后者缺省回退为scope以兼容旧调用方。计数分发在 option_type_filters 中按是否已有选中值分两条路径# Batch counts if grouped_selected_options.empty? counts batch_option_value_counts(scope_before_options, all_ov_ids) else scope_groups option_types.group_by { |ot| disjunctive_scope_for(ot) } counts {} scope_groups.each do |scope, types| ov_ids types.flat_map { |t| ov_rows_by_type[t.id].map(:first) || [] } counts.merge!(batch_option_value_counts(scope, ov_ids)) end end其中grouped_selected_optionsL237-L246与文档的group_option_values_by_type一一对应解码前缀 ID 后Spree::OptionValue.where(id: decoded).group_by(:option_type_id)一条查询完成分组并缓存。真正的析取语义由 disjunctive_scope_for 实现——它返回应用了除本选项类型外所有选中类型的 scope# Returns the scope with all option type filters EXCEPT the given one applied. # This gives disjunctive counts: selecting Blue still shows Reds true count. def disjunctive_scope_for(option_type) return scope_before_options if grouped_selected_options.empty? other_groups grouped_selected_options.except(option_type.id) # If this type has selections but no other types do, use scope before any option filters return scope_before_options if other_groups.empty? # Rebuild: start from scope before options, apply only other option types scope scope_before_options other_groups.each_value do |ov_ids| matching Spree::Variant.where(deleted_at: nil) .joins(:option_value_variants) .where(Spree::OptionValueVariant.table_name { option_value_id: ov_ids }) .select(:product_id) scope scope.where(id: matching) end scope end这里有两个分支精确对应文档与实现的语义没有任何选中值时退回全量 scope普通 faceting某类型有选中值但其他类型没有时也退回scope_before_options——因为此时排除本类型等于没有任何选项约束单条分面查询就足够这正是数据库侧的单类型跳过优化。计数本身由 batch_option_value_counts 完成对每个候选 scope 用一条带INNER JOIN variants ... AND variants.deleted_at IS NULL的分组 COUNT 查询批量算出该 scope 内所有选项值 ID 的商品数避免逐值查询。代码里还有一处性能设计scope_groups option_types.group_by { |ot| disjunctive_scope_for(ot) }会把析取 scope 相同的选项类型归并例如所有未被选中的类型共享同一个scope_before_options衍生 scope相同的 scope 只发一条批量计数查询。用测试固化语义Blue 之下 Red 必须显示真实计数filters_aggregator_spec.rb 的 disjunctive option facet counts 小节 是这套方案的行为契约。测试构造了 BlueS 与 RedM 两个产品然后# 断言 1同类型内取析取 —— 过滤 Blue 后Red 的计数是 1 而不是 0 filtered_scope scope.with_option_value_ids([blue.prefixed_id]) aggregator described_class.new( scope: filtered_scope, currency: currency, category: nil, option_value_ids: [blue.prefixed_id], scope_before_options: scope ) red_option color_filter[:options].find { |o| o[:name] red } expect(red_option[:count]).to eq(1) # 断言 2跨类型取合取 —— 过滤 Blue 后Size 只统计蓝色产品 # S 计数 1blue_product 是 BlueSM 根本不在结果中无 BlueM 产品 expect(s_option[:count]).to eq(1) expect(m_option).to be_nil这两条断言完整刻画了同类型 OR、跨类型 AND的边界析取只发生在被选中的那个选项类型内部。响应组装上build_option_type_results 会用next if count.zero?丢弃计数为 0 的选项值所以BlueM 不存在直接体现为 M 选项消失。不变的部分与迁移路径文档强调的什么保持不变在代码中可以逐一核对API 参数仍是扁平的q[with_option_value_ids][]数组见 docs/api-reference/store-api/querying.mdx 对 Store API 查询参数的说明SDKProductListParams.with_option_value_ids未变可参考 packages/sdk/examples/products/list.ts响应结构filters 数组中type: option的项依然是{ id, name, label, kind, options: [{ id, name, label, position, color_code, image_url, count }] }只是count的含义从合取改为按类型析取。迁移路径文档 Migration Path 一节与仓库现状一致Meilisearch 端拦截with_option_value_ids→ 分组 → N1 multi-search → 合并分面Database 端拦截同参数 → 保存scope_before_options→ 交给FiltersAggregator按类型计算析取计数SDK/Storefront/API 零改动。边界与约束小结索引宽度限制Meilisearch 的组合 token 深度由 presenter 的MAX_COMBINATION_AXES决定max_combination_axes读取自presenter_class过滤轴数超过 token 宽度时退回逐轴并集 数据库精确收窄的兜底代价是页数可能偏少文档接受该折衷Ransack 直用兼容with_option_value_ids作为Spree::Product的类级方法保留直接 Ransack/AR 使用不受 SearchProvider 影响分面查询成本每多选中一个选项类型多一条hitsPerPage: 0的轻量查询且与主查询合并为一次/multi-search往返Database 侧则通过 scope 去重group_by { |ot| disjunctive_scope_for(ot) }把批量计数查询压到不同析取 scope 数条适用前提以上行为属于 Spree 5.4 引入、当前仓库主干中的实现选项值必须开启filterableoption_type_filters只取Spree::OptionType.filterable且 Meilisearch 路径要求索引包含option_value_ids、option_value_combination_ids等内置 filterable 属性见 BUILT_IN_FILTERABLE_ATTRIBUTES。参考设计文档docs/plans/5.4-disjunctive-option-faceting.md本文骨架来源含 Key Decisions 与 Constraints 原文前置依赖5.4-search-provider.mdSearchProvider 接口定义Database 端实现FiltersAggregator、Database SearchProvider、with_option_value_ids scopeMeilisearch 端实现SearchProvider行为测试FiltersAggregator 析取计数 spec、Database 过滤语义 spec【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考