Commit Graph

105 Commits

Author SHA1 Message Date
Tobi Lutke
13e8473455
docs: update node usage and bump version
Update README installation and quick-start commands to Node examples.
- replace bun install/link commands with npm-based Node workflow
- bump package version to 0.9.9 for CLI and MCP metadata
- keep Bun guidance as optional development/runtime note
2026-02-15 16:44:47 -04:00
Tobi Lütke
392934e78a
perf: CPU parallelism via multi-context thread splitting
Our assumption that CPU can't benefit from multiple contexts was
wrong. The withLock in node-llama-cpp serializes within a single
context, but separate contexts with split threads run on different
cores in true parallel.

Key changes:
- computeParallelism() now returns >1 on CPU (cores / 4, max 4)
- threadsPerContext() splits math cores evenly across contexts
- Both embed and rerank contexts get proper thread counts
- Benchmark updated to test CPU parallelism

Before (CPU, 40 docs): 9.7s (4.1 docs/s) — 6 threads, 1 context
After  (CPU, 40 docs): 2.3s (17.2 docs/s) — 32 threads, 8 contexts

Two fixes stacked:
1. Thread count: default was 6 (library hardcode), now uses all
   math cores — 2× improvement alone
2. Multi-context: splitting cores across 8 contexts gives another
   2.2× on top

End-to-end 'qmd query' on CPU: 10.3s → 2.9s

CPU benchmark (Threadripper PRO 7975WX, 32 math cores):
  1 ctx: 5001ms (8.0 docs/s)
  2 ctx: 3585ms (11.2 docs/s)  1.4×
  4 ctx: 2874ms (13.9 docs/s)  1.7×
  8 ctx: 2323ms (17.2 docs/s)  2.2×
2026-02-15 11:21:45 -05:00
Tobi Lütke
bf42223086
bench: add reranker benchmark (bench-rerank.ts)
Standalone benchmark for the reranking pipeline. Reports:
- System info (CPU, GPU, VRAM)
- Model VRAM usage
- Per-config: parallelism, flash attention, median time,
  throughput (docs/s), VRAM per context, total VRAM, peak RSS
- Speedup relative to baseline (1 context)

Usage:
  bun src/bench-rerank.ts              # full (40 docs, 3 iters, 1/2/4/8 ctx)
  bun src/bench-rerank.ts --quick      # quick (10 docs, 1 iter)
  bun src/bench-rerank.ts --docs 100   # custom doc count

Results on this machine:
  CUDA: 254ms/40 docs (8 ctx), 688ms (1 ctx) = 2.7x speedup
  CPU:  9697ms/40 docs (1 ctx) = 38x slower than single GPU ctx
2026-02-15 10:51:09 -05:00
Tobi Lütke
0a941c442f
perf: flash attention, right-sized contexts, cleaner GPU detection
Holistic tuning pass on context and GPU configuration:

GPU detection:
- Use getLlamaGpuTypes() to discover available backends at runtime
  instead of try/catch loop. Prefer CUDA > Metal > Vulkan > CPU.
- getLlama({gpu:'auto'}) returns false even when CUDA is available
  (node-llama-cpp issue), so we can't rely on it.

Context tuning:
- Rerank context: 2048 tokens (was auto=40960). The Qwen3 reranker
  template adds ~200 tokens overhead, chunks are ~800, query ~50.
  Total ~1050 tokens, so 2048 gives comfortable margin.
  VRAM per context: ~960 MB (was 11.6 GB with auto).
- Flash attention enabled for rerank contexts (~20% less VRAM).
  Falls back gracefully if flash attention not supported.
- Embed context: kept at model default (2048 for nomic-embed).

Platform considerations:
- CUDA (server): up to 8 parallel contexts, flash attention
- Metal (MacBook): 1-4 contexts depending on unified memory
- Vulkan: detected and used if CUDA/Metal unavailable
- CPU: single context (parallelism has no benefit due to locks)

Context size was 1024 initially but Qwen3's reranker template is
verbose (system prompt + instruct + think tags) — some inputs
exceeded 1024 tokens. Bumped to 2048 for safety.
2026-02-15 10:34:39 -05:00
Tobi Lütke
4ac95b5e26
perf: adaptive parallel contexts for embed + rerank, fix VRAM waste
Holistic overhaul of context management:

1. Parallel embedding contexts: embedBatch now splits work across
   multiple EmbeddingContexts (same pattern as reranking). Each
   context is ~143 MB. Benchmarked 6x speedup on 20 texts with
   4 contexts vs 1.

2. Rerank context size: was using auto (40960 tokens = 11.6 GB per
   context!). Reranking chunks are ~800 tokens max, so 1024 is
   plenty. Now 711 MB per context — 16x less VRAM. 4 contexts went
   from 46 GB to 2.8 GB.

