# How to chunk an OpenAPI document for retrieval

> Cut an OpenAPI description into retrieval chunks that are each one complete operation, so a top hit carries the whole parameter table rather than half a schema.

Source: https://voxgig.com/howto/chunk-an-openapi-document-for-retrieval

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## Short answer

Make one chunk per operation, and put the method, the path, the summary, every parameter, the request body and each response into it. Resolve local references inline so the chunk stands alone, and cap that inlining at two levels so a shared or recursive schema cannot make one chunk larger than the document. Never cut the serialized document by character count.

---
## You will need

An OpenAPI 3 description, and Node 22 or later. The structure being walked is the [Paths
Object](https://spec.openapis.org/oas/v3.1.0.html#paths-object), and the references being resolved
are the local [Reference Objects](https://spec.openapis.org/oas/v3.1.0.html#reference-object) that a
bundled document carries.[^1]

Voxgig maintains apidef. This page compares it with swagger-parser, Redocly CLI, and a generic text
splitter.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A character splitter over the raw document](https://docs.langchain.com/oss/python/integrations/splitters/recursive_text_splitter) | You want retrieval working this afternoon and will fix it later | Chunks cut mid-schema, so the top hit is often unusable on its own | Anyone will act on what the retrieval returns |
| [apidef entity extraction](https://github.com/voxgig/apidef) | You want operations grouped under the entity they act on, without writing that | Its own view of what an entity is, which non-CRUD endpoints may not fit | Your API does not divide into entities cleanly |
| [Redocly bundle then walk](https://redocly.com/docs/cli/commands/bundle) | The description is split across files and you already use the tool | A build step, and dereferenced output that loses component names | The document is already one file |
| [swagger-parser dereference then walk](https://apitools.dev/swagger-parser/docs/) | Most teams, because it resolves everything and leaves the walk to you | Full dereferencing, so a recursive schema needs a cap you impose | You want entity grouping rather than a flat list |

The real division is between cutting text and walking structure. A splitter treats the description
as prose, and an API description is not prose: it is a tree where the useful unit is an operation
and the boundaries are known exactly. Anything that walks the tree gets those boundaries for free.
What differs after that is grouping, and whether the tool decides it or you do. Grouping is worth
having when your API divides into resources, and worth refusing when it does not.

## Emit one chunk per operation

The unit is the operation, and everything a reader needs goes in it.

```js title="chunk.mjs"
export function operationChunks(doc, { maxDepth = 2 } = {}) {
  const chunks = []
  for (const [path, item] of Object.entries(doc.paths)) {
    for (const verb of METHODS) {
      const operation = item[verb]
      if (!operation) continue
      chunks.push({
        id: operation.operationId,
        title: `${verb.toUpperCase()} ${path} - ${operation.summary ?? operation.operationId}`,
        text: render(doc, path, verb, withPathParams(item, operation), maxDepth),
      })
    }
  }
  return chunks
}
```

Walk the methods, not the keys. A Path Item holds its operations under `get`, `post` and the rest,
and beside them it may hold `summary`, `description`, `servers` and `parameters`.[^2] Iterating the
object treats those as operations, and the first one reaching the renderer throws on a missing
`responses` member. Those path-level parameters apply to every operation underneath, so merge them
in: a chunk that omits the path parameter describes a call the reader cannot make.

Include the failure responses. A question about listing branches is often really a question about
what happens when the repository does not exist, and a chunk that stops at the 200 cannot answer it.

Keep the operation id as the chunk id. Retrieval that cites an id a reader can find in the
description is checkable, and one that cites a character range is not.

Sort the chunks by path rather than by insertion order. It costs nothing, and it makes two builds of
the same description produce the same index, which is what lets you diff one against the next.

## Cap the inlining

Full dereferencing is the trap, and it appears only on a large description.

```js title="chunk.mjs"
export function inline(doc, node, depth) {
  if (!node || typeof node !== 'object') return node
  if (typeof node.$ref === 'string') {
    const name = node.$ref.split('/').pop()
    if (depth <= 0) return { schema: name }
    return inline(doc, doc.components.schemas[name], depth - 1)
  }
```

Two levels covers the shape a reader needs: the envelope and the thing inside it. Past that, the
name is more useful than the body, and a recursive component that references itself would otherwise
expand until the process stops.[^3] Leaving the name behind also gives a follow-up question something to
retrieve. A reader who asks what a commit looks like gets the component chunk, and the operation
chunk stays the size of an operation.

## Check it worked

Ask one real question against both chunkings.

```bash
node demo.mjs
```

```text output
one chunk per operation
  chunks     3, longest 1137 chars
  top hit    GET /branches - List the branches of a repository
  usable     yes
  parameters 3 named in the hit
one chunk per 600 characters
  chunks     9, longest 600 chars
  top hit    characters 0 to 600
  usable     no: no method and path, no parameter list, no response section
  parameters 0 named in the hit
```

The second block is not a bad score, it is a broken answer. The top hit is the opening of the
document, it contains no operation at all, and an assistant handed that chunk will invent the
parameters or apologize. The first block returns three named query parameters with their types and
descriptions, plus both response shapes, and the failure case.

```bash
node --test chunk.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 116.013385
```

## When it goes wrong

Chunks are enormous and retrieval is slow. A shared schema is being inlined everywhere it appears.
Lower the depth cap, and check the longest chunk after every description change. A chunk longer than
the context you allot per retrieval is one the model never sees in full.

The assistant cites the wrong operation. Two operations share a summary, and the path is buried. Put
the method and path first in the chunk text, where a term match will weight it. Summaries repeat
across a resource far more than paths do.

A question about a field returns nothing. Field names live inside a schema that was named rather
than inlined. Emit a second chunk per component schema, and let both be retrievable. The operation
chunk and the schema chunk answer different questions, and both are cheap to build.

Retrieval works and answers are out of date. Chunks were built once. Rebuild them in the same job
that publishes the description, and key them on the description version. An index that cannot say
which version it was built from cannot be trusted after the first release.

## When not to do this

Do not chunk a description that changes hourly without rebuilding the index in the same pipeline. A
stale index answers with an endpoint you removed, which is worse than no assistant.

Do not use apidef's entity grouping without reading what it produced for your odd endpoints. A
search operation, a bulk import, and a webhook registration are not operations on an entity, and
grouping them under one distorts what a reader is told.

Do not put the whole description in one chunk to be safe. Retrieval then returns everything for
every question, and the model does the filtering the index should have done. It will do it badly on
a large description, and the cost per question is the whole document every time.

## Related how-tos

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

## Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

[^1]: The document has been through fourteen numbered editions and two names. The
[revision history](https://spec.openapis.org/oas/v3.1.0.html) at the back of the specification
starts with 1.0 on August 10, 2011, described as the first release of the Swagger Specification.
It reaches 1.2 in March 2014 as the initial release of the formal document, which dates the
paperwork three years after the thing. Swagger 2.0 follows in September 2014, 3.0.0 in July 2017
after three release candidates, and 3.1.0 on February 15, 2021 after two more. The name changed
between 2.0 and 3.0, and the table records the versions without remarking on it.

[^2]: The [Path Item Object](https://spec.openapis.org/oas/v3.1.0.html#path-item-object) has
thirteen fixed fields, and eight of them are HTTP methods: `get`, `put`, `post`, `delete`,
`options`, `head`, `patch` and `trace`. The other five are `$ref`, `summary`, `description`,
`servers` and `parameters`, which is why iterating the keys finds more than operations. The
specification also allows a Path Item to be empty, due to ACL constraints. The path is then still
exposed to the documentation viewer, who will not know which operations and parameters are
available. A path that exists and says nothing is, by the letter of the standard, a valid thing to
publish.

[^3]: swagger-parser has met the recursive schema and given it an option. Its
[options page on GitHub](https://github.com/APIDevTools/swagger-parser/blob/main/docs/options.md)
includes `circular`,
which takes a boolean or the string `"ignore"`. Set to `false`, the parser throws a
`ReferenceError` if the API contains any circular references. Set to `"ignore"`, no error is
thrown, the circular references are ignored, and a `$Refs.circular` property is set to `true`
anyway, so the parser still tells you what it declined to do. The cap on this page is a different
answer: follow the reference twice, then write down its name.