Skip to content

Commit a402672

Browse files
committed
Add chunking with metadata to Upstash Search indexing
1 parent f961cd5 commit a402672

10 files changed

Lines changed: 366 additions & 40 deletions

File tree

‎_TODO.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,9 @@ All code samples in this article are licensed under the MIT License. Feel free t
211211
- Home page reorganization: move the "What I Deliver" box from the Hero into the Backstage image. Move the Backstage image / video to the hero.
212212

213213
- 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.
214+
215+
## Search
216+
217+
The indexer globs for files like `src/content/articles/**/index.mdx` and `src/content/services/**/index.md`, reads the raw file bytes, extracts YAML frontmatter, then runs regex-based stripping (MDX imports, JSX tags, Markdown syntax) to produce plain text for the fullContent field.
218+
219+
No build step, no HTML parsing, no dev server fetch.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
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:content-length": "python3 scripts/search_content_lengths.py",
5859
"search:relevancy": "python3 scripts/search_relevancy.py",
5960
"search:relevancy:reranking": "python3 scripts/search_relevancy.py --reranking",
6061
"sync": "FORCE_COLOR=1 npx astro sync",

‎scripts/search-index.py‎

Lines changed: 157 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""
2-
Page-level Upstash Search indexer.
2+
Section-chunked Upstash Search indexer.
33
4-
Reads Markdown/MDX content files from src/content/, extracts frontmatter
5-
and body text, and upserts one document per page into the Upstash Search
6-
index. Replaces the crawler-based approach that created per-section chunks.
4+
Reads Markdown/MDX content files from src/content/, splits each page into
5+
h2-level sections, and upserts one document per section chunk into the
6+
Upstash Search index. Each chunk stays within the 4 096-character content
7+
limit. At query time the responder deduplicates chunks that share the same
8+
canonical page path, returning one hit per page.
79
810
Usage:
911
python3 scripts/search-index.py # full reindex (drop + upsert)
@@ -28,6 +30,7 @@
2830

2931
DEFAULT_INDEX_NAME: Final[str] = "default"
3032

33+
REPO_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
3134
CONTENT_ROOT: Final[Path] = Path(__file__).resolve().parents[1] / "src" / "content"
3235

3336

@@ -56,13 +59,28 @@ class PageDocument:
5659
path: str
5760
title: str
5861
description: str
59-
body_text: str
62+
raw_body: str
6063
collection: str
64+
source_path: str
65+
66+
67+
@dataclass(slots=True, frozen=True)
68+
class ChunkDocument:
69+
id: str
70+
path: str
71+
title: str
72+
section_heading: str
73+
section_content: str
74+
collection: str
75+
source_path: str
76+
77+
78+
CHUNK_CONTENT_LIMIT: Final[int] = 4096
79+
CONTENT_OVERHEAD: Final[int] = 150
6180

6281

6382
def load_environment() -> None:
64-
repo_root = Path(__file__).resolve().parents[1]
65-
env_path = repo_root / ".env.development"
83+
env_path = REPO_ROOT / ".env.development"
6684
if not env_path.exists():
6785
raise ValueError("Missing .env.development. Use `npm run dev:env` to run with it, or create the file locally.")
6886
load_dotenv(dotenv_path=env_path)
@@ -153,6 +171,113 @@ def extract_plain_text(body: str) -> str:
153171
return text.strip()
154172

155173

