What Actually Gets Indexed
A 1080-line file produces 27 chunks. Most boundaries fall mid-function, with no awareness of code structure. Here's exactly what gets lost — and why it matters for search quality.
Code RAG on a real production codebase — the backend, a Node.js/Express app with 454 JS/TS files. The problem: indexing source code for semantic search so agents can find relevant code when diagnosing and fixing bugs.
Why code is hard to index
Some retrieval targets are easy. A support ticket, a bug report, a log entry — each is a purpose-built document written in natural language about one thing. It was designed to be found.
Code RAG indexes source files. A source file is not a document — it’s hundreds or thousands of lines written for a compiler, not a search engine. Functions share files. Files share modules. A single file can contain ten unrelated functions, a hundred imported names, and boilerplate that’s identical across every file in the repo.
You can’t index a file as one document. You have to chunk it. And how you chunk it determines everything about what you can find.
How the current chunker works
The chunker in app/services/rag.py is simple: split by line count.
CHUNK_LINES = 50 # target lines per chunk
OVERLAP_LINES = 10 # lines shared between adjacent chunks
Take a file. Split it into windows of 50 lines. Each window starts 40 lines after the previous one (50 − 10 overlap). So a 1080-line file produces 27 chunks, each sharing 10 lines with its neighbors.
The overlap exists for a specific reason: if a function boundary falls near the end of a chunk, the next chunk will still contain enough of that function’s opening lines to be searchable. Without overlap, a 5-line function that starts at line 48 of a 50-line window would appear as 2 lines in one chunk and 3 lines in the next — neither enough to be useful.
The 10-line overlap is a hedge against boundary misses. It’s not a guarantee.
What the chunks look like in practice
routes/services/image.js is 1080 lines long. The indexer produces 27 chunks. Here’s the full list:
chunk lines 1– 50 const aws = require("aws-sdk");
chunk lines 41– 90 [blank — overlap with chunk 1]
chunk lines 81–130 ACL: "public-read",
chunk lines 121–170 // const scaleY = metadata.height / options.displayedHeight;
chunk lines 161–210 [blank]
chunk lines 201–250 });
chunk lines 241–290 ACL: "public-read",
chunk lines 281–330 } catch (err) {
chunk lines 321–370 const image = await fetch(signUrl);
chunk lines 361–410 const params = {
chunk lines 401–450 }
chunk lines 441–490 console.error("Error fetching image dimensions:", error);
chunk lines 481–530 }
chunk lines 521–570 .toString()
chunk lines 561–610 // media update endpoint; a JSON body returns 200 but does NOT save it
chunk lines 601–650 return null;
chunk lines 641–690 [blank]
chunk lines 681–730 [blank]
chunk lines 721–770 console.warn(`⚠️ Could not delete original image ${originalKey}: ${err...
chunk lines 761–810 [blank]
chunk lines 801–850 });
chunk lines 841–890 resolve(true);
chunk lines 881–930 [blank]
chunk lines 921–970 let segments = path.split("/");
chunk lines 961–1010 }
chunk lines 1001–1050 } catch (exception) {
chunk lines 1041–1080 return new Promise((res, rej) => {
The first thing to notice: chunk boundaries fall in the middle of function bodies constantly. Lines 81–130 starts mid-object-literal. Lines 281–330 starts with } catch (err) { — mid-conditional, closing a block that started in the previous chunk. Lines 1001–1050 starts mid-catch.
A reader dropped into any of these chunks cold has no idea what function they’re in.
A specific case: UploadOriginaltoS3
// line 318
async function UploadOriginaltoS3(bucket, baseFileKey, compressFileKey) {
try {
const signUrl = s3.getSignedUrl("getObject", { Bucket: bucket, Key: baseFileKey, Expires: 18000 });
const image = await fetch(signUrl);
let buffer = await image.buffer();
return s3
.putObject({
Bucket: bucket,
Key: compressFileKey,
Body: buffer,
ACL: "public-read",
})
.promise();
} catch (exception) {
console.error("compressAndUploadtoS3", exception, bucket, baseFileKey, compressFileKey);
}
}
// ends at line 334
It’s 17 lines. The 50-line chunker splits it across two chunks:
Chunk 281–330 — contains the full function declaration and most of the body, but cuts off mid-.promise() call, one line before the catch block.
Chunk 321–370 — picks up from const image = await fetch(signUrl), has the rest of the body and the full catch block — but no function declaration. A reader (or embedding model) looking at this chunk doesn’t know what function they’re in.
Here’s what each chunk actually looks like when you read them:
Chunk 281–330 (last 15 lines):
}
async function UploadOriginaltoS3(bucket, baseFileKey, compressFileKey) {
try {
const signUrl = s3.getSignedUrl("getObject", { Bucket: bucket, Key: baseFileKey, Expires: 18000 });
const image = await fetch(signUrl);
let buffer = await image.buffer();
return s3
.putObject({
Bucket: bucket,
Key: compressFileKey,
Body: buffer,
ACL: "public-read",
})
.promise(); // <-- CUT OFF HERE, catch block is in the next chunk
Chunk 321–370 (first 15 lines):
const image = await fetch(signUrl);
let buffer = await image.buffer();
return s3
.putObject({
Bucket: bucket,
Key: compressFileKey,
Body: buffer,
ACL: "public-read",
})
.promise();
} catch (exception) {
console.error("compressAndUploadtoS3", exception, bucket, baseFileKey, compressFileKey);
}
}
// next function starts here
Neither chunk is self-contained. The first has the function name but not the error handling. The second has the error handling but no function name.
A note on moveAndRemoveFileFromS3 — the function actually responsible for the S3 NoSuchKey incident this series is built around, and the target for every measurement in the parts that follow. At the time this post was first drafted, it lived at line 479 and was split across two chunks the same way UploadOriginaltoS3 is above. Since then, unrelated code was added earlier in the file, and the function now lives at line 499 — which happens to land entirely inside a single 50-line chunk (481–530). The function’s content hasn’t changed at all; only its position did. That’s not a coincidence worth glossing over — it’s the whole point of this post. Whether a function survives fixed-size chunking intact isn’t a property of the function. It’s a property of where it happens to land relative to an arbitrary 50-line ruler laid across the file, and that can change with a single unrelated commit somewhere earlier in the same file. UploadOriginaltoS3 is this post’s live example of the split precisely because, as of this measurement, it’s the one still exhibiting it.
Why overlap doesn’t fully fix this
The 10-line overlap between chunks 281–330 and 321–370 recovers some of the lost context — most of UploadOriginaltoS3’s body appears in both chunks. But overlap can’t fix the dilution problem. The first chunk is mostly unrelated code before the function starts. Its embedding is pulled toward whatever came before it, not toward the S3-upload-and-error-handling logic that actually matters for this function.
Overlap is a partial fix for the boundary problem. It doesn’t help when a function’s declaration and its closing logic land in different windows with unrelated context on either side.
What a good chunk looks like
For contrast, look at s3ObjectExists — it starts at line 660, well within the 641–690 chunk:
async function s3ObjectExists(s3Client, bucket, key) {
try {
await s3Client.headObject({ Bucket: bucket, Key: key }).promise();
return true;
} catch (err) {
if (err.code === 'NotFound') {
return false;
}
throw err; // propagate other errors like permission issues
}
}
This function is 11 lines, starts about a third of the way into the chunk, and is followed by the start of deleteImageVariantsFromS3. The chunk has the complete function with its declaration and is easy to understand without surrounding context.
Queries for S3 key existence, headObject, NotFound — they all land here cleanly.
The difference between s3ObjectExists (easy to find) and UploadOriginaltoS3 (hard to find) isn’t about what the functions do. It’s about where their declarations happen to fall relative to a 50-line window.
The structural problem
Fixed line-count chunking is unaware of code structure. It doesn’t know what a function is. It doesn’t know where one ends and another begins. It slices the file into equal-size strips and embeds whatever falls in each strip.
This works reasonably well for large functions — a 100-line function will dominate at least one chunk, and that chunk will have a coherent semantic identity. It breaks down for small functions (under 50 lines) that share a chunk with neighbors, especially when the function boundary doesn’t align with the chunk boundary.
routes/services/image.js is 1080 lines with many small utility functions — 35 of them match a simple function name( pattern alone, and (as later parts in this series cover) far more once arrow functions and class methods are counted properly. The file is a collection of tightly scoped helpers. The 50-line chunker is a poor fit for it.
Part 2 measures exactly how much this costs in search quality.
Key takeaways
- 27 chunks for a 1080-line file — one chunk every ~40 lines
- Most chunk boundaries fall mid-function, with no awareness of code structure
- 10-line overlap reduces but doesn’t eliminate the boundary problem
- Whether a given function survives intact is a matter of where it lands relative to the 50-line window at the moment you measure it — not a stable property of the function. The same function in this file moved from “badly split” to “cleanly contained” purely because unrelated code was added earlier in the file.
- Short functions that land near a chunk boundary are the worst case
- The fix is function-boundary chunking — one chunk per function, sized to the function, not to an arbitrary line count
Next: Part 2 — measuring the retrieval cost of line-count chunking with a concrete eval suite, before touching the chunker.