Product Indexing

How CelerSearch shapes WooCommerce products into searchable records, and how to customise that shape.

Document Shape

ProductsIndex::get_candidate_objects() emits one document per product — variable products are not split into per-variation documents; instead the parent document embeds a variations[] array (see below). The full attribute map that lands in Meilisearch looks like this:

JSONC
{
  "object_id":             "1234",            // primary key
  "post_id":               1234,
  "post_type":             "product",
  "post_type_label":       "Products",
  "post_title":            "Classic Blue T-Shirt",
  "post_excerpt":          "Comfortable everyday cotton tee",
  "post_date":             1705315800,        // unix timestamp
  "post_date_formatted":   "Jan 15, 2024",
  "post_modified":         1711112400,
  "comment_count":         23,
  "menu_order":            0,
  "post_author":           { "user_id": 1, "display_name": "…", "user_login": "…", "user_url": "…" },
  "images":                ["https://…", "…"],
  "permalink":             "https://…",
  "status":                "publish",

  "taxonomies":            { "product_cat": ["Clothing", "T-Shirts"], "product_tag": ["cotton"], "pa_color": ["Blue"] },
  "taxonomies_hierarchical": { "product_cat": [ /* nested term tree */ ] },
  "attributes":            { "pa_color": ["Blue"], "pa_size": ["S", "M", "L"] },

  "sku":                   "TSHIRT-BLU-001",
  "short_description":     "…",

  "price":                 29.99,
  "regular_price":         39.99,
  "sale_price":            29.99,
  "price_formatted":       "$29.99",
  "regular_price_formatted": "$39.99",
  "sale_price_formatted":  "$29.99",
  "currency_symbol":       "$",

  // Variable products only:
  "max_price":             49.99,
  "max_price_formatted":   "$49.99",
  "variations_count":      6,
  "variations":            [ { "id": 1235, "title": "Blue, M", "sku": "…", "price": 29.99, "price_html": "…", "permalink": "https://…", "in_stock": true, "image": "https://…", "attributes": { "pa_color": "blue", "pa_size": "m" } } ],

  "total_sales":           156,
  "ratings":               { "total_ratings": 23, "average_rating": 4 },
  "physical_attributes":   { "weight": "250 g", "dimensions": "20 × 30 × 2 cm" },
  "in_stock":              true,
  "stock_quantity":        45,
  "catalog_visibility":    "visible"
}

All taxonomies the product type has — including custom taxonomies and WooCommerce attribute taxonomies (pa_*) — are surfaced inside taxonomies, so you don't have to configure each new taxonomy explicitly.

Default Settings

PHP
// includes/Indices/ProductsIndex.php
$settings = (new IndexSettingsBuilder)
    ->primary_key('object_id')
    ->distinct_attribute('post_id')
    ->searchable_attributes([
        'post_title:unordered',
        'sku:unordered',
        'short_description:unordered',
        'taxonomies:unordered',
        'content:unordered',
    ])
    ->filterable_attributes([
        'taxonomies', 'taxonomies_hierarchical',
        'post_author.display_name', 'post_type_label',
        'price', 'sale_price', 'in_stock',
        'ratings.average_rating', 'catalog_visibility', 'status',
    ])
    ->sortable_attributes([
        'post_date:desc', 'price:asc', 'total_sales:desc',
        'ratings.average_rating:desc', 'record_index:asc', 'post_title:asc',
    ]);

Override everything via celersearch_products_index_settings or from the Index Settings UI on the Indices page.

Variable Products

ProductsIndex indexes the parent product and embeds purchasable variations as a nested variations[] array. Each entry carries its own id, title (e.g. "Blue, M"), sku, price, price_html, permalink, in_stock, image, and attributes map so you can:

  • Show the lowest price in the listing (price on the parent is the minimum variation price) and the highest (max_price) for ranges
  • Search by variation SKU/title (mark variations.sku / variations.title searchable in Index Settings)
  • Render per-variation data in custom templates

