A note-taking web app built with Svelte 4 that lets you:
File list panel with tabbed views: All Files and Search Results.
Document editor panel with live Markdown preview.
Dirty state indicator shows when unsaved changes exist.
AI chat panel:
Dark mode toggle:
Resizable editor/chat split with drag handle, state saved to localStorage.
File List Panel
Tabs:
Document Fields
<textarea> where you type Markdown.Indicators
AI Chat
GET /api/health
Returns { ok: true } if the API is running.
POST /api/save
Body:
{
"title": "My Note",
"content": "Some markdown text..."
}
Returns:
{
"id": 1,
"lastModified": "2025-09-20T15:25:31.123Z"
}
GET /api/docs
Returns array of docs (no content):
[
{ "id": 1, "title": "My Note", "lastModified": "2025-09-20T15:25:31.123Z" }
]
GET /api/doc/:id
Returns full document:
{
"id": 1,
"title": "My Note",
"content": "Some markdown text...",
"lastModified": "2025-09-20T15:25:31.123Z"
}
DELETE /api/doc/:id
Returns:
{ "success": true }
POST /api/search
Body:
{ "query": "find razor svelte", "topK": 5 }
Returns:
[
{
"id": 1,
"title": "My Note",
"lastModified": "2025-09-20T15:25:31.123Z",
"score": 0.82
}
]
POST /api/chat
Body:
{ "query": "What are the benefits of RazorSvelte?", "topK": 3 }
Returns:
{
"answer": "Hereβs an explanation ... <<DOC_ID:1 TITLE:My Note SCORE:0.82>>content<</DOC_ID>>",
"docIds": [
{ "id": 1, "title": "My Note", "score": 0.82 }
]
}
You need Ollama running locally. π Install Ollama
Embedding Model (default: nomic-embed-text)
π Get nomic-embed-text
Chat Model (default: llama3.2)
π Get llama3.2
Run these commands after installing Ollama:
ollama pull nomic-embed-text
ollama pull llama3.2
better-sqlite3expressaxioscorsdotenvsvelte (v4)markedmarked-highlightprismjsbootstrapgit clone https://github.com/wergeld/svelte-markdown-embeddings.git
cd svelte-markdown-embeddings
cd backend
npm install
npm run dev # or node server.mjs
cd frontend
npm install
npm run dev
Open browser at listed port on localhost
Hereβs how everything connects:
sequenceDiagram
participant U as User (Browser)
participant F as Frontend (Svelte)
participant B as Backend (Express + SQLite)
participant O as Ollama (LLM + Embeddings)
U->>F: Type note / question
F->>B: Save document (/api/save)
B->>O: Request embedding
O-->>B: Return embedding
B-->>F: Document saved
U->>F: Ask question in chat
F->>B: /api/chat { query }
B->>O: Get embedding for query
B->>B: Compute cosine similarity with stored docs
B->>O: Send prompt with context docs
O-->>B: Return answer with doc refs
B-->>F: Answer + docIds
F-->>U: Show answer + clickable doc links
bm25 scoring to rank matches based on query terms. 0.7 * vectorScore + 0.3 * keywordScore (tunable). flowchart TD
A[User Query / Chat Input] --> B[Hybrid Search]
B --> B1[Vector Search using Embeddings]
B --> B2[Keyword Search using FTS5]
B1 --> C[Combine Scores: vectorScore + keywordScore]
B2 --> C
C --> D[Retrieve Top-K Documents / Aggregate by Title]
D --> E[Build LLM Prompt with Inline Document References]
E --> F[Ollama LLM Chat Model]
F --> G[AI Response with Footnote-Style Inline Document Links]
G --> H[Frontend]
H --> H1[Render Markdown / Syntax Highlighting]
H --> H2[Clickable Buttons to Open Relevant Documents]
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL, -- JSON array of floats
lastModified TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL,
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts
USING fts5(title, content, content_rowid = 'id');
CREATE VIRTUAL TABLE IF NOT EXISTS document_chunks_fts
USING fts5(content, content_rowid = 'id');
Notes:
embedding column.id β unique doc identifier
title β document title
content β raw markdown text
embedding β vector embedding stored as JSON text
Example:
[0.0135, -0.0279, 0.0042, ...]
Format: array of floats (float32 values from Ollama, serialized as JSON string).
lastModified β ISO8601 string (2025-09-20T18:55:33.123Z)
bm25 scoring to rank matches based on query terms.0.7 * vectorScore + 0.3 * keywordScore (tunable).| Feature | Notes |
|---|---|
| Embedding generation | Per document, can be slow on very large documents; consider chunking for large datasets |
| FTS5 search | Fast for keyword lookups |
| Hybrid search | Combines semantic and keyword search; balances accuracy and speed |
| LLM chat | Requires Ollama model to be loaded locally; prompt size can affect latency |
Currently, embeddings are stored as JSON strings. This is simple, but inefficient for large collections because:
JSON.parse.Switch to binary BLOB storage (raw Float32Array).
ALTER TABLE documents RENAME TO documents_old;
CREATE TABLE documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB NOT NULL,
lastModified TEXT NOT NULL
);
documents_old.Float32Array.documents table as a BLOB.Example in Node.js:
const oldDocs = db.prepare("SELECT * FROM documents_old").all();
const insert = db.prepare("INSERT INTO documents (id, title, content, embedding, lastModified) VALUES (?, ?, ?, ?, ?)");
for (const d of oldDocs) {
const arr = JSON.parse(d.embedding);
const buf = Buffer.from(new Float32Array(arr).buffer);
insert.run(d.id, d.title, d.content, buf, d.lastModified);
}
const row = db.prepare("SELECT embedding FROM documents WHERE id = ?").get(1);
const floatArray = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
β Benefits:
JSON.parse).Currently, embeddings are stored as JSON strings. This is simple, but inefficient for large collections because:
JSON.parse.Switch to binary BLOB storage (raw Float32Array).
ALTER TABLE documents RENAME TO documents_old;
CREATE TABLE documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB NOT NULL,
lastModified TEXT NOT NULL
);
const oldDocs = db.prepare("SELECT * FROM documents_old").all();
const insert = db.prepare("INSERT INTO documents (id, title, content, embedding, lastModified) VALUES (?, ?, ?, ?, ?)");
for (const d of oldDocs) {
const arr = JSON.parse(d.embedding);
const buf = Buffer.from(new Float32Array(arr).buffer);
insert.run(d.id, d.title, d.content, buf, d.lastModified);
}
const row = db.prepare("SELECT embedding FROM documents WHERE id = ?").get(1);
const floatArray = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
| Aspect | JSON (current) | BLOB (proposed) |
|---|---|---|
| Storage Size | Larger (array serialized as text) | Smaller (compact float32 binary) |
| Insert Speed | Slower (serialize with JSON.stringify) |
Faster (direct Float32Array β Buffer) |
| Read Speed | Slower (parse with JSON.parse) |
Faster (direct memory view via Float32Array) |
| Similarity Search | Requires JSON.parse per row before compute |
Direct numeric ops on binary data |
| Cross-Language | Easy (JSON is universally supported) | Harder (requires decoding binary format) |
| Debuggability | Easy (open DB, embeddings are human-readable) | Hard (binary blobs unreadable in SQLite GUI) |
| Scalability | Limited (parsing overhead dominates >100k docs) | Better suited for large collections (>1M docs) |
β Recommendation: