Skip to content

Commit 642946d

Browse files
committed
Search fixes
1 parent c6c2c52 commit 642946d

13 files changed

Lines changed: 465 additions & 151 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,6 @@ Need a tooltip component for consistency. List to add tooltips to:
170170

171171
## Support Pages to Style
172172

173-
- /404 (should show search results based on query)
174-
- /search
175173
- Bug reporter modal
176174
- Email templates
177175

@@ -213,8 +211,3 @@ All code samples in this article are licensed under the MIT License. Feel free t
213211
- Home page reorganization: move the "What I Deliver" box from the Hero into the Backstage image. Move the Backstage image / video to the hero.
214212

215213
- Add a "Preview Special" item to our Download CTA that lets the user know the Deep Dive content can be previewed in HTML format, and offer a switch to it.
216-
217-
218-
## Search
219-
220-
- The modal on the search page should be different than returning results - maybe the page results should update as you type

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@
5555
"lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"",
5656
"lint:tsc:check": "npm run sync && tsc --noEmit -p tsconfig.json --pretty false",
5757
"search:reindex": "python3 scripts/search-index.py",
58-
"search:relevancy": "python3 scripts/test_search_relevancy.py",
58+
"search:relevancy": "python3 scripts/search_relevancy.py",
59+
"search:relevancy:reranking": "python3 scripts/search_relevancy.py --reranking",
5960
"sync": "FORCE_COLOR=1 npx astro sync",
6061
"test": "npm run test:unit && npm run test:e2e",
6162
"test:coverage": "FORCE_COLOR=1 npx vitest run --coverage",

‎scripts/search_relevancy.py‎

Lines changed: 55 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from dataclasses import dataclass
77
from pathlib import Path
88
from typing import Any, Final
9-
from urllib.parse import urlparse
109

1110
from dotenv import load_dotenv
1211
from upstash_search import Search
@@ -17,9 +16,9 @@
1716

1817

1918
@dataclass(slots=True)
20-
class ArticleRelevancyRow:
21-
path: str
19+
class SearchRelevancyRow:
2220
title: str
21+
path: str
2322
score: float
2423

2524

@@ -61,132 +60,88 @@ def get_value(source: Any, key: str) -> Any:
6160
return getattr(source, key, None)
6261

6362

64-
def normalize_path(value: Any) -> str:
65-
if not isinstance(value, str):
66-
return ''
67-
68-
trimmed = value.strip()
69-
if not trimmed:
70-
return ''
71-
72-
if trimmed.startswith('http://') or trimmed.startswith('https://'):
73-
parsed = urlparse(trimmed)
74-
path = parsed.path or ''
75-
suffix = ''
76-
if parsed.query:
77-
suffix = f'?{parsed.query}'
78-
if parsed.fragment:
79-
suffix = f'{suffix}#{parsed.fragment}'
80-
return f'{path}{suffix}'
81-
82-
return trimmed
83-
84-
85-
def get_base_article_path(path: str) -> str:
86-
without_query = path.split('?', 1)[0]
87-
without_fragment = without_query.split('#', 1)[0]
88-
return without_fragment.rstrip('/')
89-
90-
91-
def is_article_path(path: str) -> bool:
92-
return path.startswith('/articles/') and len(path.split('/')) >= 3
93-
94-
95-
def slug_to_title(slug: str) -> str:
96-
words = [word for word in slug.replace('_', '-').split('-') if word]
97-
return ' '.join(word.capitalize() for word in words)
98-
99-
100-
def get_article_title(result: Any, article_path: str) -> str:
63+
def get_result_title(result: Any) -> str:
10164
content = get_value(result, 'content') or {}
102-
title = get_value(content, 'title')
65+
title = get_value(content, 'title') or get_value(content, 'name') or get_value(result, 'id')
10366
if isinstance(title, str) and title.strip():
104-
result_path = normalize_path(
105-
get_value(get_value(result, 'metadata') or {}, 'path')
106-
or get_value(get_value(result, 'metadata') or {}, 'url')
107-
or get_value(content, 'path')
108-
or get_value(content, 'url')
109-
)
110-
if '#' not in result_path:
111-
return title.strip()
67+
return title.strip()
68+
return '(untitled result)'
11269

113-
slug = article_path.rsplit('/', 1)[-1]
114-
return slug_to_title(slug)
11570

71+
def get_result_path(result: Any) -> str:
72+
content = get_value(result, 'content') or {}
73+
metadata = get_value(result, 'metadata') or {}
74+
path = (
75+
get_value(metadata, 'path')
76+
or get_value(metadata, 'url')
77+
or get_value(content, 'path')
78+
or get_value(content, 'url')
79+
)
11680