The parent document also surfaces every product-level attribute in the top-level attributes map (e.g. attributes.pa_color), which is handy for swatches and filtering.

Reshape the embedded variations with celersearch_product_variations:

PHP
add_filter('celersearch_product_variations', function ($variations, $product) {
    foreach ($variations as &$v) {
        $v['custom_field'] = get_post_meta($v['id'], 'custom_field', true);
    }
    return $variations;
}, 10, 2);

Adding Custom Fields

Full shared attributes

To append or replace fields on every product document:

PHP
add_filter('celersearch_searchable_product_shared_attributes', function ($attrs, $post) {
    $product = wc_get_product($post);

    $attrs['brand']      = get_post_meta($post->ID, 'brand', true);
    $attrs['material']   = get_field('material', $post->ID);
    $attrs['is_handmade'] = (bool) get_post_meta($post->ID, 'is_handmade', true);

    return $attrs;
}, 10, 2);

Attribute whitelist

If you only want to modify the attributes map (e.g. drop a noisy attribute before it hits the index):

PHP
add_filter('celersearch_product_indexable_attributes', function ($attributes, $product, $post) {
    unset($attributes['pa_internal_code']);
    return $attributes;
}, 10, 3);

Content override

PHP
add_filter('celersearch_searchable_product_content', function ($content, $post) {
    return substr(wp_strip_all_tags($content), 0, 2000);
}, 10, 2);

Making Custom Fields Filterable or Sortable

Indexing the data isn't enough — Meilisearch also needs the attribute marked filterable or sortable:

PHP
add_filter('celersearch_products_index_settings', function ($settings) {
    return $settings
        ->filterable_attributes(array_merge(
            $settings->get_filterable_attributes(),
            ['brand', 'is_handmade']
        ))
        ->sortable_attributes(array_merge(
            $settings->get_sortable_attributes(),
            ['is_handmade']
        ));
});

After changing settings, rebuild the index so Meilisearch re-analyses existing documents.

Content Splitting

For long product descriptions, enable the Content Splitting toggle in the index's settings. CelerSearch splits the content field into multiple records with the same post_id, using the distinct attribute to collapse them back into a single hit in search results. This keeps Meilisearch's per-record size limit happy for mega-descriptions without inflating hit counts.

Sync Triggers

Products are re-indexed by PostChangesObserver when:

EventHook
Product saved (create or update)wp_after_insert_post
Product trashedtrashed_post
Product permanently deletedbefore_delete_post
Watched meta key changedadded_post_meta / updated_post_meta / deleted_post_meta

By default only _thumbnail_id triggers re-indexing from meta. Extend the watchlist to include price, stock, or any other field you care about:

PHP
add_filter('celersearch_watch_post_meta_keys', function ($keys, $post_id) {
    return array_merge($keys, ['_price', '_stock', '_stock_status']);
}, 10, 2);

Action Scheduler debounces rapid edits into a single sync, so bulk price updates don't hammer the search service.

Tuning Batch Size

Large catalogs benefit from smaller rebuild batches (heavier records, more meta reads per row):

PHP
add_filter('celersearch_products_indexable_items_batch_size', fn () => 25);

Admin rebuilds use the batch size from Settings → General → Batch Size (10–500, defaults to 100). Code paths outside the admin (e.g. wp celersearch reindex without --batch) fall back to the filter default of 15.

Troubleshooting

Products missing from results. Only published products are indexed, and the index's own options can exclude sold-out or hidden/catalog-only products. Use the Index Browser modal (Indices → Browse) to confirm what Meilisearch actually has.

Custom field doesn't filter. Make sure it's also added to filterable_attributes — indexing a value without marking it filterable means Meilisearch accepts the field but refuses to filter on it.

Price not matching shop. WooCommerce per-user or per-role pricing runs at read time and bypasses the index. See WooCommerce overview — limitations for workarounds.