Fixing the Chunking Split with Function-Boundary Chunks
The function name query score jumps from 0.49 to 0.71. A function that was buried at rank 9 moves to rank 3. Here's what changes when each function gets its own chunk — and why the extractor ended up being a bigger decision than expected.
Part of a series on building production RAG over a real JavaScript codebase. Part 2 measured two failure modes: a chunking split (functions fragmented across line-count windows) and a vocabulary gap (runtime errors not in source). This post fixes the first one — and along the way, upgrading the extractor turned out to matter as much as the chunking strategy itself.
The hypothesis
Part 2 showed UploadOriginaltoS3 fragmented across two 50-line chunks: the declaration and most of the body in one, the closing logic in the next, neither self-contained. The hypothesis is simple: if each function gets its own chunk, search will work better because the embedding represents exactly one logical unit instead of a fragment mixed with unrelated code.
Before implementing anything, here’s what “better” means:
- Fragmented functions should stop being fragmented, and their scores should improve
- Functions that were already intact (like
moveAndRemoveFileFromS3, per Part 2) should still improve — they’re currently padded with ~30 lines of unrelated code inside their 50-line window, which is dilution even without literal fragmentation - The vocabulary gap query (
NoSuchKey) should remain unfixed — that’s not this post’s problem to solve
The extractor — and why a regex isn’t the real answer
The obvious approach: match (async )?function name(, then use brace-depth tracking to find the closing }.
func_re = re.compile(r'^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(')
# ... walk lines, track { and } depth, yield a chunk when depth returns to 0
This is what actually shipped first — and it looked fine on this one file, where regular function name() declarations dominate. But this backend has 454 JS/TS files, not one, and measuring the regex against all of them together tells a different story: it found 346 of 4248 real functions — about 8%. It only ever matched top-level function name( declarations. Every arrow function assigned to a const (666 of them) and every class method (3236 of them) was invisible to it, silently. On this specific file, the gap is smaller — 35 of 38 real functions match the regex pattern, missing 3 arrow functions (delay, hgSharp, checkFileExists) — but “smaller on one file” isn’t the same as “fine,” and a chunker that quietly skips 92% of the codebase’s real functions isn’t a rounding error, it’s a different tool than the one it looks like.
This codebase already had a real answer sitting one module away: app/services/code_graph/parser.py, a tree-sitter-based parser built for the call graph feature, which walks the actual concrete syntax tree instead of guessing from line prefixes. It already distinguished function / arrow / method correctly. The fix wasn’t writing an AST parser from scratch — it was reusing the one that already existed for something else.
def _chunk_js_by_ast(file_path, content, raw, language):
tree = parse_source(raw, Path(file_path).suffix)
fn_defs = extract_function_definitions(tree, content)
return [
CodeChunk(
chunk_id=..., file_path=file_path, language=language,
start_line=fn.start_line, end_line=fn.end_line,
content=raw[fn.start_byte:fn.end_byte].decode("utf-8", errors="ignore"),
function_name=fn.name, kind=fn.kind, # "function" | "arrow" | "method"
)
for fn in fn_defs
]
The parser walks three JS/TS shapes — function_declaration, lexical_declaration (catches const foo = () => {}), and method_definition (catches class methods) — and stops recursing once it enters a function body, so nested functions aren’t collected as separate top-level chunks. No brace-counting, no risk of miscounting a { inside a string literal (a known limitation of the regex version).
What got extracted
Running the tree-sitter extractor on routes/services/image.js (1080 lines):
Function Kind Lines Range
----------------------------------------------------------------------
delay arrow 1 43-43
hgSharp arrow 1 45-45
removeFile function 12 47-58
generateKeyFromBaseKey function 5 60-64
applyModification function 95 66-160
handleCompression function 46 162-207
compressAndUploadtoS3 function 10 209-218
getThumbnailForImage function 10 220-229
saveThumbnailFromBuffer function 20 231-250
generateThumbnailAndUploadtoS3 function 10 252-261
convertToJPEGFromBuffer function 23 263-285
handleCorruptImage function 30 287-316
UploadOriginaltoS3 function 17 318-334 ← split under line-based chunking
getSignUrls function 20 336-355
getSignUrlsWithKey function 25 357-381
getSignUrlsWithBuffer function 25 383-407
getBufferFromBucket function 13 409-421
getImagesDimensions function 21 423-443
getNormalAndCompressedImages function 43 445-487
replaceFunc function 9 489-497
moveAndRemoveFileFromS3 function 17 499-515 ← target
slugify function 15 517-531
uploadImageByConfig function 72 533-604
deleteImageFromWordPressOrS3 function 10 609-618
buildVariantKeyFromSource function 9 650-658
s3ObjectExists function 11 660-670
deleteImageVariantsFromS3 function 74 672-745
deleteImage function 42 748-789
findMediaItem function 38 791-828
checkFileExists arrow 16 830-845
uploadMultipleImages function 72 847-918
convertFilename function 13 920-932
revertImageToOriginalUpload function 28 934-961
saveImageToOriginal function 14 963-976
checkForImageKeyIfExists function 7 978-984
fixTiffImage function 19 986-1004
fixFailedCompressedImages function 33 1006-1038
convertTiffImage function 13 1040-1052
38 functions extracted (the regex would have found 35, silently dropping delay, hgSharp, and checkFileExists). The smallest are single-line arrows. The largest is applyModification at 95 lines. moveAndRemoveFileFromS3 is 17 lines — a single, self-contained chunk with nothing else in it.
Compare that to what the line-based chunker does to the same function today: it lands inside the 481–530 window, but that window also contains 18 unrelated lines before it and 15 after. The function isn’t fragmented anymore (per Part 2, that changed as the file grew), but it’s still diluted — a third of that chunk’s embedding is representing something else.
The results
Running the same four queries from Part 2:
Query Line-based (padded) Function-boundary
--------------------------------------------------------------------------------
moveAndRemoveFileFromS3 rank 3 (0.4927) rank 1 (0.7092)
copy S3 object then delete source rank 2 (0.4509) rank 1 (0.5722)
S3 copyObject deleteObject bucket key not found rank 2 (0.5972)
S3 NoSuchKey missing key error not found not found
And the function that was genuinely fragmented in Part 2:
Query Line-based (fragmented) Function-boundary
------------------------------------------------------------------------------------
UploadOriginaltoS3 rank 9-10 (0.419) rank 3-4 (0.46-0.49)
Three improvements, one unchanged, and the second table confirms the fix works on the case it was actually built for.
Query 1: function name (moveAndRemoveFileFromS3)
Score jumps from 0.4927 to 0.7092 — a gain of +0.22 — and rank improves from 3rd to 1st. The line-based chunk had the function name once, embedded alongside ~33 lines of unrelated code on either side. The function-boundary chunk has nothing but this function, so the entire embedding is anchored to its actual content.
Query 2: natural language (copy S3 object then delete source)
Rank 2 → rank 1, score 0.4509 → 0.5722. Removing the padding was enough to move it past whatever was previously edging it out.
Query 3: API call names (S3 copyObject deleteObject bucket key)
Goes from not found in the top 10 to rank 2 at 0.5972 — the biggest qualitative jump. This is the query where dilution was hurting most: copyObject and deleteObject are exact substrings of the function, but a 50-line window diffuses that signal across a lot of surrounding code that has nothing to do with S3 API calls.
Query 4: incident vocabulary (S3 NoSuchKey missing key error)
Still not found. As predicted, this is the vocabulary gap from Part 2, and it’s untouched by chunking strategy. The function-boundary chunk still contains zero occurrences of “NoSuchKey” — it’s the exact same source text, just without the padding. Confirms the two problems are independent: better chunking fixes dilution and fragmentation, not absence.
UploadOriginaltoS3: from fragmented to findable
Rank 9–10 (barely inside the top 10, both fragments weak) to rank 3–4 at a meaningfully higher score. This is the function-boundary fix doing exactly what it was built for — turning two incoherent half-functions back into one real one.
Why the score gaps matter
The gap between “correct answer” and “next-best wrong answer” determines how safely you can threshold.
With line-based chunking, moveAndRemoveFileFromS3 scored 0.4927 at rank 3 — meaning two other, less relevant chunks scored higher. Any threshold that passes the correct answer also passes noise ranked above it.
With function-boundary chunking, it’s rank 1 at 0.7092, with a real margin over whatever comes next. A tighter, more confident score with real separation from the pack is what makes a downstream threshold trustworthy instead of arbitrary.
The chunk count difference
Line-based: 27 chunks for 1080 lines. Function-based: 38 chunks for 38 functions — more chunks, but each one is smaller and semantically whole. There’s also no overlap needed: overlap in line-based chunking exists to hedge against boundary misses, and when chunks are function-sized there are no boundary misses to hedge against.
What this fixes that the regex version couldn’t
Two real gaps in the original regex extractor, both closed by switching to the tree-sitter parser instead of writing tighter regex rules:
Arrow function assignments. const foo = () => {} isn’t matched by a function name( pattern. On this file alone, that’s 3 real functions (delay, hgSharp, checkFileExists) the old version silently never indexed at all — not “indexed poorly,” just absent, the same way NoSuchKey is absent from the source in Part 2. Across the full 454-file backend, this pattern accounts for the majority of the 92% the regex missed.
Class methods. class Foo { bar() {} } — same story. The regex pattern only matches top-level function keyword declarations; a method defined inside a class body was invisible to it. This backend doesn’t lean heavily on classes in most files, which is part of why the gap looked small when this project first measured it on one file — but 3236 of the codebase’s 4248 real functions are methods, and all of them were being silently skipped.
Still not solved: the vocabulary gap. S3 NoSuchKey missing key error still returns nothing. That requires richer indexed text — Part 4.
Summary
| Line-based | Function-based | |
|---|---|---|
Chunks from image.js | 27 | 38 |
| Chunk size | fixed 50 lines | variable (1–95 lines) |
| Functions found | all, but padded/sometimes fragmented | all 38, exactly bounded |
| Arrow functions / class methods | invisible (regex) or fine (tree-sitter) | fine |
moveAndRemoveFileFromS3 — name query | rank 3, 0.49 | rank 1, 0.71 |
moveAndRemoveFileFromS3 — API query | not found | rank 2, 0.60 |
UploadOriginaltoS3 — name query | rank 9-10, 0.42 | rank 3-4, 0.49 |
| Incident vocabulary query | not found | not found (unchanged) |
The chunking split — and the dilution that persists even without literal fragmentation — is fixed. The vocabulary gap is Part 4.
Next: Part 4 — closing the vocabulary gap with LLM-generated function summaries, and what happens when you actually measure how often the model includes the specific error name.