Skip to main content

max / makenotwork

Match discover search against item descriptions ITEM_SEARCH_CLAUSE matched i.title only, while the relevance expression already scored similarity(COALESCE(i.description,''), $1) * 0.5. An item whose description was a perfect match ranked as if it matched but was never returned. Both description terms are written as COALESCE(i.description, '') to match idx_items_desc_trgm, which is built on that expression rather than the bare column. Verified with EXPLAIN over 50k items: the four-way OR plans as a BitmapOr across idx_items_title_trgm and idx_items_desc_trgm, and holds through the generic-plan window under PREPARE/EXECUTE. Facet counts share the clause, so counts and results widen together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 18:23 UTC
Commit: 80098a611c3a64357bfc1d2483db8636a8e48db6
Parent: 8ed96c9
2 files changed, +90 insertions, -0 deletions
@@ -49,9 +49,15 @@ impl DiscoverFilters<'_> {
49 49 // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
50 50 // The ILIKE escapes \, %, _ in the search term to prevent LIKE metacharacter interpretation.
51 51
52 + // The description terms are written as `COALESCE(i.description, '')` to match
53 + // `idx_items_desc_trgm`, which is built on that expression rather than on the
54 + // bare column. Written any other way Postgres will not use the index, and this
55 + // clause runs on the busiest public page.
52 56 const ITEM_SEARCH_CLAUSE: &str = r#" AND (
53 57 i.title % $1
54 58 OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
59 + OR COALESCE(i.description, '') % $1
60 + OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
55 61 )"#;
56 62
57 63 const PROJECT_SEARCH_CLAUSE: &str = r#" AND (
@@ -63,6 +69,7 @@ const PROJECT_SEARCH_CLAUSE: &str = r#" AND (
63 69
64 70 const ITEM_SEARCH_CLAUSE_SHORT: &str = r#" AND (
65 71 i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
72 + OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
66 73 )"#;
67 74
68 75 const PROJECT_SEARCH_CLAUSE_SHORT: &str = r#" AND (
@@ -110,6 +110,60 @@ async fn search_finds_published_item_by_title() {
110 110 );
111 111 }
112 112
113 + /// The relevance expression has always scored `similarity(description) * 0.5`,
114 + /// but the match clause only looked at the title — an item whose description was
115 + /// a perfect match ranked as if it matched and was then never returned.
116 + #[tokio::test]
117 + async fn search_finds_published_item_by_description() {
118 + let mut h = TestHarness::new().await;
119 + let (_creator, item_id) =
120 + make_discoverable_item(&mut h, "desccreator", "Unrelated Title", "digital").await;
121 + sqlx::query("UPDATE items SET description = 'A hand-built theremin kit' WHERE id = $1::uuid")
122 + .bind(&item_id)
123 + .execute(&h.db)
124 + .await
125 + .unwrap();
126 +
127 + let resp = h.client.get("/discover?mode=items&q=theremin").await;
128 + assert!(resp.status.is_success());
129 + assert!(
130 + resp.text.contains("Unrelated Title"),
131 + "Search should match on description, not title only"
132 + );
133 + }
134 +
135 + /// The 0.5 weighting on description similarity is the reason the relevance
136 + /// expression is written the way it is. Widening the match clause must not
137 + /// flatten that ordering.
138 + #[tokio::test]
139 + async fn title_match_outranks_description_only_match() {
140 + let mut h = TestHarness::new().await;
141 + let (_, desc_item) =
142 + make_discoverable_item(&mut h, "rankdesc", "Something Else", "digital").await;
143 + sqlx::query("UPDATE items SET description = 'mentions theremin in passing' WHERE id = $1::uuid")
144 + .bind(&desc_item)
145 + .execute(&h.db)
146 + .await
147 + .unwrap();
148 + let (_, title_item) =
149 + make_discoverable_item(&mut h, "ranktitle", "Theremin", "digital").await;
150 + sqlx::query("UPDATE items SET description = 'no match here' WHERE id = $1::uuid")
151 + .bind(&title_item)
152 + .execute(&h.db)
153 + .await
154 + .unwrap();
155 +
156 + // No explicit sort: a search term makes match_score DESC the default order.
157 + let resp = h.client.get("/discover?mode=items&q=theremin").await;
158 + assert!(resp.status.is_success());
159 + let title_pos = resp.text.find("Theremin").expect("title match should be returned");
160 + let desc_pos = resp.text.find("Something Else").expect("description match should be returned");
161 + assert!(
162 + title_pos < desc_pos,
163 + "Title match must outrank a description-only match (title at {title_pos}, description at {desc_pos})"
164 + );
165 + }
166 +
113 167 #[tokio::test]
114 168 async fn search_does_not_leak_draft_items() {
115 169 let mut h = TestHarness::new().await;
@@ -385,6 +439,35 @@ async fn facet_counts_match_the_whole_tag_subtree() {
385 439 /// `ai_tier` was applied to the result set but to none of the four facet
386 440 /// queries, so selecting "Handmade only" narrowed the listing while the sidebar
387 441 /// kept reporting counts for the unfiltered catalog.
442 + /// Facet counts and results share `ITEM_SEARCH_CLAUSE` — that parity was the
443 + /// point of commit a668df6f. Widening the clause to match descriptions must
444 + /// therefore widen the counts by the same amount, or the sidebar would report a
445 + /// zero next to a facet the results list is populating.
446 + #[tokio::test]
447 + async fn facet_counts_widen_with_description_matches() {
448 + let mut h = TestHarness::new().await;
449 + let (_, item_id) =
450 + make_discoverable_item(&mut h, "facetdesc", "Nothing Relevant", "audio").await;
451 + sqlx::query("UPDATE items SET description = 'a hand-built theremin kit' WHERE id = $1::uuid")
452 + .bind(&item_id)
453 + .execute(&h.db)
454 + .await
455 + .expect("set description");
456 +
457 + let mut filters = filters_for(&[]);
458 + let term = String::from("theremin");
459 + filters.search = Some(&term);
460 + let counts = makenotwork::db::discover::get_item_type_counts(&h.db, &filters)
461 + .await
462 + .expect("item type counts");
463 + let total: i64 = counts.iter().map(|c| c.count).sum();
464 +
465 + assert_eq!(
466 + total, 1,
467 + "facet counts must count a description-only match, matching the results query"
468 + );
469 + }
470 +
388 471 #[tokio::test]
389 472 async fn facet_counts_apply_the_ai_tier_filter() {
390 473 let mut h = TestHarness::new().await;