Experimental Research fork — APIs and behaviour may change without notice. Read disclaimer →

Question: Is it feasible and worthwhile to store pre-computed embedding vectors in YAML frontmatter of markdown files, so that agents pulling the KB via git don't need to re-index?

Findings

(a) Encoding a 768-dim Float32 Vector in YAML

Three viable encoding strategies exist, with clear trade-offs:

Option 1: Base64-encoded binary (recommended if storing in frontmatter)

Encode the raw float32 bytes as a base64 string. YAML supports this natively via the !!binary tag, and Python can round-trip through base64.b64encode(array.tobytes()).

---
title: My Entry
embedding: !!binary |
  SGVsbG8gV29ybGQ... (4,096 characters)
---

Encode/decode in Python:

import numpy as np, base64

# Encode
vec = np.random.randn(768).astype(np.float32)
encoded = base64.b64encode(vec.tobytes()).decode('ascii')
# => 4,096 character string

# Decode
decoded = np.frombuffer(base64.b64decode(encoded), dtype=np.float32)

Size: 768 floats x 4 bytes = 3,072 bytes raw. Base64 inflates by 4/3x = 4,096 bytes (4 KB) as a string in the YAML.

Option 2: JSON array of floats

Store as a YAML list (which maps to JSON array). Each float is ~12-18 characters of text.

embedding: [0.0234, -0.1567, 0.8921, ...]

Size: 768 floats x ~15 chars average = ~11,520 bytes (11.5 KB). Nearly 3x larger than base64.