117-
def collect_article_relevancy_rows(results: list[Any]) -> list[ArticleRelevancyRow]:
118-
grouped: dict[str, ArticleRelevancyRow] = {}
81+
if isinstance(path, str) and path.strip():
82+
return path.strip()
11983

120-
for result in results:
121-
metadata = get_value(result, 'metadata') or {}
122-
content = get_value(result, 'content') or {}
123-
path = normalize_path(
124-
get_value(metadata, 'path')
125-
or get_value(metadata, 'url')
126-
or get_value(content, 'path')
127-
or get_value(content, 'url')
128-
)
84+
return '(no path)'
12985

130-
article_path = get_base_article_path(path)
131-
if not is_article_path(article_path):
132-
continue
13386

87+
def collect_search_relevancy_rows(results: list[Any]) -> list[SearchRelevancyRow]:
88+
rows: list[SearchRelevancyRow] = []
89+
90+
for result in results:
13491
raw_score = get_value(result, 'score')
13592
if not isinstance(raw_score, int | float):
13693
continue
13794

138-
candidate = ArticleRelevancyRow(
139-
path=article_path,
140-
title=get_article_title(result, article_path),
141-
score=float(raw_score),
95+
rows.append(
96+
SearchRelevancyRow(
97+
title=get_result_title(result),
98+
path=get_result_path(result),
99+
score=float(raw_score),
100+
)
142101
)
143102

144-
existing = grouped.get(article_path)
145-
if existing is None:
146-
grouped[article_path] = candidate
147-
continue
148-
149-
if candidate.score > existing.score:
150-
title = existing.title
151-
if '#' not in path and candidate.title.strip():
152-
title = candidate.title
153-
grouped[article_path] = ArticleRelevancyRow(path=article_path, title=title, score=candidate.score)
154-
continue
155-
156-
if '#' not in path and candidate.title.strip() and existing.title == slug_to_title(article_path.rsplit('/', 1)[-1]):
157-
grouped[article_path] = ArticleRelevancyRow(path=article_path, title=candidate.title, score=existing.score)
158-
159-
return sorted(grouped.values(), key=lambda row: row.score, reverse=True)
103+
return rows
160104

161105

162106
def format_score(score: float) -> str:
163107
return f'{score:.6f}'.rstrip('0').rstrip('.')
164108

165109

166-
def format_results_table(rows: list[ArticleRelevancyRow]) -> str:
167-
title_header = 'Article Title'
110+
def format_results_table(rows: list[SearchRelevancyRow], *, show_path: bool = True) -> str:
111+
title_header = 'Result Title'
112+
path_header = 'Path'
168113
score_header = 'Relevancy Score'
169114

170115
title_width = max([len(title_header), *(len(row.title) for row in rows)])
116+
path_width = max([len(path_header), *(len(row.path) for row in rows)]) if show_path else 0
171117
score_values = [format_score(row.score) for row in rows]
172118
score_width = max([len(score_header), *(len(value) for value in score_values)])
173119

120+
if show_path:
121+
separator = f'+-{'-' * title_width}-+-{'-' * path_width}-+-{'-' * score_width}-+'
122+
header = (
123+
f'| {title_header.ljust(title_width)} | '
124+
f'{path_header.ljust(path_width)} | '
125+
f'{score_header.rjust(score_width)} |'
126+
)
127+
body = [
128+
f'| {row.title.ljust(title_width)} | {row.path.ljust(path_width)} | {format_score(row.score).rjust(score_width)} |'
129+
for row in rows
130+
]
131+
return '\n'.join([separator, header, separator, *body, separator])
132+
174133
separator = f'+-{'-' * title_width}-+-{'-' * score_width}-+'
175134
header = f'| {title_header.ljust(title_width)} | {score_header.rjust(score_width)} |'
176-
177-
body = [
178-
f'| {row.title.ljust(title_width)} | {format_score(row.score).rjust(score_width)} |'
179-
for row in rows
180-
]
135+
body = [f'| {row.title.ljust(title_width)} | {format_score(row.score).rjust(score_width)} |' for row in rows]
181136

182137
return '\n'.join([separator, header, separator, *body, separator])
183138

184139

185-
def run_search(*, query: str, limit: int, index_name: str | None = None) -> list[ArticleRelevancyRow]:
140+
def run_search(*, query: str, limit: int, index_name: str | None = None, reranking: bool = False) -> list[SearchRelevancyRow]:
186141
url, token, default_index_name = resolve_upstash_credentials()
187142
client = Search(url=url, token=token)
188-
raw_results = client.index(index_name or default_index_name).search(query, limit=limit, reranking=True)
189-
return collect_article_relevancy_rows(raw_results)
143+
raw_results = client.index(index_name or default_index_name).search(query, limit=limit, reranking=reranking)
144+
return collect_search_relevancy_rows(raw_results)
190145

191146

192147
def parse_args(argv: list[str]) -> argparse.Namespace:
@@ -196,6 +151,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
196151
parser.add_argument('query', help='Search query. Wrap multi-word queries in quotes.')
197152
parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT, help='Maximum Upstash results to request.')
198153
parser.add_argument('--index-name', default=None, help='Override the Upstash index name.')
154+
parser.add_argument('--reranking', action='store_true', help='Enable Upstash reranking for the query.')
155+
parser.add_argument('--hide-path', action='store_true', help='Hide the path column from the output table.')
199156
return parser.parse_args(argv)
200157

