Skip to content

Instantly share code, notes, and snippets.

@possibilities
Created March 18, 2026 19:03
Show Gist options
  • Select an option

  • Save possibilities/60dc2e6f87e8c0a8200ba58e6f0cedf4 to your computer and use it in GitHub Desktop.

Select an option

Save possibilities/60dc2e6f87e8c0a8200ba58e6f0cedf4 to your computer and use it in GitHub Desktop.
ChromaDB → QMD Migration: Meta Plan

ChromaDB → QMD Migration: Meta Plan

Executive Summary

The arthack codebase has a centralized vector daemon (vectorctl) that manages all ChromaDB operations via ZMQ IPC. Two major consumers — knowctl and claudectl — delegate all embedding, ingestion, and search through this daemon. The QMD server on artbird is a completely different system: a Bun/TypeScript search engine (@tobilu/qmd) with BM25 + vector + HyDE search, file-based collections, and its own GGUF embedding model. knowctl still uses vectorctl's ChromaDB backend for embeddings and search. The migration means replacing the vectorctl daemon + local ChromaDB stack with QMD as the universal search backend.


Current Architecture

The vectorctl Daemon (Central Hub)

vectorctl is a ZMQ IPC daemon that keeps the SentenceTransformer model warm in memory and handles all ChromaDB operations for every consumer:

Action Protocol Timeout
semantic_search ZMQ request/reply 30s (3 retries)
write_documents ZMQ (slow queue) 120s
delete_documents ZMQ (slow queue) 30s
embed_documents ZMQ request/reply 120s
count_documents ZMQ request/reply 30s
ping / status ZMQ request/reply 5s

Embedding model: all-MiniLM-L6-v2 (SentenceTransformer, 384-dim) Socket: ~/.local/share/vectorctl/daemon.sock LaunchAgent: arthack.vectorctl.sync-vectors.plist

Consumer: knowctl

Aspect Detail
ChromaDB path ~/.local/share/knowctl/chromadb/
Collection documents (single unified collection for all topics)
Chunking chonkie RecursiveChunker, 768 chars, 128 overlap
Metadata topic, path, chunk_index, source_url, ordinal_id, source_type
Client code knowctl/daemon_client.pycli_common/vectorctl_client.py → vectorctl daemon
Direct chromadb imports helpers.py (cleanup), run_list_documents.py, run_show_content.py (ChromaDocumentStore queries)

Consumer: claudectl

Aspect Detail
ChromaDB path ~/.local/share/claudectl/data/chromadb/
Collection sessions (all source types in one collection)
Chunking Custom: 1400 chars, 180 overlap, message-boundary-aware
Source types session, subagent, sessions-index, agent-cache, todo, plan
Metadata source_type, session_id, agent_id, project_dir_name, project_path, timestamp, session_scope, slug, role, cwd, git_branch
Client code claudectl/daemon_client.pycli_common/vectorctl_client.py → vectorctl daemon
Direct chromadb imports search_helpers.py (SharedSystemClient for memory management between batches)
Sync daemon arthack.claudectl.sync-sessions.plist — triggers sync every 120s

QMD Server (The Target)

Aspect Detail
What it is @tobilu/qmd — a Bun/TypeScript search engine
Runs on artbird
Ports 8181 (MCP), 8182 (web UI)
Search modes lex (BM25), vec (vector), hyde (HyDE), expand (query expansion)
Embedding model GGUF via node-llama-cpp (on-device, possibly GPU)
Collections File-based: directories with qmd.yaml manifests in /home/artbird/collections/
CLI commands qmd query, qmd search, qmd get, qmd collection list/add/remove, qmd update, qmd embed
API Bun HTTP server: /api/query, /api/search, /api/get, /api/multi-get, /api/status, /api/health
Deployment qmdctl push-deploy (rsync + restart), qmdctl provision-service (setup)
Monitoring qmdctl check-health (5min LaunchAgent), Telegram notifications
Auto-reindex qmdctl watch-collections daemon watches filesystem, throttled 30s

Dependency Graph

knowctl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
                                                                     ↑
                                                           SentenceTransformer
                                                           all-MiniLM-L6-v2

claudectl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)

QMD server (artbird) ────→ qmd CLI ────→ GGUF embeddings + file-based collections
  ↑
  qmdctl (deployment/ops only, not a search client)

Complete File Inventory

Files That Import ChromaDB

File Import Purpose
vectorctl/daemon.py ChromaDocumentStore, ChromaEmbeddingRetriever Core daemon — owns all ChromaDB instances
knowctl/helpers.py chromadb.PersistentClient, ChromaDocumentStore Cleanup + chromadb path
knowctl/run_list_documents.py ChromaDocumentStore Query ordinal mappings
knowctl/run_show_content.py ChromaDocumentStore Retrieve document content
knowctl/run_cleanup_example_collections.py chromadb One-time migration script
knowctl/daemon_client.py (indirect via vectorctl_client) Search wrapper
claudectl/search_helpers.py chromadb.api.shared_system_client.SharedSystemClient Memory management
claudectl/daemon_client.py (indirect via vectorctl_client) Search wrapper

