The docs-mcp build pipeline supports incremental indexing — re-embedding only new or changed chunks while preserving correct, deterministic output. For large production corpora (thousands of files), this avoids expensive full embedding API passes on every build.
- Determinism preserved. Given the same input corpus, a build with a warm cache produces identical
chunks.jsonand equivalent search results from.lancedb/compared to a cold build. Equivalence is defined as: for any fixed query and filter, both indexes return the samechunk_idset in the same rank order. - No server changes. The incremental strategy is entirely build-side.
metadata.json,chunks.json, and the.lancedb/directory retain their schemas. The server, eval harness, and MCP tools are unaffected. - Embedding provider agnostic. The caching layer sits below the
EmbeddingProviderinterface. It works identically for any provider — though the cost savings are only material for API-based providers. - On by default. Embedding caching is enabled by default. A
--rebuild-cacheflag forces all chunks to be re-embedded and writes a fresh cache. The cache is self-healing — incompatible or corrupt caches are discarded with a warning, never silently. - CI-cache friendly. The cache is a single directory that can be archived/restored by standard CI cache actions.
A chunk's embedding depends on exactly two inputs: the embedding model configuration and the text sent to the provider. If both are unchanged, the vector is unchanged.
Both the embedding call and the fingerprint computation use the same function to construct the text payload:
// Defined once in @speakeasy-api/docs-mcp-core/embedding.ts
function toEmbeddingInput(chunk: Chunk): string {
return `Context: ${chunk.breadcrumb}\n\nContent:\n${chunk.content_text}`;
}The breadcrumb includes the heading text, so heading changes are captured. Both provider.embed() and computeFingerprint() call toEmbeddingInput() — there is no second code path that could diverge.
const EMBEDDING_FORMAT_VERSION = "1";
type EmbeddingFingerprint = string; // SHA-256 hex digest
function computeFingerprint(chunk: Chunk, config: EmbeddingConfig): EmbeddingFingerprint {
const input = [EMBEDDING_FORMAT_VERSION, config.configFingerprint, toEmbeddingInput(chunk)].join(
"\0",
);
return sha256hex(input);
}The null-byte delimiter prevents ambiguous concatenations.
Each EmbeddingProvider implementation exposes a configFingerprint getter: a stable SHA-256 hash of all configuration fields that affect vector output.
interface EmbeddingConfig {
provider: string;
model: string;
dimensions: number;
configFingerprint: string;
}What goes into configFingerprint per provider:
| Provider | Fields hashed |
|---|---|
hash |
provider, model, dimensions |
openai |
provider, model, dimensions, baseUrl |
Chunk IDs are structure-derived ({filepath}#{heading-path}), not content-derived. Editing a paragraph under an ## Authentication heading does not change the chunk ID. The fingerprint must hash the actual text sent to the embedding model.
The cache uses a LanceDB table stored in a dedicated directory (.embedding-cache/). Since @lancedb/lancedb is already a project dependency, this adds zero new dependencies while providing binary Arrow storage for vectors, O(1) point lookups by fingerprint, and natural compatibility with directory-based CI caches.
Schema:
| Column | Type | Description |
|---|---|---|
fingerprint |
string |
SHA-256 hex digest (primary key for lookups) |
chunk_id |
string |
Stored for diagnostics only |
vector |
FixedSizeList<Float32>[dims] |
The embedding vector |
Cache metadata is stored in a sibling file .embedding-cache/cache-meta.json:
{
"cache_version": "1",
"format_version": "1",
"config_fingerprint": "a3f8..."
}cache_version: For forward compatibility. Cache is discarded (with warning) on major version mismatch.format_version: Must matchEMBEDDING_FORMAT_VERSION. If the embedding input construction changes and this constant is bumped, the cache is invalidated.config_fingerprint: Must match the current provider'sconfigFingerprint. If any differ, the entire cache is invalidated.
- Default:
{out}/.embedding-cache/(sibling to.lancedb/andmetadata.json) - Overridable via
--cache-dir <path>for CI environments that persist caches separately.
- Cleanup: Remove any leftover
.embedding-cache.tmp/and.embedding-cache.old/directories from a previous interrupted build. - Load: Read
cache-meta.json. If missing, corrupt, or incompatible, discard the entire cache directory, emit a warning to stderr with the reason, and proceed with a cold build. Cache read is skipped when--rebuild-cacheis set. - Match: For each chunk, compute its fingerprint. Batch-query the cache table for all fingerprints. Partition chunks into cache-hit (vector reused) and cache-miss (needs embedding) sets.
- Embed: Send only the cache-miss chunks to the
EmbeddingProvider. - Merge: Combine cached vectors + freshly embedded vectors.
- Prune & Write Cache: Write the cache table with only the current build's fingerprint→vector mappings using the rotate-and-rename pattern (see "Atomic Writes" below). This keeps cache size proportional to corpus size.
- Write Index: Overwrite the LanceDB index table (rebuilt from the full chunk set). Write
chunks.jsonandmetadata.json.
Cache writes use a rotate-and-rename pattern:
- Write new cache table and
cache-meta.jsonto.embedding-cache.tmp/. rename(.embedding-cache, .embedding-cache.old)— best-effort.rename(.embedding-cache.tmp, .embedding-cache)— atomic swap-in.rm -rf .embedding-cache.old— async/best-effort cleanup.
On POSIX, directory rename is atomic. .embedding-cache/ always contains a valid state — it transitions directly from old to new with no gap.
LanceDB's FTS indexes and scalar indexes must reflect the current corpus exactly. Stale rows from deleted files must be removed. The cheapest correct strategy is to rebuild the table from the full set of chunks+vectors each time. The expensive operation being skipped is the embedding API calls, not the local index write.
# Default: incremental (reads/writes embedding cache automatically)
docs-mcp build \
--docs-dir ./docs \
--out ./index \
--embedding-provider openai
# Re-embed everything and write a fresh cache
docs-mcp build \
--docs-dir ./docs \
--out ./index \
--embedding-provider openai \
--rebuild-cache
# Explicit cache location (useful for CI with persistent caches)
docs-mcp build \
--docs-dir ./docs \
--out ./index \
--embedding-provider openai \
--cache-dir /tmp/docs-mcp-cache
The build command emits cache metrics to stderr:
embedding cache: 1847 hits, 53 misses (97.2% hit rate)
embedded 53 chunks via openai in 2.1s
wrote 1900 chunks and .lancedb index to ./index
On cache invalidation:
warn: embedding cache invalidated: config_fingerprint mismatch (provider config changed)
embedded 1900 chunks via openai in 34.7s
wrote 1900 chunks and .lancedb index to ./index
For a corpus of N total chunks where M chunks changed since last build:
| Operation | Full Build | Incremental Build |
|---|---|---|
| Chunking (AST parse) | N | N |
| Fingerprint computation | 0 | N (SHA-256, ~μs each) |
| Embedding API calls | N | M |
| LanceDB index table write | N | N |
| Cache read (binary) | 0 | O(N) Arrow scan |
| Cache write (binary) | 0 | O(N) Arrow write |
The savings are entirely in embedding API calls: (N - M) × cost_per_embedding.
- First build: No cache exists. All chunks are embedded. Cache is written for next run.
- Embedding provider changed:
config_fingerprintmismatch → warning emitted → entire cache invalidated → full embed. - Model dimensions changed: Same as above (dimensions are part of
configFingerprint). - Base URL changed: Same as above (
baseUrlis part ofconfigFingerprint). - Embedding input template changed:
format_versionmismatch → warning emitted → entire cache invalidated. - Cache directory corrupt/unreadable: Warning emitted, proceed with full embed, write fresh cache.
cache-meta.jsonmissing but LanceDB files present: Treated as corrupt — warning, discard, cold build.- Interrupted write: May leave
.embedding-cache.tmp/or.embedding-cache.old/. Next build's startup cleanup removes both and proceeds.