174+
# ---------------------------------------------------------------------------
175+
# Section chunking
176+
# ---------------------------------------------------------------------------
177+
178+
SECTION_SPLIT_RE: Final[re.Pattern[str]] = re.compile(
179+
r"^(#{2}\s+.+)$", re.MULTILINE,
180+
)
181+
182+
183+
def split_into_sections(raw_body: str) -> list[tuple[str, str]]:
184+
"""Split raw markdown into (heading, body) pairs by h2 headings."""
185+
parts = SECTION_SPLIT_RE.split(raw_body)
186+
sections: list[tuple[str, str]] = []
187+
188+
intro = parts[0].strip()
189+
if intro:
190+
sections.append(("", intro))
191+
192+
for i in range(1, len(parts), 2):
193+
heading = parts[i].lstrip("#").strip()
194+
body = parts[i + 1].strip() if i + 1 < len(parts) else ""
195+
if body:
196+
sections.append((heading, body))
197+
198+
if not sections and raw_body.strip():
199+
sections.append(("", raw_body.strip()))
200+
201+
return sections
202+
203+
204+
def chunk_text(text: str, budget: int) -> list[str]:
205+
"""Split text into pieces that each fit within the character budget."""
206+
if len(text) <= budget:
207+
return [text]
208+
209+
chunks: list[str] = []
210+
paragraphs = text.split("\n\n")
211+
current = ""
212+
213+
for para in paragraphs:
214+
candidate = f"{current}\n\n{para}" if current else para
215+
if len(candidate) <= budget:
216+
current = candidate
217+
else:
218+
if current:
219+
chunks.append(current)
220+
if len(para) > budget:
221+
for start in range(0, len(para), budget):
222+
chunks.append(para[start:start + budget])
223+
current = ""
224+
else:
225+
current = para
226+
227+
if current:
228+
chunks.append(current)
229+
230+
return chunks
231+
232+
233+
def chunk_page(page: PageDocument) -> list[ChunkDocument]:
234+
"""Split a page into section-level chunks that fit Upstash's character limit."""
235+
sections = split_into_sections(page.raw_body)
236+
chunks: list[ChunkDocument] = []
237+
238+
for heading, raw_section_body in sections:
239+
section_text = extract_plain_text(raw_section_body)
240+
if not section_text:
241+
continue
242+
243+
overhead = len(page.title) + len(heading) + CONTENT_OVERHEAD
244+
budget = max(CHUNK_CONTENT_LIMIT - overhead, 500)
245+
text_parts = chunk_text(section_text, budget)
246+
247+
for text_part in text_parts:
248+
chunk_idx = len(chunks)
249+
chunks.append(ChunkDocument(
250+
id=f"{page.path}#chunk-{chunk_idx}",
251+
path=page.path,
252+
title=page.title,
253+
section_heading=heading,
254+
section_content=text_part,
255+
collection=page.collection,
256+
source_path=page.source_path,
257+
))
258+
259+
if not chunks and page.description:
260+
chunks.append(ChunkDocument(
261+
id=f"{page.path}#chunk-0",
262+
path=page.path,
263+
title=page.title,
264+
section_heading="",
265+
section_content=page.description,
266+
collection=page.collection,
267+
source_path=page.source_path,
268+
))
269+
270+
return chunks
271+
272+
273+
def chunk_pages(pages: list[PageDocument]) -> list[ChunkDocument]:
274+
"""Chunk all pages into section-level documents."""
275+
chunks: list[ChunkDocument] = []
276+
for page in pages:
277+
chunks.extend(chunk_page(page))
278+
return chunks
279+
280+
156281
# ---------------------------------------------------------------------------
157282
# Content discovery
158283
# ---------------------------------------------------------------------------
@@ -198,15 +323,15 @@ def discover_pages(collections: list[str] | None = None) -> list[PageDocument]:
198323

199324
slug = slug_from_path(content_file, collection_dir)
200325
url_path = f"{config.url_prefix}/{slug}"
201-
body_text = extract_plain_text(body)
202326

203327
pages.append(PageDocument(
204328
id=url_path,
205329
path=url_path,
206330
title=title,
207331
description=description,
208-
body_text=body_text,
332+
raw_body=body,
209333
collection=config.name,
334+
source_path=str(content_file.relative_to(REPO_ROOT)),
210335
))
211336

212337
return pages
@@ -231,38 +356,38 @@ def drop_index(*, upstash_url: str, upstash_token: str, index_name: str) -> None
231356
UPSERT_BATCH_SIZE: Final[int] = 50
232357

233358