Dependencies Declaring ChromaDB

Package Dependency
vectorctl/pyproject.toml chroma-haystack>=0.6.0
knowctl/pyproject.toml chroma-haystack>=0.6.0
claudectl/pyproject.toml chroma-haystack>=0.6.0, haystack-ai>=2.8.0, sentence-transformers>=3.0.0
Root pyproject.toml chromadb, chroma_haystack (type-checking stubs)

LaunchAgent Plists

Plist What It Runs
arthack.vectorctl.sync-vectors.plist vectorctl daemon (ZMQ server + embedding model)
arthack.claudectl.sync-sessions.plist claudectl session sync (calls vectorctl for writes)
arthack.qmdctl.check-health.plist QMD health check every 5min

Shared Infrastructure

File Purpose Used By
cli_common/vectorctl_client.py ZMQ client: daemon_search, daemon_write_documents, daemon_delete_documents, etc. knowctl, claudectl
cli_common/embedding.py Output suppression for SentenceTransformer init vectorctl
cli_common/zmq_ipc.py ZMQ IPC request/reply infrastructure vectorctl

Current State vs. Desired End State

Current State

knowctl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)
                                                                     ↑
                                                           SentenceTransformer
                                                           all-MiniLM-L6-v2

claudectl ──→ daemon_client ──→ vectorctl_client ──→ [ZMQ] ──→ vectorctl daemon ──→ ChromaDB (local)

QMD server (artbird) ────→ qmd CLI ────→ GGUF embeddings + file-based collections
  ↑
  qmdctl (deployment/ops only, no search client)

Desired End State

knowctl ──→ [HTTP] ──→ QMD server (artbird) ──→ qmd search engine
claudectl ──→ [HTTP] ──→ QMD server (artbird) ──→ qmd search engine

Eliminated: vectorctl daemon, local ChromaDB, SentenceTransformer on local machine, ZMQ IPC layer, chroma-haystack dependency, sentence-transformers dependency.


Migration Phases

Phase 0: Understand QMD's Capabilities and Gaps

Before writing any migration code, answer these questions by testing against the live QMD server:

Question Why It Matters
Does QMD's /api/search support metadata filtering? knowctl filters by topic, claudectl filters by project_dir_name, session_scope, source_type
How does QMD handle document updates/deletes? claudectl re-indexes changed sessions, deletes old chunks
Can QMD handle the metadata cardinality? claudectl stores 12+ metadata fields per chunk
What's QMD's chunking behavior? Does it chunk on ingest, or expect pre-chunked input?
How do QMD collection names work? Can we use knowctl-documents and claudectl-sessions? Or are they directory-based only?
Is the /api/query endpoint the right one? It supports multiple search modes — do we want BM25+vec hybrid, or vec only for backward compatibility?
What's the ingest API? Is it the /api/... endpoint, or do files need to land on disk in /home/artbird/collections/?

This phase is critical. The QMD server is a file-based search engine — documents live as files in /home/artbird/collections/. The current vectorctl approach sends document content over the wire to ChromaDB. These are fundamentally different models. We need to understand whether QMD supports an "API ingest" path or whether we need to rsync markdown files to artbird.

Deliverable: A tested understanding of QMD's ingest + search API, with sample requests/responses confirming each capability we need.

Phase 1: Build a QMD Search Client

Scope: Create the cross-CLI client library that replaces vectorctl_client.py.

Work:

  • Create (or expand) qmdctl/api.py with search/ingest/delete functions matching vectorctl_client's interface
  • Functions needed: search(), ingest_document(), delete_document(), list_collections(), health_check()
  • Handle auth (bearer token from ~/.config/qmdctl/config.yaml)
  • Handle network errors gracefully (QMD is remote, not local)
  • Match the filter interface that knowctl and claudectl expect

Decision needed: Should this be a thin HTTP client, or should it also handle chunking? If QMD expects files-on-disk, this layer needs to rsync or SCP content to artbird.

Phase 2: Migrate knowctl

Scope: Switch knowctl from vectorctl daemon to QMD.

Why first: knowctl has simpler metadata (5 fields vs 12) and its sync-topics pipeline is well-understood. Also, the qmdctl watch-collections daemon already handles re-indexing of file-based collections — knowctl's topic directories could map directly to QMD collections.

Work:

  • Replace knowctl/daemon_client.py to call QMD instead of vectorctl
  • Replace direct ChromaDocumentStore usage in run_list_documents.py and run_show_content.py
  • Update helpers.py to remove get_chromadb_path() and cleanup functions
  • Update run_sync_topics.py to ingest via QMD instead of daemon_write_documents()
  • Remove chroma-haystack from knowctl/pyproject.toml
  • Test: knowctl semantic-search <topic> "query" returns correct results

Phase 3: Migrate claudectl

Scope: Switch claudectl from vectorctl daemon to QMD.

Why second: claudectl has richer metadata, complex filtering (AND/OR conditions on scope, project, source_type), and a daemon sync loop with crash recovery. More moving parts.