3. Adaptive parallelism via computeParallelism(): checks available
   VRAM and allocates at most 25% of free VRAM for contexts, capped
   at 8. Falls back to 1 on CPU (no benefit from multiple contexts
   with node-llama-cpp's withLock serialization). Gracefully handles
   allocation failures — uses however many contexts succeeded.

VRAM budget per operation:
- Embed:  N × 143 MB (nomic-embed, 2048 ctx)
- Rerank: N × 711 MB (Qwen3-Reranker-0.6B, 1024 ctx)
- Generate: ~1.1 GB (qmd-expansion-1.7B, fresh ctx per call)

Works across:
- Large GPU boxes (4x A6000, 190 GB): allocates up to 8 contexts
- Consumer GPUs (16 GB): 2-4 contexts fit comfortably
- Apple Metal (8-16 GB unified): 1-4 contexts depending on memory
- CPU-only: single context (parallelism has no benefit)
2026-02-15 10:27:01 -05:00
Tobi Lütke
0a0e1e6f29
perf: parallel reranking with multiple contexts (2.7x speedup)
node-llama-cpp's LlamaRankingContext uses a single sequence with a
withLock() guard, making rankAll() effectively sequential despite
using Promise.all(). Each document evaluation erases the context,
evaluates tokens, and extracts the logit — all serialized.

Fix: create 4 parallel ranking contexts from the same model (model
weights are shared, only KV cache is duplicated). Split documents
across contexts and evaluate in parallel via Promise.all().

Benchmarks (40 chunks, CUDA, 4x A6000):
- 1 context:  898ms (baseline)
- 2 contexts: 460ms (2.0x)
- 4 contexts: 338ms (2.7x)  ← sweet spot
- 8 contexts: 458ms (VRAM contention)

End-to-end 'qmd query' time: 7.5s → 3.7s

Gracefully handles VRAM limits — if creating the Nth context fails,
falls back to however many were successfully created.
2026-02-15 10:19:55 -05:00
Tobi Lütke
ee86bba45e
feat: auto-detect GPU acceleration + device info in status
QMD was running all models on CPU even when CUDA/Vulkan/Metal
was available. The getLlama() call used no gpu option, defaulting
to false.

Now:
- ensureLlama() tries cuda → vulkan → metal → CPU fallback
- Prints warning to stderr if falling back to CPU
- 'qmd status' shows GPU type, device names, VRAM, and CPU cores
- On this machine: 7.5s query vs 5+ minutes on CPU (reranker)

The reranker (Qwen3-Reranker-0.6B) calls are serialized by a lock
in node-llama-cpp's rankAndSort() — each of the 40 chunks is
evaluated sequentially. This is inherent to the library's design
(single sequence context). GPU acceleration is the fix, not
batching — the lock prevents true parallelism regardless.
2026-02-15 10:13:07 -05:00
Tobi Lütke
b69fae7aa3
perf: batch vector embeddings + collection-aware FTS filtering
Three improvements to hybridQuery:

1. Collection filter pushed into SQL: searchFTS and searchVec now
   accept collectionName directly instead of filtering post-hoc.
   Reduces noise in FTS probe and all expanded-query FTS calls.
   Also fixes MCP server's FTS search to use SQL-level filtering.

2. Batch embed for vector searches: instead of embedding each
   vec/hyde query sequentially (one embed call per query), we now
   collect all texts that need vector search and embed them in a
   single embedBatch() call. The sqlite-vec lookups still run
   sequentially (they're fast), but the expensive LLM embed step
   is batched.

3. FTS-first ordering: all lex expansions run immediately (sync,
   no LLM needed) before the vector embedding batch. This means
   FTS results are ready while embeddings compute.

Also cleans up legacy collectionId parameter naming (was number,
now properly string collectionName throughout).
2026-02-15 09:53:28 -05:00
Claude
73136e4f59
fix: verify sqlite-vec readiness after extension load. Closes #169 2026-02-14 19:15:21 -05:00
Claude
96643a28ed
fix: reactivate deactivated documents on re-index. Closes #168 2026-02-14 19:15:21 -05:00
Claude
0eabfe73db
fix: allow $ route filenames in handelize. Closes #162 2026-02-14 19:14:46 -05:00
Claude
da79e77d34
feat: add --version/-v flag. Closes #88 2026-02-14 19:14:46 -05:00
Claude
5dec3ab662
fix: disable following symlinks in glob.scan. Closes #134 2026-02-14 19:14:46 -05:00
Tobi Lütke
96634da39b feat: promote query as primary search command, add CLI aliases
List query first in --help as the recommended search method. Add
vector-search and deep-search as undocumented CLI aliases matching
MCP tool names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:34:29 -05:00
Tobi Lütke
993628e768 fix: add missing context to search results markdown and XML formatters
searchResultsToMarkdown and searchResultsToXml in formatter.ts were
silently dropping the context field. Added formatter.test.ts covering
context visibility across all output formats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:34:23 -05:00
Ilya Grigorik
785bbcf319
MCP: Streamable HTTP, scoring fixes, tool improvements (#149)
* feat: MCP HTTP transport with daemon lifecycle

  Add streaming HTTP transport as an alternative to stdio for the MCP
  server. A long-lived HTTP server avoids reloading 3 GGUF models (~2GB)
  on every client connection, reducing warm query latency from ~16s (CLI)
  to ~10s.

  New CLI surface:
    qmd mcp --http [--port N]   # foreground, default port 3000
    qmd mcp --http --daemon     # background, PID in ~/.cache/qmd/mcp.pid
    qmd mcp stop                # stop daemon via PID file
    qmd status                  # now shows MCP daemon liveness

  Server implementation (mcp.ts):
  - Extract createMcpServer(store) shared by stdio and HTTP transports
  - HTTP transport uses WebStandardStreamableHTTPServerTransport with
    JSON responses (stateless, no SSE)
  - /health endpoint with uptime, /mcp for MCP protocol, 404 otherwise
  - Request logging to stderr with timestamps, tool names, query args

  Daemon lifecycle (qmd.ts):
  - PID file + log file management with stale PID detection
  - Absolute paths in Bun.spawn (process.execPath + import.meta.path)
    so daemon works regardless of cwd
  - mkdirSync for cache dir on fresh installs
  - Removes top-level SIGTERM/SIGINT handlers before starting HTTP
    server so async cleanup in mcp.ts actually runs

  Move hybridQuery() and vectorSearchQuery() into store.ts as standalone
  functions that take a Store as first argument. Both CLI and MCP now
  call the identical pipeline, eliminating the class of bugs where one
  copy drifts from the other.

  Shared pipeline (store.ts):
  - hybridQuery(): BM25 probe → expand → FTS+vec search → RRF →
    chunk → rerank (chunks only) → position-aware blending → dedup
  - vectorSearchQuery(): expand → vec search → dedup → sort
  - SearchHooks interface for optional progress callbacks
  - Constants: STRONG_SIGNAL_MIN_SCORE, STRONG_SIGNAL_MIN_GAP,
    RERANK_CANDIDATE_LIMIT (40), addLineNumbers()

  Bugs fixed by unification:
  - MCP now gets strong-signal short-circuit (was CLI-only)
  - Reranker candidate limit unified at 40 (MCP had 30)
  - File dedup added to hybrid query (MCP was missing it)
  - Collection filter pushed into searchVec DB query
  - Filter-then-slice ordering fixed (MCP was slice-then-filter)

* feat: type-routed query expansion — lex→FTS, vec/hyde→vector

  expandQuery() now returns typed ExpandedQuery[] instead of string[],
  preserving the lex/vec/hyde type info from the LLM's GBNF-structured
  output. hybridQuery() and vectorSearchQuery() route searches by type:
  lex queries go to FTS only, vec/hyde go to vector only.

  Previously, every expanded query ran through BOTH backends — keyword
  variants wasted embedding forward passes, semantic paraphrases wasted
  BM25 lookups. Type routing eliminates ~4 calls/query with zero quality
  loss (cross-backend noise actually hurt RRF fusion).

  Cache format changed from newline-separated text to JSON (preserves
  types). Old cache entries gracefully re-expand on first access.

  CLI expansion tree now shows query types:
    ├─ original query
    ├─ lex: keyword variant
    ├─ vec: semantic meaning
    └─ hyde: hypothetical document...

  Benchmark (5 queries, 1756-doc index, warm LLM, Apple Silicon):

    Metric              Old (untyped)  New (typed)  Delta
    Avg backend calls   10.0           6.0          -40%
    Total wall time     1278ms         549ms        -57%
    Avg saved/query     —              —            146ms

    "authentication setup"          12 → 7 calls   511 → 112ms
    "database migration strategy"   10 → 6 calls   182 → 106ms
    "how to handle errors in API"   10 → 6 calls   216 → 121ms
    "meeting notes from last week"  10 → 6 calls   228 → 110ms
    "performance optimization"       8 → 5 calls   141 → 100ms

  Savings come from skipped embed() calls (~30-80ms each). FTS is
  synchronous SQLite (~0ms), so lex→FTS routing is free while
  vec/hyde→vector-only avoids wasted embedding passes.

* fix: MCP query snippets now use reranker's best chunk, not full body

  extractSnippet() was scanning the entire document body for keyword
  matches to build the snippet. But hybridQuery() already identified
  the most relevant chunk via cross-attention reranking — rescanning
  the full body is redundant and can land on a less relevant section
  if the query terms appear elsewhere in the document.

  CLI was already using bestChunk (set during the refactor). MCP was
  still using body — a pre-existing inconsistency, not a regression.

* feat: dynamic MCP instructions + tool annotations

  The MCP server now generates instructions at startup from actual index
  state and injects them into the initialize response. LLMs see collection
  names, document counts, content descriptions, and search strategy
  guidance in their system prompt — zero tool calls needed for orientation.

  Previously, the only guidance was generic static tool descriptions and
  a user-invocable "query" prompt that no LLM would discover on its own.
  An LLM connecting to QMD had no idea what collections existed, what they
  contained, or how to scope searches effectively.

* change default port to 8181

* fix: BM25 score normalization was inverted

  The normalization formula `1 / (1 + |bm25|)` is a decreasing function of
  match strength. FTS5 BM25 scores are negative where more negative = better
  match (e.g., -10 is strong, -0.5 is weak). The formula mapped:

    strong match (raw -10) → 1/(1+10) =  9%   ← should be highest
    weak match   (raw -0.5) → 1/(1+0.5) = 67%  ← should be lowest

  Three downstream effects:
  1. `--min-score 0.5` (or MCP minScore: 0.5) filtered OUT strong matches
     and kept only weak ones. The MCP instructions recommend this threshold.
  2. CLI `formatScore()` color bands never showed green for BM25 results
     (best matches scored ~9%, green threshold is 70%).
  3. The strong signal optimization in hybridQuery (skip ~2s LLM expansion
     when BM25 already has a clear winner) was dead code — strong matches
     scored ~0.09, never reaching the 0.85 threshold.

  Fix: `|x| / (1 + |x|)` — same (0,1) range, monotonic, no per-query
  normalization needed, but now correctly maps strong → high, weak → low.

  The normalization was born broken (Math.max(0, x) clamped all
  negative BM25 to 0 → every score = 1.0), then PR #76 changed to
  Math.abs which made scores vary but inverted the direction. Neither
  state was ever correct.

* fix: rerank cache key ignores chunk content

  The rerank cache key was (query, file, model) but the actual text sent
  to the reranker is a keyword-selected chunk that varies by query terms.
  Two different queries hitting the same file can select different chunks,
  but the second query gets a stale cached score from the first chunk.

  Example:
    Query "auth flow" → selects chunk about authentication → score 0.92
    Query "auth tokens" → same file, selects chunk about tokens
      → cache HIT on (query, file, model) → returns 0.92 from wrong chunk

  Fix: include full chunk text in cache key. getCacheKey() already
  SHA-256 hashes its inputs, so this adds no key bloat — just
  disambiguation. Old cache entries become natural misses (different key
  shape) and re-warm on next query.

* rename MCP tools for clarity, rewrite descriptions for LLM tool selection

  Rename MCP tools: vsearch → vector_search, query → deep_search.
  LLMs see these names — self-documenting names reduce reliance on
  descriptions for tool selection. CLI commands stay unchanged
  (qmd vsearch, qmd query) — different namespace, users type those.

  Rewrite all search tool descriptions to be action-oriented:
    - search: "Search by keyword. Finds documents containing exact
      words and phrases in the query."
    - vector_search: "Search by meaning. Finds relevant documents even
      when they use different words than the query — handles synonyms,
      paraphrases, and related concepts."
    - deep_search: "Deep search. Auto-expands the query into variations,
      searches each by keyword and meaning, and reranks for top hits
      across all results."

  Rewrite instructions ladder — each tool says what it does, no
  "start here" / "escalate as needed" strategy language.

  Delete the "query" prompt (registerPrompt) — it restated what
  descriptions + instructions already cover. No LLM proactively
  calls prompts/get to learn how to use tools.

* supress HTTP server logs during tests
2026-02-10 16:37:33 -05:00
David Gil
47b705409e
fix: BM25 score normalization - use Math.abs instead of Math.max (#76)
BM25 scores in SQLite FTS5 are negative (lower = better match).
The previous code used Math.max(0, score) which clamped all negative
scores to 0, resulting in all results showing 100% (score = 1.0).

Fix: Use Math.abs(score) to properly convert negative BM25 scores
to positive values for the normalization formula.

Before: All results show Score: 100%
After:  Scores vary based on actual BM25 relevance (e.g., 16%, 5%, 6%)

Fixes #74
2026-02-01 16:38:52 -05:00
Christopher Stöckl
0f87e2429d
fix: workaround Bun UTF-8 path corruption bug (#82)
Replace Bun.file() async calls with Node.js fs sync methods to work
around a Bun bug that corrupts UTF-8 file paths containing non-ASCII
characters.

Bug: Bun.file(filepath).stat() and Bun.file(filepath).text() internally
mangle UTF-8 encoding, causing ENOENT errors with mojibake paths when
accessing files in iCloud Drive and other locations.

Changes:
- src/qmd.ts: Use readFileSync instead of Bun.file().text()
- src/qmd.ts: Use statSync instead of Bun.file().stat() for file metadata
- src/store.ts: Use statSync for SQLite custom path detection
2026-02-01 16:37:04 -05:00
Matthías Páll Gissurarson
5de063ae96
Fix: Add missing --index option to argument parser (#84)
* Fix: Add missing --index option to argument parser

The --index flag was documented and used in code but not defined
in parseArgs options, causing it to be ignored. Now properly handles
custom index names like: qmd --index test status

* Feature: Use index name for config files too

Now --index <name> loads ~/.config/qmd/<name>.yml instead of index.yml.
This allows completely separate indexes with their own collections.

Example:
  qmd --index hackage status
  → Uses ~/.config/qmd/hackage.yml + ~/.cache/qmd/hackage.sqlite

Moved hackage collection to hackage.yml for separation.
2026-02-01 16:36:51 -05:00
Tobi Lütke
102ff861d3
fix: use Qwen3 recommended sampling params to prevent repetition loops
- Changed temperature from 0/0.1 to 0.7 (Qwen3 non-thinking mode default)
- Added topK=20, topP=0.8 per Qwen3 docs
- Added repeatPenalty with presencePenalty=0.5 for query expansion
- Fixes infinite loop on acronyms like DHH, BFCM

Qwen3 docs explicitly warn: 'DO NOT use greedy decoding, as it can
lead to performance degradation and endless repetitions'
2026-02-01 03:24:20 +00:00
Tobi Lütke
479b68bbf1
add qmd model pull and refresh logic 2026-01-31 23:02:23 +00:00
Tobi Lutke
7de18ee066
Merge main into finetune
Brings in:
- /only: variants for single-type expansions
- LLM session management for lifecycle safety
- skills.sh integration for AI agent discovery
- Various bug fixes for vector search and embeddings

Merge conflicts resolved by keeping hyde-first format ordering
from finetune branch while accepting expanded templates and
new features from main.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:10:22 -05:00
Tobi Lutke
785620467a
refactor: reorder output format to put hyde line first
Move the hyde (hypothetical document) line to the beginning of the
output format, before lex and vec lines. This better reflects the
logical flow where the hypothetical document is generated first and
then informs the keyword/semantic expansions.

Also adds auto-download of eval_common.py in training scripts for
standalone HuggingFace Jobs execution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:09:04 -05:00
Tobi Lütke
32d313ad6b Add LLM session management for lifecycle safety
Adds a session layer that prevents LLM contexts from being disposed
mid-operation during long-running tasks like batch embedding or
multi-step search workflows (expand → embed → rerank).

Key changes:
- Add LLMSessionManager with reference counting for active sessions
- Add LLMSession class for scoped access with automatic acquire/release
- Add withLLMSession() API for multi-step workflows
- Update idle timer to check canUnloadLLM() before disposing
- Wrap querySearch, vectorSearch, and embed command in sessions
- Add optional session parameter to searchVec and getEmbedding

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:20:20 +00:00
Christopher Jones
6d9871d2f5
Fix DisposedError during slow batch embedding (#41) 2026-01-29 18:28:48 -08:00
Tobias Lütke
eb1b77c8cb
Deploy fine-tuned GRPO model as default query expansion (#67)
* Add query expansion model finetuning infrastructure

- Training scripts for Qwen3-0.6B and 1.7B models
- Dataset generation from s-emanuilov/query-expansion
- Evaluation scripts comparing finetuned vs baseline models
- GRPO RL training script (optional improvement)
- Export script for GGUF conversion

Results:
- 0.6B finetuned: 95% format compliance (lex/vec/hyde)
- Baseline: 0% format compliance
- Dataset: 5,157 examples on HuggingFace Hub

Models available at:
- tobil/qmd-query-expansion-0.6B (recommended)
- tobil/qmd-query-expansion-train (dataset)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GRPO training script for TRL API compatibility

- Use max_completion_length instead of max_new_tokens
- Use processing_class instead of tokenizer
- Use args instead of config for GRPOTrainer
- Add __name__ attribute to reward function class
- Accept **kwargs in reward function for extra TRL args
- Add new LoRA adapter after merging SFT weights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update README with final evaluation results

- 0.6B SFT: 95% format compliance (best)
- 0.6B GRPO: 0% (catastrophic forgetting from RL)
- 1.7B v2: training completed, evaluation pending
- Added GRPO evaluation results

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add comprehensive scoring system for query expansion

New scoring criteria (0-100 points):
- Format (30): Must have lex: and vec: prefixes
- Diversity (30): Multiple types, no echoing query, diverse expansions
- Hyde (20): Optional, concise, no newlines, no word repetition
- Quality (20): Lex=keywords, vec=natural language

See SCORING.md for full documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add HuggingFace login and comprehensive scoring to GRPO v2 training

- Add explicit HF_TOKEN login before training
- Use SCORING.md criteria as RL reward function
- Conservative training: LR 1e-6, LoRA rank 4
- Reward scores: good=0.94, bad=0.38

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor finetune folder: train/rl scripts with YAML configs

Major changes:
- train.py: Generic SFT training script using YAML config
- rl.py: Generic GRPO training script using YAML config
- configs/: YAML configs per training run (sft_v4.yaml, grpo_v4.yaml)
- dataset/: Data preparation scripts moved here
- tui.py: Interactive model testing interface

Training results:
- SFT v4: 98.8% avg score (all Excellent)
- GRPO v4: 0% (failed - model drifted to verbose explanations)

Removed per-model scripts (train_0.6B.py, train_1.7B.py, etc)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add named entity extraction to GRPO reward function

Key changes:
- Extract named entities (acronyms, proper nouns, technical terms)
- Heavy penalty (-30) when lex queries miss named entities
- Penalty (-15) for generic filler phrases like "find information about"
- Compound entity detection (TDS motorsports -> both words)
- Update GRPO config with KL regularization (beta=0.04)
- Lower learning rate (5e-7) and add max_steps (200)

Test results:
- "who is TDS motorsports" good: 1.00, bad: 0.30 (was 0.75)
- "how to use React hooks" good: 0.87, bad: 0.45 (was 0.75)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add chat template leakage detection to reward function

Zero reward for outputs containing:
- <|im_start|>, <|im_end|> tokens
- <think>, </think> tags (Qwen3 thinking mode)
- Role markers like \nassistant\n, \nuser\n
- <|endoftext|> token

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Strict format validation: every line must be lex:/vec:/hyde:

Any line that doesn't start with a valid prefix now returns 0.0
instead of just counting as a penalty. This prevents any prose,
explanations, bullet points, or other invalid content.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Clean up evaluation files

- Remove old versioned evaluation files (0.6B, 1.7B, baseline)
- Rename evaluation_v4.json -> evaluation_sft.json
- Rename evaluation_v4_grpo.json -> evaluation_grpo_failed.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor evals into separate run and score scripts

New structure:
- evals/run.py: Generate model outputs to JSONL
- evals/score.py: Score outputs with detailed breakdown
- evals/queries.txt: Test queries (26 total)

Features:
- Supports both HF Hub and local model paths
- Named entity preservation scoring
- Chat template leakage detection
- Strict format validation (every line must be lex:/vec:/hyde:)
- Generic phrase detection

Usage:
  uv run evals/run.py --model tobil/qmd-query-expansion-0.6B-v4
  uv run evals/score.py evals/results_*.jsonl

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GRPO model loading to use SFT base first

The GRPO adapter was trained on merged SFT weights, so loading it
directly on the base model results in 0% score. Added --sft-model
parameter to evals/run.py to load SFT first, then apply GRPO adapter.

With correct loading: GRPO scores 89.7% (all 26 queries Excellent).

Updated README with correct GRPO score and loading instructions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix TUI to load GRPO models with SFT base first

GRPO adapters were trained on merged SFT weights, so they need SFT
loaded and merged first before applying the GRPO adapter.

Updated MODELS config to include sft_base path for GRPO models,
and load_model() now handles the SFT -> merge -> GRPO flow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update README for unified model repository structure

All models (0.6B, 1.7B, 4B) with SFT and GRPO variants now go into
a single HuggingFace repo (tobil/qmd-query-expansion) with subfolders
for each size and training method.

Updated loading examples to show subfolder-based model loading.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update README with separate model repos

Changed from subfolder approach to separate repos per model since
trainer.push_to_hub() doesn't support subfolder argument.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add 1.7B and 4B GRPO training and GGUF conversion scripts

Training scripts for GRPO fine-tuning:
- train_1.7B_grpo.py: GRPO training for Qwen3-1.7B
- train_4B_grpo.py: GRPO training for Qwen3-4B

GGUF conversion scripts:
- convert_1.7B_gguf.py: Merge SFT+GRPO adapters and convert to GGUF
- convert_4B_gguf.py: Merge SFT+GRPO adapters and convert to GGUF

All scripts use PEP 723 inline dependencies for HuggingFace Jobs.

Models published:
- tobil/qmd-query-expansion-1.7B-sft
- tobil/qmd-query-expansion-1.7B-grpo
- tobil/qmd-query-expansion-1.7B-gguf
- tobil/qmd-query-expansion-4B-sft
- tobil/qmd-query-expansion-4B-grpo
- tobil/qmd-query-expansion-4B-gguf

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove beads issue tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove beads reference from CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GRPO reward function to handle think blocks and end tokens

- Strip <|im_end|> token from completions (model output includes it)
- Change think_penalty to skipped_think bonus (+20 for not using think)
- Adjust max_possible to account for bonus (120/140)
- Fix typo in chat template artifact check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Make TUI model list dynamic from HuggingFace Hub

- Fetch available qmd-query-expansion models from tobil/ on Hub
- Auto-detect model size (0.6B, 1.7B, 4B) and use correct base model
- Group models by type (SFT vs GRPO) in menu
- Skip GGUF repos in model listing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GRPO training: apply chat template to prompts

The SFT model was trained with chat template format but GRPO was
passing raw prompts. Now prompts are formatted with tokenizer.apply_chat_template()
so the model sees the same format it learned during SFT.

Also update extract_query_from_prompt to strip chat template artifacts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Finetune 2.0: consolidate and simplify the entire training pipeline

Consolidate ~2,800 lines of duplicated code across 12 files into 5 clean,
well-documented files targeting Qwen3-1.7B end-to-end.

Key changes:
- Extract reward function into single source of truth (reward.py)
  Previously duplicated 3x with divergent bugs across rl.py,
  train_1.7B_grpo.py, and train_4B_grpo.py
- Unify training into one script with sft/grpo subcommands (train.py)
  Replaces train.py + rl.py + train_1.7B_grpo.py + train_4B_grpo.py
- Merge eval generate+score into single eval.py
  Replaces evals/run.py + evals/score.py
- Parameterize GGUF conversion by --size (convert_gguf.py)
  Replaces convert_1.7B_gguf.py + convert_4B_gguf.py
- Fix critical bug: rl.py silently ignored beta/temperature from config,
  causing the exact catastrophic drift its own comments warned about
- Fix prompt consistency: all files use /no_think chat template format
- Retarget configs from 0.6B to 1.7B
- Comprehensive README documenting the full pipeline

Removed: rl.py, train_1.7B_grpo.py, train_4B_grpo.py, convert_1.7B_gguf.py,
convert_4B_gguf.py, tui.py, evals/run.py, evals/score.py

Net: -3,429 lines, +382 lines

Co-Authored-By: Claude (claude-fudge-eap-cc) <noreply@anthropic.com>

* Add HF Jobs scripts, temporal query examples, and training results

- jobs/sft.py and jobs/grpo.py: self-contained scripts for
  `hf jobs uv run` (no local GPU needed)
- 12 temporal/recency query examples in training data (e.g. "recent
  news about Shopify" -> lex with years 2025/2026)
- 4 temporal test queries in evals/queries.txt
- README updated with HF Jobs workflow, training results, and
  updated file structure
- Remove .beads tracking

SFT and GRPO successfully trained on A10G via HF Jobs:
  SFT: eval loss 0.321, token accuracy 92.4%
  GRPO: mean reward 0.757, 200 steps, KL 0.00048

Co-Authored-By: Claude (claude-fudge-eap-cc) <noreply@anthropic.com>

* Deploy fine-tuned GRPO model as default for query expansion

Switch from generic Qwen3-1.7B-Q8_0 (~2.2GB) to fine-tuned
qmd-query-expansion-1.7B-q4_k_m (~1.1GB). The fine-tuned Q4
scores 91.7% avg with 30/30 Excellent, outperforming the base Q8.

- Update default generate model in src/llm.ts
- Update README model table, architecture diagram, config block
- Add v2 training data, eval scripts, and quantize job
- Remove superseded v1 training data (5,742 → 1,000 examples)
- Update finetune README with v2 results and file structure

Co-Authored-By: Claude (claude-fudge-eap-cc) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:25:17 -08:00
Tobi Lutke
8572c2fd94
Deploy fine-tuned GRPO model as default for query expansion
Switch from generic Qwen3-1.7B-Q8_0 (~2.2GB) to fine-tuned
qmd-query-expansion-1.7B-q4_k_m (~1.1GB). The fine-tuned Q4
scores 91.7% avg with 30/30 Excellent, outperforming the base Q8.

- Update default generate model in src/llm.ts
- Update README model table, architecture diagram, config block
- Add v2 training data, eval scripts, and quantize job
- Remove superseded v1 training data (5,742 → 1,000 examples)
- Update finetune README with v2 results and file structure

Co-Authored-By: Claude (claude-fudge-eap-cc) <noreply@anthropic.com>
2026-01-28 23:24:58 -08:00
jdvmi00
64c6e6c2e3
fix: rename collectionId to collectionName in searchVec for proper filtering (#61) 2026-01-27 22:03:02 -08:00
Freeman Jiang
bfb0eebc3e
fix: use sequential embedding on CPU-only systems to avoid race condition (#54)
* fix: add promise guard to ensureEmbedContext to prevent race condition

Root cause: ensureEmbedContext() was not thread-safe. When multiple parallel
embedding requests called ensureEmbedContext() simultaneously, all would see
embedContext === null and start creating new contexts. This race condition
caused 'Context is disposed' errors as contexts were overwritten/orphaned.

The fix adds a promise guard (embedContextCreatePromise) to ensure only one
context creation runs at a time - identical to the pattern already used in
ensureGenerateModel().

Changes:
- Add embedContextCreatePromise field to track in-progress context creation
- Modify ensureEmbedContext() to wait for existing creation if in progress
- Update test comment and timeout for CPU-only systems

Testing:
- Fresh model download + qmd embed: 28/28 chunks succeeded (was 14/27)
- All embedBatch tests pass
- No warmup hack needed - full parallel performance from the start

Environment tested:
- Ubuntu 24.04 LTS (x64), Bun 1.3.6, node-llama-cpp 3.14.5, no GPU

* test: improve race condition test to verify single context creation

The previous test only verified embeddings succeeded but didn't prove the fix
actually prevents multiple context creation. This improved test:

- Instruments createEmbeddingContext to count invocations
- Runs 5 concurrent embedBatch calls on a fresh LlamaCpp instance
- Asserts exactly 1 context is created (fails with 5 without the fix)

Verified locally:
- With fix: 1 context created (PASS)
- Without fix: 5 contexts created (FAIL)

* chore: clear embedContextCreatePromise in dispose() for consistency
2026-01-27 22:02:36 -08:00
Copilot
053252ca24
Add Windows path utilities with cross-platform test coverage (#51)
* Initial plan

* Add Windows path utility functions and comprehensive tests

Co-authored-by: tobi <347+tobi@users.noreply.github.com>

* Add clarifying comments for Git Bash path detection logic

Co-authored-by: tobi <347+tobi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tobi <347+tobi@users.noreply.github.com>
2026-01-27 09:05:47 -05:00
sh54
ba7391832d
Add org-mode title extraction support (#50)
Refactor extractTitle to use extension-based extractors:
- .md: preserves original markdown logic (Notes skip behavior)
- .org: extracts from #+TITLE: property or first * heading

Extensions are lowercased for case-insensitive matching.
Easy to add more file types in the future.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:38:49 -05:00
komsit37
88f78314bb
Fix sqlite-vec loading with BREW_PREFIX (#42) 2026-01-22 17:00:48 -05:00
Tobias Lütke
3c7dfad1b6
Make docid lookup more lenient with quotes support (#39)
- Add normalizeDocid() to strip quotes and # prefix
- Add isDocid() to detect docid patterns including quoted forms
- Update findDocumentByDocid, findDocument, getDocument to use new helpers
- All formats now work: #abc123, abc123, "#abc123", "abc123", '#abc123', 'abc123'
- Add 18 unit tests for normalizeDocid and isDocid

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:33:42 -04:00
Joshua Lelon Mitchell
fbd7fe8c8e
Fix docid lookup in qmd get command (#36)
The `qmd get` command was documented to support docid lookups
(e.g., `qmd get "#abc123"` or `qmd get abc123`), but the
implementation in getDocument() never actually handled docids.

The findDocumentByDocid() function existed in store.ts and worked
correctly, but getDocument() in qmd.ts reimplemented document
lookup without calling it.

This adds docid detection at the start of getDocument() to resolve
docids to virtual paths before other path handling.

Co-authored-by: Joshua Mitchell <jlelonmitchell@gmail.com >
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:28:34 -04:00
Tobi Lutke
7817dc11a4
Show embedding notice only once at end of qmd update
Previously, the "Run 'qmd embed' to update embeddings" message was
printed after each collection was indexed, repeating the same global
count multiple times. Now it's shown once at the end of the update.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:32:59 -04:00
Tobias Lütke
6fbad4e9a6
Merge pull request #15 from gavrix/main 2026-01-18 10:34:44 -05:00
Tobias Lütke
77e1f82cd9
Merge pull request #23 from mbrendan/fix-vsearch-hang 2026-01-18 10:34:33 -05:00
Wojtek
a0ca60bc6f Fix MCP server exiting immediately after startup 2026-01-13 21:41:56 -05:00
Brendan McCord
aea494bb24 Fix regression test to use proper test helpers
Use insertTestDocument and createTestCollection helpers
to match existing test patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:17:05 -06:00
Brendan McCord
01d74727f7 Add regression test and explanatory comments
- Add detailed comments explaining why two-step query is necessary
- Add regression test for sqlite-vec JOIN hang bug
- Link to PR in comments for future reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:12:56 -06:00
Brendan McCord
216793380a Fix vsearch/query hang caused by sqlite-vec JOIN incompatibility
sqlite-vec virtual tables don't work correctly with JOINs in the same
query - they cause the query to hang indefinitely.

Changes:
- searchVec: Rewrite to use two-step approach
  1. Query vectors_vec table alone (no JOINs)
  2. Look up document info separately using result hash_seqs
- vsearch: Change from Promise.all to sequential for loop
  (node-llama-cpp embedding context doesn't handle concurrent calls)

This fixes vsearch and hybrid query commands that were hanging at
"Searching N vector queries..."

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:08:32 -06:00
Sergey Gavrilyuk
bebee61bec Fix case sensitivity in Qwen3-1.7B model filename
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:46:24 -07:00
Tobi Lutke
5fa66fd228
Skip empty files during indexing
Empty files have nothing useful to index or embed. Previously they would
be indexed with an empty body, causing confusing "1 need embedding" status
messages that could never be resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:44:52 -05:00
Tobi Lutke
5b636b2e4c
Merge pull request #8 from burke/native-realpath
Use native realpathSync instead of spawning subprocess per file
(15.6s -> 1.0s for large collections)
2026-01-09 17:33:12 -05:00
Tobias Lütke
fe0fd0859b
Merge pull request #9 from burke/single-tokenize
Optimize chunking to tokenize once per document
2026-01-09 17:24:54 -05:00
Tobi Lutke
4d21c5ab2b
Fix collection filter SQL and support non-ASCII filenames
Issue #11: Collection filter (-c) SQL error
- Fixed searchVec to properly parameterize collection filter
- Changed collectionId check from !== undefined to truthy
- Added test for searchVec with collection filter

Issue #10: Non-ASCII filename support
- Updated handelize() to use Unicode property escapes (\p{L}\p{N})
- Now supports Cyrillic, Japanese, and other Unicode filenames
- Updated tests to verify Unicode filename handling

Also:
- Fixed expandQuery to filter out lex entries when includeLexical=false
- Updated expandQuery tests to match actual behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:40:36 -05:00
Tobi Lutke
0dfd7a4686
Fix query hang, SQL errors, and missing docid in search results
- Fix SQL syntax error when collectionId is empty string (searchFTS, searchVec)
- Add 1-second timeout to llama.dispose() to prevent indefinite hang
- Add process.exit(0) after cleanup for clean CLI exit
- Include hash/docid in search results mapping
- Update query expansion to use structured Queryable types
- Switch to Qwen3-1.7B model for better query expansion
- Improve bun discovery in qmd wrapper script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:24:00 -05:00
Burke Libbey
3974c231dc Optimize chunking to tokenize once per document
Instead of calling countTokens() multiple times during binary search
for chunk boundaries, tokenize the document once upfront and slice
token arrays. This reduces tokenizer calls from O(chunks × iterations)
to O(1) per document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:35:52 -05:00
Burke Libbey
cfc5ebd553 Use native realpathSync instead of spawning subprocess per file
The previous implementation spawned a subprocess for every file during
indexing (e.g., 4500 subprocess spawns for a large collection). This
caused resource exhaustion and random hangs. Using Node's native
realpathSync is orders of magnitude faster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:53:06 -05:00
Tobi Lutke
431f6e505b
Fix qmd embed crash and resolve all TypeScript errors
- Fix ReferenceError in vectorIndex(): firstResult was used but never
  defined. Added code to embed first chunk to get embedding dimensions.

- Fix 87 TypeScript errors across codebase:
  - formatter.ts: Define MultiGetFile type locally (was missing from store.ts)
  - collections.ts: Add non-null assertion for array access
  - mcp.ts: Fix StatusResult type to match store.ts CollectionInfo,
    add list parameter to ResourceTemplate, fix undefined checks
  - qmd.ts: Fix boolean/string type coercions, undefined array access
  - llm.test.ts: Update expandQuery tests for Queryable[] return type,
    fix array access assertions
  - store.test.ts: Add non-null assertions for array access in tests
  - eval-harness.ts: Fix array access assertion
2025-12-31 13:32:30 -04:00