234-
def upsert_pages(
359+
def upsert_chunks(
235360
*,
236361
upstash_url: str,
237362
upstash_token: str,
238363
index_name: str,
239-
pages: list[PageDocument],
364+
chunks: list[ChunkDocument],
240365
) -> int:
241-
"""Upsert pages into Upstash Search. Returns count of documents upserted."""
366+
"""Upsert section chunks into Upstash Search. Returns count of documents upserted."""
242367
client = Search(url=upstash_url, token=upstash_token)
243368
index = client.index(index_name)
244369

245370
total = 0
246-
for i in range(0, len(pages), UPSERT_BATCH_SIZE):
247-
batch = pages[i:i + UPSERT_BATCH_SIZE]
371+
for i in range(0, len(chunks), UPSERT_BATCH_SIZE):
372+
batch = chunks[i:i + UPSERT_BATCH_SIZE]
248373
documents = [
249374
{
250-
"id": page.id,
375+
"id": chunk.id,
251376
"content": {
252-
"title": page.title,
253-
"description": page.description,
254-
"fullContent": page.body_text,
377+
"title": chunk.title,
378+
"sectionHeading": chunk.section_heading,
379+
"sectionContent": chunk.section_content,
255380
},
256381
"metadata": {
257-
"path": page.path,
258-
"collection": page.collection,
382+
"path": chunk.path,
383+
"collection": chunk.collection,
259384
},
260385
}
261-
for page in batch
386+
for chunk in batch
262387
]
263388
index.upsert(documents)
264389
total += len(batch)
265-
print(f"[search:reindex] Upserted batch {i // UPSERT_BATCH_SIZE + 1} ({total}/{len(pages)} pages)")
390+
print(f"[search:reindex] Upserted batch {i // UPSERT_BATCH_SIZE + 1} ({total}/{len(chunks)} chunks)")
266391

267392
return total
268393

@@ -272,7 +397,7 @@ def upsert_pages(
272397
# ---------------------------------------------------------------------------
273398

274399
def main() -> int:
275-
parser = argparse.ArgumentParser(description="Page-level Upstash Search indexer.")
400+
parser = argparse.ArgumentParser(description="Section-chunked Upstash Search indexer.")
276401
parser.add_argument("--no-drop", action="store_true", help="Skip dropping the index before upserting.")
277402
parser.add_argument("--dry-run", action="store_true", help="Discover pages and print summary without writing to Upstash.")
278403
parser.add_argument("--collection", action="append", dest="collections", help="Only index specific collection(s). Can be repeated.")
@@ -286,25 +411,28 @@ def main() -> int:
286411
return 1
287412

288413
pages = discover_pages(args.collections)
289-
print(f"[search:reindex] Discovered {len(pages)} pages across {len(set(p.collection for p in pages))} collection(s).")
414+
chunks = chunk_pages(pages)
415+
collections_count = len(set(p.collection for p in pages))
416+
print(f"[search:reindex] Discovered {len(pages)} pages \u2192 {len(chunks)} chunks across {collections_count} collection(s).")
290417

291418
if args.dry_run:
292419
for page in pages:
293-
print(f" {page.collection:<15} {page.path:<80} {page.title}")
294-
print(f"\n[search:reindex] Dry run complete. {len(pages)} pages would be indexed.")
420+
page_chunks = chunk_page(page)
421+
print(f" {page.collection:<15} {page.path:<80} {len(page_chunks):>3} chunks {page.title}")
422+
print(f"\n[search:reindex] Dry run complete. {len(pages)} pages \u2192 {len(chunks)} chunks would be indexed.")
295423
return 0
296424

297425
try:
298426
if not args.no_drop:
299427
drop_index(upstash_url=upstash_url, upstash_token=upstash_token, index_name=index_name)
300428

301-
count = upsert_pages(
429+
count = upsert_chunks(
302430
upstash_url=upstash_url,
303431
upstash_token=upstash_token,
304432
index_name=index_name,
305-
pages=pages,
433+
chunks=chunks,
306434
)
307-
print(f"[search:reindex] Done. Indexed {count} pages into '{index_name}'.")
435+
print(f"[search:reindex] Done. Indexed {count} chunks ({len(pages)} pages) into '{index_name}'.")
308436
return 0
309437
except Exception as exc: # noqa: BLE001
310438
print(f"[search:reindex] {exc}", file=sys.stderr)

0 commit comments

Comments
 (0)