· 8 min read

Fixing the Chunking Split with Function-Boundary Chunks

The function name query score jumps from 0.56 to 0.71. The natural language query goes from rank 3 to rank 1. Here's what changes when each function gets its own chunk.

Part of a series on building production RAG over a real JavaScript codebase. Part 2 found two failure modes: chunking split (functions split across line-count windows) and vocabulary gap (runtime errors not in source). This post fixes the first one.


The hypothesis

Part 2 showed moveAndRemoveFileFromS3 split across two 50-line chunks: the declaration in one, the body in the other. 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 half a function mixed with unrelated code.

Before implementing anything, let’s be precise about what “better” means:

  • The function should rank higher for natural language queries about what it does
  • The score for the function name query should go up (less dilution from surrounding code)
  • The vocabulary gap query (NoSuchKey) should remain unfixed — we’re not touching that problem here

The extractor

The AllInterviews backend uses a consistent style for top-level functions:

async function name(...) { ... }
function name(...) { ... }

No arrow functions at the top level. That makes detection straightforward: match any line that starts with (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*\(')

i = 0
while i < len(lines):
    m = func_re.match(lines[i])
    if m:
        name = m.group(1)
        start = i
        depth = 0
        found_open = False
        for j in range(i, len(lines)):
            for ch in lines[j]:
                if ch == '{': depth += 1; found_open = True
                elif ch == '}': depth -= 1
            if found_open and depth <= 0:
                yield FunctionChunk(name, start+1, j+1, content)
                i = j + 1
                break

Known limitation: brace characters inside string literals are counted. In practice this is rare in AllInterviews — most string content doesn’t contain standalone { or }. A production-quality extractor would use a proper parser (tree-sitter). For this exercise, the simple approach works.


What got extracted

Running the extractor on routes/services/image.js (1026 lines):

Function                              Lines  Range
--------------------------------------------------
removeFile                               12  46–57
generateKeyFromBaseKey                    5  59–63
applyModification                        76  65–140
handleCompression                        46  142–187
compressAndUploadtoS3                    10  189–198
getThumbnailForImage                     10  200–209
...
moveAndRemoveFileFromS3                  17  479–495   ← target
slugify                                  15  497–511
...
s3ObjectExists                           11  603–613
deleteImageVariantsFromS3                74  615–688
...

35 functions extracted. The smallest is 5 lines (generateKeyFromBaseKey). The largest is 76 lines (applyModification). moveAndRemoveFileFromS3 is 17 lines — a single, self-contained chunk.

Notice what the extractor produced for the target function:

// lines 479–495 — exactly one chunk, exactly the function
async function moveAndRemoveFileFromS3(bucket, imageObj) {
  try {
    if (!imageObj.source || !imageObj.destination) return;
    if (imageObj.source === imageObj.destination) return;

    await s3
      .copyObject({
        Bucket: bucket,
        CopySource: `${bucket}/${imageObj.source}`,
        Key: `${imageObj.destination}`,
      })
      .promise();
    await s3.deleteObject({ Bucket: bucket, Key: imageObj.source }).promise();
  } catch (error) {
    console.log("moveAndRemoveFileFromS3 error", error, bucket, imageObj);
  }
}

Compare this to what the line-based chunker produced:

  • Chunk 441–490: 38 lines of unrelated code + the first 12 lines of this function
  • Chunk 481–530: 15 lines of this function + 35 lines of unrelated code

The function-boundary chunk has zero dilution. Every token in the embedding represents this function.


The results

Running the same four queries from Part 2 against both collections:

Query                               Line-based          Function-based
----------------------------------------------------------------------
moveAndRemoveFileFromS3             rank 1  (0.5596)    rank 1  (0.7093)
copy S3 object then delete source   rank 3  (0.5035)    rank 1  (0.5719)
S3 copyObject deleteObject bucket   rank 4  (0.5025)    rank 2  (0.5973)
S3 NoSuchKey missing key error      not in top 5        not in top 5

Three improvements, one unchanged.


Breaking down each result

Query 1: function name (moveAndRemoveFileFromS3)

Score jumps from 0.5596 to 0.7093 — a gain of +0.15. Same rank (1st), but the confidence is much higher.

Why? The line-based chunk had the function name in the declaration once and in the error log once — but those 12 lines of signal were embedded alongside 38 lines of unrelated S3 upload logic. The function-boundary chunk has the function name in the declaration, in the error log, and the entire embedding is anchored to those tokens.

Query 2: natural language (copy S3 object then delete source)

Jumps from rank 3 to rank 1. This is the most important fix.

The line-based rank 3 happened because lines 601–650 (s3ObjectExists, deleteImageVariantsFromS3) outscored the target — they also contain S3 deletion code and their chunks are less diluted. The function-boundary chunk for moveAndRemoveFileFromS3 now has nothing but copy-and-delete S3 logic, so it beats both.

Query 3: API call names (S3 copyObject deleteObject bucket key)

Goes from rank 4 to rank 2. The function-boundary chunk scores 0.5973 vs 0.5025 — a gain of +0.095. Rank 1 is deleteImageVariantsFromS3, which calls deleteObject multiple times and is semantically very close to this query. That’s correct behaviour — deleteImageVariantsFromS3 is also relevant.

Query 4: incident vocabulary (S3 NoSuchKey missing key error)

Still not in top 5. As predicted in Part 2, this is the vocabulary gap problem. The function-boundary chunk still contains zero occurrences of “NoSuchKey”. This confirms the two problems are independent: better chunking fixes dilution, not absence.


Why the score jump from 0.56 to 0.71 matters

The score gap between “correct answer” and “wrong answer” determines how safely you can threshold.

With line-based chunking:

rank 1: 0.5596  moveAndRemoveFileFromS3 (declaration chunk — correct but diluted)
rank 2: 0.5208  image.js:1-50 (import block — wrong)
gap = 0.039

With function-boundary chunking:

rank 1: 0.7093  moveAndRemoveFileFromS3 (full function — correct)
rank 2: ...     next result
gap is much larger

A gap of 0.039 means a threshold of 0.55 would pass the correct answer but also pass the import block. A score of 0.71 for the correct answer with a large gap means a threshold of 0.65 confidently separates signal from noise.

Score compression makes thresholds fragile; wider gaps make them reliable.


The chunk count difference

Line-based: 26 chunks for 1026 lines. Function-based: 35 chunks for 35 functions.

More chunks, but each one is smaller and focused. Total token count is the same (same source code). The difference is that every chunk now has a clear semantic identity: “this chunk is function X.”

There’s also no overlap needed. Overlap in line-based chunking exists to hedge against boundary misses. When chunks are function-sized, there are no boundary misses — the chunk starts at the function declaration and ends at the closing brace.


What this doesn’t fix

Three things the function extractor doesn’t handle:

Class methods. If a function is defined inside a class:

class ImageProcessor {
  async move(bucket, obj) { ... }
}

The extractor won’t find move — the pattern matches top-level declarations only. AllInterviews doesn’t use classes extensively in the backend, so this isn’t a real gap here. A production extractor would need to handle it.

Arrow function assignments.

const processImage = async (config) => {
  ...
};

Not matched by the current pattern. Again, the AllInterviews backend uses function declarations almost exclusively at the top level.

The vocabulary gap. Still unfixed. S3 NoSuchKey still returns nothing. That requires richer indexed text — Part 4.


Summary

Line-basedFunction-based
Chunks from image.js2635
Chunk sizefixed 50 linesvariable (5–76 lines)
Function name query rank11
Function name query score0.55960.7093 (+0.15)
Natural language query rank31
API call query rank42
Incident vocabulary querynot foundnot found (unchanged)

The chunking split is fixed. The vocabulary gap is Part 4.


Next: Part 4 — closing the vocabulary gap with LLM-generated function summaries: one embedding call per function at index time, indexed text that spans both source vocabulary and error vocabulary.

See also