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.
Why it exists
Section titled “Why it exists”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.
Entry points
Section titled “Entry points”| 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) |
How it works
Section titled “How it works”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]Configuration
Section titled “Configuration”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.
Invariants and gotchas
Section titled “Invariants and gotchas”- Result thumbnails depend on a variable leaked from the hero block.
search.php:72,81pass$image_sizes, whichsearch.phpnever defines — it exists only becausesearch.php:26requiresblocks/hero/block-hero.php, which sets$image_sizesatblock-hero.php:130in 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_sizesunset, andget_responsive_image()returns an empty string whensizesis missing — every result thumbnail would silently disappear. - The relevance CASE is built with
esc_sql(), notprepare()(functions.php:1292-1304). It works, but it is a different escaping discipline from the WHERE clause a few lines above. Keep usingesc_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
ANDacross 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 whenpost_typeis set, so a crafted?post_type=attachmentsearch can surface attachments the unfiltered search hides. - The filter bar’s
anyoption is not a real post type — it works becauseremove_block_guide_from_search()treatsanythe 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.
Changing it safely
Section titled “Changing it safely”- Adding a searchable ACF field → add a
LEFT JOINalias inbpcollins_search_join(), extend the condition inbpcollins_search_extend_team_meta()using$wpdb->prepare(), and add tiers to the CASE inbpcollins_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_filtersinsearch.php:37-43; the key is used verbatim as thepost_typeparameter. - Fixing the oversized thumbnails → define a local
$image_sizesinsearch.phpbefore the loop (shape as inpartials/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.