Option 3: Pack64 (Luminoso's compact format)

Encodes each float in 3 bytes of base64-like representation. Trades some precision (17 bits vs 24 bits of float32) for compactness. Size: 768 x 3 = 2,304 bytes (~2.3 KB). However, this is a niche library with minimal adoption, adds a dependency, and the precision loss (5 significant digits) may affect similarity calculations at the margins.

Verdict: Base64-encoded binary is the clear winner. It is compact (4 KB), standard, zero-dependency (stdlib base64 + numpy), and preserves full float32 precision. The JSON array approach wastes 3x the space for no benefit.

(b) Size Overhead Per File

A typical picobot KB entry has: - YAML frontmatter (title, topic, tags, created, updated): ~150-200 bytes - Markdown body: ~500-3,000 bytes (a few paragraphs of knowledge) - Total typical file: ~700-3,200 bytes (call it ~1.5 KB median)

Adding a base64-encoded 768-dim embedding adds 4,096 bytes (4 KB).

Scenario Without embedding With embedding Overhead
Small entry (700 B) 700 B 4,796 B +585%
Median entry (1.5 KB) 1,500 B 5,596 B +273%
Large entry (3 KB) 3,000 B 7,096 B +137%

At scale (total KB size on disk):

Doc count Without embeddings With embeddings Delta
100 docs ~150 KB ~550 KB +400 KB
500 docs ~750 KB ~2.75 MB +2 MB
1,000 docs ~1.5 MB ~5.5 MB +4 MB

The absolute numbers are small (a few MB even at 1,000 docs), but the relative overhead is large: embeddings would be the majority of every file's content. This matters for git operations (see section c).

(c) Impact on Git Diffs

This is the strongest argument against frontmatter embeddings.

The core problem: Any content edit to a KB entry requires re-computing its embedding. The new embedding is a completely different 4 KB base64 string. Git treats this as a full-line replacement -- zero overlap between old and new strings. Every content edit generates a diff that looks like:

-embedding: !!binary |
-  SGVsbG8gV29ybGQ... (entire old 4 KB blob)
+embedding: !!binary |
+  WW91IHJlYWxseSB... (entire new 4 KB blob)

Concrete consequences:

  1. Diff noise: Every commit that modifies KB content will have ~4 KB of unreadable base64 change per file, drowning out the actual meaningful content diff. Reviewing changes becomes harder.

  2. Merge conflicts: If two agents edit the same file concurrently (picobot uses last-write-wins conflict resolution on the updated timestamp), the embedding field will always conflict. The current _resolve_conflicts() method parses frontmatter and picks the newer version, which would work, but generates more conflict surface area.

  3. Repository bloat: Git stores diffs as deltas in packfiles. Base64 strings have poor delta compression because changing one float in the vector changes the entire base64 encoding (base64 encodes in 3-byte groups, so a single byte change cascades). Over time, the git packfile grows significantly. With 500 docs edited 10 times each, that is 500 x 10 x 4 KB = ~20 MB of embedding delta data in git history, vs ~0 without embeddings.

  4. Sync overhead: picobot kb sync does git pull/push. Larger packfiles mean slower sync, especially on constrained connections.

Mitigation strategies (all add complexity): - Store embeddings in a separate sidecar file (e.g., entry.md + entry.emb) -- but this doubles file count and complicates the KB API - Store all embeddings in a single binary index file (e.g., embeddings.npy) -- effectively reinventing a vector store - Use .gitattributes to mark embedding fields as binary -- but this is per-file, not per-field, so it would suppress diffs on the entire markdown file

Verdict: Frontmatter embeddings create serious git hygiene problems. The noise, bloat, and conflict issues are not hypothetical -- they follow directly from how base64 strings diff.

(d) YAML Parse Performance Impact

The picobot KB parser (_parse_frontmatter in knowledge.py) uses a regex to extract the frontmatter block, then yaml.safe_load() to parse it. Adding a 4 KB base64 string to every frontmatter block affects parsing:

PyYAML parsing overhead (estimated from benchmark data): - PyYAML with C extension: ~0.02 ms per small frontmatter block - PyYAML with C extension: ~0.05-0.1 ms per block with 4 KB base64 field - PyYAML without C extension: ~0.2 ms per small block, ~0.5-1.0 ms with embedding - The !!binary tag triggers base64 decoding, which adds CPU time proportional to string length

At scale:

Doc count Parse time (no embedding) Parse time (with embedding) Delta
100 docs ~2 ms ~5-10 ms +3-8 ms
500 docs ~10 ms ~25-50 ms +15-40 ms
1,000 docs ~20 ms ~50-100 ms +30-80 ms

The picobot search path (_search_frontmatter) iterates all entries and parses each one's frontmatter. With embeddings, this means loading and decoding 4 KB of base64 per file even when only checking title/tags. The embedding data would need to be extracted on every search call, wasting time on data the search function does not use.

Mitigation: Lazy parsing -- only decode the embedding field when specifically needed, not on every frontmatter parse. This would require changing the YAML parsing to skip the embedding field during search. Alternatively, store embeddings in a separate key file that is only loaded by the vector search path.

Verdict: The parse overhead is measurable but not catastrophic (tens of milliseconds at 1,000 docs). The bigger issue is wasted work: parsing 4 MB of base64 data during tag-based searches that never use embeddings.

(e) Re-Embedding Time: Is This Even a Problem Worth Solving?

This is the critical question. If re-embedding on init is fast enough, there is no reason to store embeddings in frontmatter.

Embedding throughput benchmarks (all-MiniLM-L6-v2, CPU):

The all-MiniLM-L6-v2 model (22.7M parameters, 384-dim output, but 768-dim for nomic-embed-text) has well-documented CPU performance:

  • Single-threaded: ~50-100 sentences/sec (conservative, short sentences)
  • Batched (batch_size=32, multi-core): ~500-2,000 sentences/sec on modern CPUs
  • MacOS (Apple Silicon): ~1,200 sentences/sec reported in benchmarks with sentence-transformers
  • ONNX-optimized: ~800-1,300 sentences/sec on CPU

For nomic-embed-text (137M parameters, 768-dim), throughput is lower due to larger model size. Estimated at ~30-50% of MiniLM speed, so ~250-600 sentences/sec batched on CPU.

Calculated re-embedding times (using conservative batched CPU estimates):

For all-MiniLM-L6-v2 at ~1,000 sentences/sec (batched, modern CPU):

Doc count Embedding time Model load time Total init time
100 docs 0.1 sec ~1-2 sec ~1.5-2.5 sec
500 docs 0.5 sec ~1-2 sec ~1.5-2.5 sec
1,000 docs 1.0 sec ~1-2 sec ~2-3 sec

For nomic-embed-text at ~400 sentences/sec (batched, CPU):

Doc count Embedding time Model load time Total init time
100 docs 0.25 sec ~2-3 sec ~2.5-3.5 sec
500 docs 1.25 sec ~2-3 sec ~3-4.5 sec
1,000 docs 2.5 sec ~2-3 sec ~4.5-5.5 sec

Key insight: Model loading dominates. Whether you embed 100 or 1,000 docs, the model load time (~1-3 seconds) is the bottleneck. The actual embedding computation for 1,000 short documents is 1-2.5 seconds. Total init time is 2-6 seconds regardless of approach.

What frontmatter storage saves: If embeddings are pre-computed in frontmatter, you skip the embedding model entirely. Init becomes: parse 1,000 YAML files + decode base64 = ~100 ms + ~50 ms = ~150 ms. This is ~2-5 seconds faster than re-embedding.

But: You still need the embedding model loaded for query-time embedding. When a user searches, their query must be embedded using the same model. So the model load cost is merely deferred, not eliminated. The only scenario where frontmatter wins is if you load the model lazily (on first search) and the user never searches -- which defeats the purpose.

Comparison: Frontmatter vs Sidecar Index vs Re-embed on Init

Approach Init time (1K docs) Query latency Git overhead Complexity
Re-embed on init 2-6 sec None (model loaded) None Low
Frontmatter base64 ~150 ms +1-3 sec (lazy model load) High (4 KB/file churn) Medium
Sidecar .npy file ~50 ms +1-3 sec (lazy model load) Medium (one binary file) Medium
Separate index DB ~10 ms +1-3 sec (lazy model load) None (in .gitignore) Low-Medium

Recommendation

Do not store embeddings in YAML frontmatter. The approach is technically feasible but not worthwhile:

  1. The problem is too small to solve. Re-embedding 1,000 docs on init takes 2-6 seconds. This is acceptable for an agent startup. Optimizing a 3-second operation with a complex caching scheme is premature optimization.

  2. Git impact is severe. Frontmatter embeddings would make every KB file 3-6x larger, pollute every content-edit diff with 4 KB of unreadable base64, and bloat the repository over time. This directly undermines the "KB as readable markdown" design principle.

  3. It does not eliminate model loading. The embedding model must still be loaded for query-time embedding, so the total session cost is the same. You just shift when the cost is paid.

  4. Better alternatives exist. If init time matters:

  5. Cache embeddings in a local .picobot/cache/embeddings.npz file (gitignored). Rebuild only when file mtimes change. Init drops to ~150 ms with no git impact.
  6. Use an incremental approach: compute embeddings for changed files only (compare file hashes).
  7. Use a persistent embedded DB (sqlite-vec, ChromaDB) that survives restarts.

  8. The sidecar pattern is strictly better. If you must share pre-computed embeddings via git, a single embeddings.npz file is superior to per-file frontmatter. It has a clean diff story (one binary file, use git-lfs if needed), no YAML parse overhead, and does not pollute the markdown files.

If You Must Do It Anyway

If there is a compelling reason to store embeddings in frontmatter (e.g., truly offline agents with no model access), the implementation would be:

---
title: My Entry
topic: projects
tags: [python, ai]
created: 2026-02-25T10:00:00
updated: 2026-02-25T10:00:00
embedding_model: nomic-embed-text-v1.5
embedding_dim: 768
embedding: !!binary |
  base64-encoded-bytes-here...
---

Include the model name and dimension in frontmatter so consumers can verify compatibility. Use !!binary for automatic base64 decode by PyYAML. Modify _parse_frontmatter to strip the embedding field before returning metadata to search functions. Accept the git overhead as a known cost.

But the recommendation remains: do not do this. Re-embed on init, or cache locally.