Work:

  • Replace claudectl/daemon_client.py to call QMD instead of vectorctl
  • Update search_helpers.py:
    • Remove get_chromadb_path()
    • Remove SharedSystemClient import
    • Replace daemon_write_documents() / daemon_delete_documents() with QMD equivalents
    • Keep the chunking logic (message-boundary chunking is claudectl-specific)
  • Update run_sync_sessions.py daemon to write to QMD
  • Update run_semantic_search.py and run_search_plans.py to search via QMD
  • Backfill: full re-sync to populate QMD with all session data
  • Remove chroma-haystack and sentence-transformers from claudectl/pyproject.toml

Phase 4: Cleanup

Scope: Remove all dead infrastructure.

Work:

  • Delete apps/vectorctl/ entirely
  • Delete cli_common/vectorctl_client.py
  • Delete cli_common/embedding.py
  • Delete cli_common/zmq_ipc.py (if no other consumers)
  • Delete arthack.vectorctl.sync-vectors.plist
  • Update arthack.claudectl.sync-sessions.plist if daemon interface changed
  • Remove chromadb, chroma-haystack, sentence-transformers from root pyproject.toml stubs
  • Run uv sync --all-packages to clean lock file
  • Run scripts/install.sh to clean completions and symlinks
  • Update CLAUDE.md references to vectorctl
  • Check sandcastles.md for vectorctl entries
  • Delete local ChromaDB data directories (~/.local/share/knowctl/chromadb/, ~/.local/share/claudectl/data/chromadb/)

Risks and Unknowns

Critical — Must Resolve in Phase 0

  1. File-based vs API-based ingest. QMD collections are directories of files on artbird. vectorctl sends content over ZMQ. If QMD has no HTTP ingest endpoint (only file watching), the migration requires either: (a) rsyncing markdown to artbird, or (b) adding an ingest API to the QMD Bun server. This is the single biggest architectural question.

  2. Metadata filtering. vectorctl supports complex Haystack/ChromaDB filters (AND/OR, equality, in operator). QMD may or may not support equivalent filtering. claudectl's search is useless without project_dir_name and session_scope filters. If QMD doesn't support metadata filters, we need to add them or rethink the data model.

  3. Different embedding models. vectorctl uses all-MiniLM-L6-v2 (SentenceTransformer, 384-dim). QMD uses a GGUF model via node-llama-cpp. These produce different embeddings. There's no cross-compatibility — all data must be re-embedded by QMD.

Moderate Risk

  1. Network dependency. vectorctl is local (ZMQ IPC, microsecond latency). QMD is remote on artbird (HTTP, millisecond+ latency). Every search and ingest becomes a network call. Need to verify latency is acceptable for interactive use (claudectl semantic-search during a conversation).

  2. Session data volume. claudectl indexes all Claude Code sessions, subagents, plans, todos, and agent-cache files. Could be thousands of documents with tens of thousands of chunks. Network ingest to artbird at 120s intervals needs to handle this throughput.

  3. Hybrid search vs. vector-only. QMD offers BM25 + vector + HyDE. vectorctl is vector-only. Migrating gives us potentially better search quality, but results will differ. Users may notice changes in what semantic-search returns.

  4. knowctl's direct ChromaDocumentStore usage. Several knowctl commands (run_list_documents.py, run_show_content.py) directly instantiate ChromaDocumentStore to query by document ID or retrieve content. These bypass the daemon entirely. Need a QMD equivalent for document retrieval by ID (qmd get?).

Low Risk

  1. ZMQ infrastructure removal. cli_common/zmq_ipc.py and cli_common/zmq_events.py may have other consumers. Verify before deleting.

  2. Test infrastructure. vectorctl/tests/ and claudectl/tests/test_search_helpers.py use ChromaDB fixtures. Tests need updating but this is mechanical.

  3. Model warm-up. vectorctl keeps the embedding model warm in the daemon. QMD also keeps its model loaded. No cold-start concern.


Decision Register

# Decision Options Recommendation
D1 Ingest path (a) HTTP API, (b) rsync files to artbird, (c) add ingest endpoint to Bun server Investigate in Phase 0. (c) is most compatible with current architecture
D2 Chunking (a) Client-side (keep current logic), (b) Server-side (let QMD chunk) (a) — claudectl's message-boundary chunking is semantically important
D3 Metadata filtering (a) QMD native, (b) Client-side post-filter, (c) Extend QMD Investigate in Phase 0. (a) if available, (c) if not
D4 Embedding model (a) Keep all-MiniLM-L6-v2 on QMD, (b) Use QMD's GGUF model (b) — whole point is GPU-accelerated embeddings. Accept re-embedding cost
D5 Collection strategy (a) One collection per consumer, (b) Merged collections (a) — knowctl-documents, claudectl-sessions keeps things isolated
D6 Search mode (a) Vector-only (backward compat), (b) Hybrid BM25+vec (b) — take advantage of QMD's capabilities, but make it configurable

Not Involved

  • notifyctl — pure desktop notification wrapper (terminal-notifier). No vectors, no search, no ChromaDB. Not part of this migration.
  • telegramctl — message routing. No vector/embedding usage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment