Skip to content

Site search and relevance ranking

Site search is standard WordPress search with a heavily customised relevance model: team members are matched on their first_name/last_name ACF fields as well as titles, and results are ranked so people and service pages outrank Knowledge Hub articles that merely mention the words.

The default WordPress search ranks by date over post content, which meant searching a solicitor’s name returned articles mentioning them above the person’s own profile, and searching a practice area returned insights above the service page. Both are the wrong answer for a law firm. The custom ranking exists to fix exactly that — the commit history calls it “Boost search result relevance so team bios and service landing pages rank above Knowledge Hub articles”.

Team members were also unfindable by first or last name alone, because those live in ACF fields rather than the post title.

Path What it does
wp-content/themes/bpcollins/functions.php:1207 customize_search_query() — attaches the four SQL filters on a front-end search
wp-content/themes/bpcollins/functions.php:1219 bpcollins_search_join() — joins postmeta twice for first_name/last_name
wp-content/themes/bpcollins/functions.php:1240 bpcollins_search_extend_team_meta() — widens the WHERE to match those fields
wp-content/themes/bpcollins/functions.php:1278 bpcollins_search_orderby() — the relevance CASE expression
wp-content/themes/bpcollins/functions.php:1232 bpcollins_search_distinct() — forces DISTINCT
wp-content/themes/bpcollins/search.php The results template, including the post-type filter bar
wp-content/themes/bpcollins/inc/template-functions.php:600 remove_block_guide_from_search() — excludes attachment and block-guide
wp-content/themes/bpcollins/header.php:~330,~372 The two search forms (desktop and mobile)

Guard. Every filter calls bpcollins_is_frontend_search_query(), which requires !is_admin(), is_main_query() and is_search(). So admin search, secondary queries and AJAX listings are untouched.

Join and match. On a front-end search, posts_join adds two aliased LEFT JOINs on postmeta for the first_name and last_name meta keys. posts_search then appends an OR clause: post_type = 'team' AND (every search word matches first_name OR last_name). Each word is escaped with $wpdb->esc_like() and bound through $wpdb->prepare(). Words are combined with AND, so “jane smith” requires both words to match somewhere in the two name fields. posts_distinct returns DISTINCT because the joins can multiply rows.

Ranking. posts_orderby replaces the ORDER BY with a scored CASE (functions.php:1307-1331), highest first:

Score Condition
200 team whose first or last name starts with the search phrase
195 team whose title contains the phrase
190 team whose first or last name contains the phrase
180 service whose title contains the phrase
160 service/sector/career whose title contains every word
150 any post type whose title contains the phrase
140 any other team result
100 any post whose title contains every word
50 everything else (content-only matches)

Ties break on a second CASE by post type (team 5, service 4, sector 3, career 2, everything else 1), then on post_date DESC.

Self-removal. bpcollins_search_orderby() calls bpcollins_search_remove_filters() before returning (functions.php:1333), detaching all four filters so they apply to exactly one query per request. It also detaches and bails out early if the search term is empty.

Excluded types. remove_block_guide_from_search() replaces the searched post types with every type not excluded from search, minus attachment and block-guide — but returns early, leaving the query untouched, if the request already specified an explicit post_type. That is what lets the filter bar work.

The results template. search.php renders the hero block, then a filter bar offering All / Knowledge Hub / Team / Sectors / Services, each link re-running the same search with a post_type parameter. Each result shows a thumbnail — the thumbnail_image ACF field for team members, the featured image otherwise, or a first-initial letter tile as a fallback — a post-type pill (with post relabelled “Knowledge Hub”), the primary Yoast category for insights, the title, the excerpt and a “Read more” link. Pagination is standard paginate_links().

flowchart TD
A[search form: GET ?s=] --> B[pre_get_posts]
B --> C[remove_block_guide_from_search: drop attachment + block-guide]
B --> D{front-end main search?}
D -->|yes| E[attach posts_join / posts_search / posts_distinct / posts_orderby]
E --> F[join postmeta first_name + last_name]
F --> G[WHERE … OR team name fields match every word]
G --> H[ORDER BY relevance CASE, then post-type CASE, then date]
H --> I[filters detach themselves after one query]
I --> J[search.php renders filter bar + results]

None — no options, no environment variables. The ranking model is the code. Team first_name and last_name ACF fields must be populated for name search to work, and the two searchable ACF field names are hardcoded in the join.

  • Result thumbnails depend on a variable leaked from the hero block. search.php:72,81 pass $image_sizes, which search.php never defines — it exists only because search.php:26 requires blocks/hero/block-hero.php, which sets $image_sizes at block-hero.php:130 in the same scope. So search thumbnails are requested at the hero’s dimensions (576×800 up to 1920×800), not at thumbnail size. Removing or reordering the hero require would leave $image_sizes unset, and get_responsive_image() returns an empty string when sizes is missing — every result thumbnail would silently disappear.
  • The relevance CASE is built with esc_sql(), not prepare() (functions.php:1292-1304). It works, but it is a different escaping discipline from the WHERE clause a few lines above. Keep using esc_sql() on anything interpolated there and never introduce raw concatenation.
  • Filters detach themselves inside posts_orderby. If a future change makes the ORDER BY filter not run — WordPress skipping it, or another plugin short-circuiting — the other three filters stay attached and will apply to the next query in the same request. Any refactor must preserve a guaranteed detach point.
  • The name match uses AND across words. Searching “smith jane” works, but searching a full name where one word is not in either name field (a middle name, a title) returns nothing from the meta branch.
  • Score 200 requires a “starts with” match on the whole phrase, so “jane s” does not hit 200 for “Jane Smith” — it falls to the “contains” tiers.
  • Filtering by post type disables the exclusion logic. remove_block_guide_from_search() returns early when post_type is set, so a crafted ?post_type=attachment search can surface attachments the unfiltered search hides.
  • The filter bar’s any option is not a real post type — it works because remove_block_guide_from_search() treats any the same as unset (inc/template-functions.php:609).
  • Team results are boosted even on a pure content match (score 140), so a broad search can return the whole team above genuinely relevant pages.
  • The relevance model is invisible to editors. There is no admin UI, no weighting field, and no logging of what scored what.
  • Adding a searchable ACF field → add a LEFT JOIN alias in bpcollins_search_join(), extend the condition in bpcollins_search_extend_team_meta() using $wpdb->prepare(), and add tiers to the CASE in bpcollins_search_orderby(). All four filters must stay consistent.
  • Changing weights → edit only the CASE scores. Keep the gaps wide (they are in tens) so future tiers can slot between.
  • Adding a post type to the filter bar → add it to $search_filters in search.php:37-43; the key is used verbatim as the post_type parameter.
  • Fixing the oversized thumbnails → define a local $image_sizes in search.php before the loop (shape as in partials/post-card.php), which also removes the hidden dependency on the hero block.
  • Deliberately not abstracted: the ranking is raw SQL rather than a search plugin, because it needs to reach ACF meta and post type together in one ORDER BY. Do not replace it with a plugin without re-testing the specific journeys it was built for — surname search and practice-area search.
  • Verify: search a solicitor’s surname (their profile first), a solicitor’s first name, a practice area (service page above insights), a two-word name, a term with no matches, then each filter-bar option and page 2.

None. No automated tests exist in this repository. The relevance model is the part of this codebase most in need of them — verify by hand against the journeys listed above after any change.