201158

@@ -204,16 +161,16 @@ def main(argv: list[str] | None = None) -> int:
204161

205162
try:
206163
load_environment()
207-
rows = run_search(query=args.query, limit=args.limit, index_name=args.index_name)
164+
rows = run_search(query=args.query, limit=args.limit, index_name=args.index_name, reranking=args.reranking)
208165
except Exception as exc: # noqa: BLE001
209166
print(f'[search:relevancy] {exc}', file=sys.stderr)
210167
return 1
211168

212169
if not rows:
213-
print(f'No article results found for query: {args.query}')
170+
print(f'No results found for query: {args.query}')
214171
return 0
215172

216-
print(format_results_table(rows))
173+
print(format_results_table(rows, show_path=not args.hide_path))
217174
return 0
218175

219176

‎scripts/test_search_relevancy.py‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from scripts.search_relevancy import ArticleRelevancyRow, collect_article_relevancy_rows, format_results_table
1+
from scripts.search_relevancy import SearchRelevancyRow, collect_search_relevancy_rows, format_results_table
22

33

4-
def test_collect_article_relevancy_rows_groups_sections_by_article() -> None:
5-
rows = collect_article_relevancy_rows(
4+
def test_collect_search_relevancy_rows_preserves_all_scored_results() -> None:
5+
rows = collect_search_relevancy_rows(
66
[
77
{
88
'content': {'title': 'Introduction'},
@@ -19,27 +19,48 @@ def test_collect_article_relevancy_rows_groups_sections_by_article() -> None:
1919
'metadata': {'path': '/case-studies/platform-migration'},
2020
'score': 0.95,
2121
},
22+
{
23+
'content': {'title': 'Missing Score'},
24+
'metadata': {'path': '/articles/missing-score'},
25+
},
2226
]
2327
)
2428

2529
assert rows == [
26-
ArticleRelevancyRow(
27-
path='/articles/typescript-best-practices',
30+
SearchRelevancyRow(title='Introduction', path='/articles/typescript-best-practices#introduction', score=0.72),
31+
SearchRelevancyRow(
2832
title='TypeScript Best Practices for Modern Development',
33+
path='/articles/typescript-best-practices',
2934
score=1.0,
30-
)
35+
),
36+
SearchRelevancyRow(title='Case Study', path='/case-studies/platform-migration', score=0.95),
3137
]
3238

3339

34-
def test_format_results_table_renders_two_columns() -> None:
40+
def test_format_results_table_renders_path_column_by_default() -> None:
3541
table = format_results_table(
3642
[
37-
ArticleRelevancyRow(path='/articles/one', title='Article One', score=1.0),
38-
ArticleRelevancyRow(path='/articles/two', title='Article Two', score=0.875),
43+
SearchRelevancyRow(title='Article One', path='/articles/one', score=1.0),
44+
SearchRelevancyRow(title='Article Two', path='/articles/two', score=0.875),
3945
]
4046
)
4147

42-
assert 'Article Title' in table
48+
assert 'Result Title' in table
49+
assert 'Path' in table
4350
assert 'Relevancy Score' in table
4451
assert 'Article One' in table
45-
assert '0.875' in table
52+
assert '/articles/one' in table
53+
assert '0.875' in table
54+
55+
56+
def test_format_results_table_can_hide_path_column() -> None:
57+
table = format_results_table(
58+
[
59+
SearchRelevancyRow(title='Article One', path='/articles/one', score=1.0),
60+
],
61+
show_path=False,
62+
)
63+
64+
assert 'Result Title' in table
65+
assert 'Path' not in table
66+
assert '/articles/one' not in table

0 commit comments

Comments
 (0)