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:
{
"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
// 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 (
priceon the parent is the minimum variation price) and the highest (max_price) for ranges - Search by variation SKU/title (mark
variations.sku/variations.titlesearchable 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:
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:
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):
add_filter('celersearch_product_indexable_attributes', function ($attributes, $product, $post) {
unset($attributes['pa_internal_code']);
return $attributes;
}, 10, 3);
Content override
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:
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:
| Event | Hook |
|---|---|
| Product saved (create or update) | wp_after_insert_post |
| Product trashed | trashed_post |
| Product permanently deleted | before_delete_post |
| Watched meta key changed | added_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:
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):
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.
Related
- Indexing hooks — every
celersearch_searchable_*filter - Shop filters — AJAX faceted filtering on the shop page
- Index settings reference — everything the builder exposes
- AI Semantic Search — add meaning-aware (vector) search on top of the products index