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
810Usage:
911 python3 scripts/search-index.py # full reindex (drop + upsert)
2830
2931DEFAULT_INDEX_NAME : Final [str ] = "default"
3032
33+ REPO_ROOT : Final [Path ] = Path (__file__ ).resolve ().parents [1 ]
3134CONTENT_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
6382def 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
231356UPSERT_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
274399def 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