· 10 min read

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-basedFunction-based
Chunks from image.js2738
Chunk sizefixed 50 linesvariable (1–95 lines)
Functions foundall, but padded/sometimes fragmentedall 38, exactly bounded
Arrow functions / class methodsinvisible (regex) or fine (tree-sitter)fine
moveAndRemoveFileFromS3 — name queryrank 3, 0.49rank 1, 0.71
moveAndRemoveFileFromS3 — API querynot foundrank 2, 0.60
UploadOriginaltoS3 — name queryrank 9-10, 0.42rank 3-4, 0.49
Incident vocabulary querynot foundnot 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.

See also