Block Model Architecture
Why chunking Markdown into structured blocks unlocks vector search, WYSIWYG editing, and inline search results.
April 3, 2026Why the Block Model
The current content pipeline stores raw Markdown as a single string per document. That works for basic rendering but it's a hard ceiling — three major features all require the same thing and none of them are possible without it: vector search, WYSIWYG editing, and inline search results (docs SERP).
Feature 1: Docs SERP with Inline Excerpts
The goal is a /docs landing page that works like a search engine over your own content. You type a question, you get ranked excerpts — not links to pages, but the actual answer text pulled from the exact location in the doc where it lives.
Query: "how do I authenticate a payment intent"
─────────────────────────────────────────────
[Auth > API Keys]
"Pass your secret key as a Bearer token in the
Authorization header. Never expose it client-side."
`Authorization: Bearer sk_live_...`
─────────────────────────────────────────────
[Payments > Payment Intents]
"The PaymentIntent must be created server-side.
Pass client_secret to the frontend..."
─────────────────────────────────────────────
This requires:
- Chunking content at block granularity (paragraph, heading, code block, etc.)
- Embedding each chunk individually
- Retrieving top-N relevant blocks and rendering them in-place with their doc context
You cannot do this against a raw Markdown string. The chunk is the block.
Feature 2: WYSIWYG Editing
Editing a Markdown string requires either a raw text editor or a parse → render → serialize round-trip on every keystroke. Both are bad. WYSIWYG requires the document to already be structured data — each block is a discrete row that can be read, mutated, reordered, or deleted independently.
The editor (Tiptap) reads blocks, writes blocks. No serialization layer between the editor state and the database.
Feature 3: MD Ingest Still Works
The file-based pipeline doesn't go away — it changes what it does after reading the file. AI writes a .md file, you drop it in content/md/[project]/, the sync script:
- Parses MD into a block tree using
remark - Inserts one row per block into the
blockstable - Embeds each block and stores the vector
The .md file is a write interface, not the canonical store. Local editing, AI authoring, and git history all still work.
Schema
documents table
Replaces the current content table. Stores document-level metadata only — no content.
CREATE TABLE documents (
id TEXT PRIMARY KEY,
project_id TEXT,
slug TEXT UNIQUE NOT NULL,
file_path TEXT,
title TEXT,
description TEXT,
type TEXT, -- 'doc' | 'post'
status TEXT, -- 'draft' | 'published'
tags TEXT, -- JSON array
created_at TEXT,
updated_at TEXT
);
blocks table
Each block is a typed node in the document tree.
CREATE TABLE blocks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'heading' | 'paragraph' | 'code' | 'list' | 'listitem' | 'blockquote' | 'image' | 'divider'
content TEXT, -- JSON: { text?, lang?, level?, items?, url?, alt? }
position REAL NOT NULL, -- fractional indexing for reorder without renumbering
parent_id TEXT, -- for nested blocks (listitem inside list)
created_at TEXT,
updated_at TEXT
);
Fractional indexing for position: new blocks get the midpoint between neighbors ((a + b) / 2). Reorder never requires updating all sibling positions. Rebalance only when precision runs out (rare).
block_embeddings table
Separate from blocks to keep the main query path fast. Join only when doing vector search.
CREATE TABLE block_embeddings (
block_id TEXT PRIMARY KEY REFERENCES blocks(id) ON DELETE CASCADE,
embedding F32_BLOB(1536) -- OpenAI text-embedding-3-small dimensions
);
CREATE INDEX block_embeddings_idx ON block_embeddings
USING libsql_vector_idx(embedding);
Turso supports vector search natively via the libsql-vector extension — no separate vector DB needed.
Block Types
type BlockType =
| 'heading' // { level: 1-6, text: string }
| 'paragraph' // { text: string } (supports inline marks via spans)
| 'code' // { lang: string, text: string }
| 'blockquote' // { text: string }
| 'list' // { ordered: boolean } — children are listitems
| 'listitem' // { text: string }
| 'image' // { url: string, alt: string, caption?: string }
| 'divider' // {}
| 'callout' // { icon: string, text: string, variant: 'info'|'warn'|'danger' }
Inline formatting (bold, italic, code, links) lives inside the text field as a portable inline format — either plain Markdown inline syntax or a small JSON span model.
MD → Blocks Parser
The ingest script replaces raw content storage with a block parse step.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import type { Root, Node } from 'mdast'
function mdToBlocks(markdown: string): BlockInsert[] {
const tree = unified().use(remarkParse).use(remarkGfm).parse(markdown) as Root
const blocks: BlockInsert[] = []
let position = 1.0
for (const node of tree.children) {
const block = nodeToBlock(node, position)
if (block) {
blocks.push(block)
position += 1.0
}
}
return blocks
}
Each mdast node maps 1:1 to a block type. Lists become a list block with listitem children (via parent_id).
Rollout Plan
Phase 1 — Schema migration
- Create
documentstable (mirrors currentcontenttable minus raw content) - Create
blockstable - Create
block_embeddingstable with vector index - Migrate existing
contentrows →documentsrows (copy metadata, discard raw content field)
Phase 2 — Ingest rewrite
- Update
scripts/sync-content.ts:- On new file: insert
documentrow + parse MD → insertblockrows - On changed file: diff blocks (or nuke + reinsert for simplicity)
- On renamed file: update
file_pathonly, blocks untouched
- On new file: insert
- Add embedding step: after block insert, call
text-embedding-3-smallper block, store inblock_embeddings - Restructure
content/md/→content/md/[project]/(extractproject_idfrom subdir)
Phase 3 — Render layer
getDocumentBySlug(slug)— fetchesdocument+ orderedblocksBlockRenderercomponent — maps block type → React componentCodeBlockwith Shiki syntax highlighting- Replace current
MarkdownRenderer(react-markdown) withBlockRenderer
Phase 4 — Docs SERP
/docslanding page with search input- API route: vector search over
block_embeddings→ return top-N blocks with document context - Render results as inline excerpts with source badges and doc links
Phase 5 — WYSIWYG editor
- Install Tiptap core + minimal extensions
- Write Tiptap ↔ blocks adapter (load: blocks → Tiptap JSON doc, save: Tiptap JSON → block upserts)
- Admin-gated editor route
- Live save on mutation (debounced)
What Stays the Same
project_iddesign — same concept, now ondocumentsinstead ofcontent- Sidebar shape — still flat (Home / Posts / Docs / Projects)
- Breadcrumb filter for project context
- File-based authoring workflow —
.mdfiles still work, ingest just does more - Turso as the only DB — vectors live there too
What Doesn't Make Sense Without Block Model
| Feature | Possible without? |
|---|---|
| WYSIWYG editing | No |
| Vector search | No |
| Inline excerpt results | No |
| Syntax highlighting (Shiki) | Yes, but awkward |
| Block reordering / drag | No |
| Callout / rich block types | No |
| Per-block AI suggestions | No |
The block model is the unlock. Everything interesting branches from it.