51 KiB
Lila Data Pipeline — Technical Documentation
Multilingual dictionary enrichment pipeline for the Lila vocabulary trainer. Last updated: 2026-06-17
Table of Contents
- Executive Summary
- Problem & Context
- Architecture Overview
- Current Implementation
- The LLM Layer
- The Gender Problem & Kaikki Integration
- Batching Strategy
- Hardware Constraints
- Testing & Quality Assurance
- Future Extensions & Roadmap
- Decisions Log
- Known Issues & Dev Notes
- How to Run
- Roadmap
Quick Reference
| What | Where |
|---|---|
| Entry point | pipeline.ts |
| LLM config | config/llm.ts |
| System prompt | config/prompt.ts |
| Output schema | utils/merge-enriched-data.ts |
| Batch size config | config/batch.ts (planned) |
| Current model | qwen2.5-1.5b-instruct-q4_k_m.gguf |
| Target scale | 100,000+ words |
1. Executive Summary
The Lila Data Pipeline is a TypeScript-based batch processing system that enriches raw word lists into structured multilingual dictionary entries for the Lila vocabulary trainer. It takes a source wordlist (e.g., English nouns) and, for each word, generates:
- One or more senses (definitions)
- A natural example sentence per sense
- A CEFR-based difficulty level (
easy/medium/hard) - Translations into German, Italian, Spanish, and French, each with grammatical gender
The pipeline is designed to scale to 100,000+ words across multiple languages and parts of speech (nouns, verbs, adjectives, adverbs). It is currently in active development: the core architecture is stable, the LLM integration layer is being evaluated across local and online providers, and a configurable batching system is planned to unlock throughput at scale.
Key Design Principles
| Principle | Rationale |
|---|---|
| Quality first | Definitions, examples, translations, and gender must be accurate. Speed and cost are secondary. |
| Local-first, cloud-fallback | Local LLMs (llama.cpp) are the default for cost control and data privacy. Online APIs are evaluated as alternatives for speed. |
| Resumable & idempotent | Each word writes to its own JSON file. The pipeline skips already-processed words on restart. |
| Configurable batching | Batch size (1, 5, 15, 50, etc.) is a single config value. The pipeline adapts without code changes. |
Open Question: Gender Accuracy
Grammatical gender is currently generated by the LLM as part of the translation object. Early testing showed that Qwen2.5-1.5B systematically defaults to neuter for languages that do not have neuter grammatical gender (Italian, Spanish, French). Whether this is a model size issue (fixable by moving to 3B+) or a training data issue (requiring an external lookup) is unresolved.
Options under evaluation:
- Larger local models (Qwen2.5-3B, Qwen3.5-1.7B)
- Online models with stronger multilingual training (Gemini, DeepSeek)
- Post-processing lookup via Kaikki Wiktionary dumps as a fallback or replacement
No decision made. Gender handling will be determined by the 20-word quality torture suite.
Current Status (2026-06-17)
- Core pipeline: scanning, enrichment, merging, verification, writing
- Local LLM integration via llama.cpp server (OpenAI-compatible API)
- Schema validation for generated JSON
- Progress tracking and timing metrics
- In progress: Evaluating local models (Qwen2.5-1.5B tested; Qwen2.5-3B download pending)
- In progress: Designing configurable batching system
- Pending: 20-word quality torture suite (will decide gender approach)
- Pending: Online API evaluation (Gemini free tier, DeepSeek, Groq)
One-Line Architecture
source wordlists -> llama.cpp server (LLM) -> merge senses -> verify schema -> write .json
|
[gender: LLM-generated, accuracy TBD]
Files at a Glance
| File | Purpose |
|---|---|
pipeline.ts |
Orchestrator. Scans sources, loops words, coordinates all stages |
config/llm.ts |
API URL, default parameters (temperature, max_tokens, etc.) |
config/prompt.ts |
System prompt sent to the LLM |
utils/enrich-word.ts |
Calls LLM, parses response, builds EnrichedSense[] |
utils/merge-enriched-data.ts |
Merges skeleton + enriched senses into final JSON |
utils/verify-enriched-file.ts |
Schema validation (required fields, types, gender enum) |
utils/check-llm-server.ts |
Health check before pipeline starts |
utils/progress-tracker.ts |
[current/total] formatting for console output |
utils/pipeline-timer.ts |
Per-word and global timing + LLM token metrics |
Scale Target
| Metric | Target |
|---|---|
| Words | 100,000+ |
| Languages | English (source), German, Italian, Spanish, French (targets) |
| Parts of speech | Nouns, verbs, adjectives, adverbs |
| Output | One .json file per word, ~2-5KB each |
2. Problem & Context
Why Build This?
Existing multilingual dictionaries and translation APIs provide raw word-to-word mappings. They do not provide the structured, pedagogical data needed for a vocabulary trainer:
| What Exists | What is Missing | Why It Matters |
|---|---|---|
| Word + translation | Student-friendly definition | Learners need explanations, not just equivalents |
| Static difficulty labels | CEFR-calibrated difficulty | "Easy" vs "hard" must map to learner proficiency levels |
| Isolated translations | Natural example sentences | Context is how vocabulary is actually acquired |
| Raw gender data | Gender integrated with translation | Flashcards must show gender immediately, not as a separate lookup |
| Bulk word lists | Per-word structured JSON | The trainer consumes one file per word for fast random access |
The Target User
A language learner using the Lila vocabulary trainer. They see a word, its definition, an example sentence, and translations with gender - all calibrated to their CEFR level (A1-C2).
Why Not Use Existing Dictionaries?
- Wiktionary: Rich data, but unstructured, inconsistent formatting, no CEFR levels, no student-friendly definitions
- Kaikki (Wiktionary dump): Structured JSON, excellent for gender/translation lookup, but definitions are often technical, no difficulty classification, no example curation
- Google Translate / DeepL: No definitions, no examples, no difficulty, no structured output
- Existing language learning apps: Closed data, no export, no control over content
The LLM fills the gap: it generates pedagogical content (student-friendly definitions, natural examples, difficulty classification) that no existing database provides at scale.
Language Direction
The pipeline is direction-agnostic. A wordlist is defined by:
- Source language: the language of the input words
- Target languages: the languages to translate into
Current focus: English -> German/Italian/Spanish/French
Planned directions include German -> French, Italian -> Spanish, etc. The LLM prompt and output schema support any combination - the only change is the source wordlist and the target languages specified in the prompt.
Why 100,000+ Words?
- Coverage: A learner needs ~10,000 words for B2 proficiency. The pipeline targets 100k to cover multiple languages, POS categories, and difficulty levels with room for curation.
- Languages: English (source) -> German, Italian, Spanish, French (targets).
- Parts of speech: Nouns (current), verbs, adjectives, adverbs. Each POS has different enrichment needs (verb conjugations, adjective agreement, etc.).
The Data Flow
Source files LLM enrichment Final JSON
(one word per line) (definitions, (one per word,
examples, self-contained)
english/nouns difficulty,
english/verbs translations) time.json
italian/nouns year.json
... people.json
The Quality Challenge
Generating 100,000 entries with an LLM introduces risks:
| Risk | Mitigation |
|---|---|
| Hallucinated definitions | Low temperature (0.1), strict system prompt, schema validation |
| Incorrect grammatical gender | Under evaluation: larger models or external lookup |
| Inconsistent difficulty levels | Explicit CEFR mapping in prompt, spot-checking |
| JSON parse failures | Retry logic, schema validation, cleanup on failure |
| Model drift (online APIs) | Version pinning, local fallback |
Why TypeScript + Node?
- Familiarity: Existing project uses TypeScript (frontend in TanStack Router + React)
- Ecosystem:
readlinefor streaming files,fsfor JSON I/O, nativefetchfor HTTP - Portability: Runs on the same Debian laptop as the llama.cpp server
- No build complexity:
tsxfor direct execution, no bundler needed
Why llama.cpp?
- GGUF format: Single-file models, easy to swap, quantize, and version
- OpenAI-compatible API:
/v1/chat/completionsmeans the same code works for local and online models - No dependencies: Self-contained binary, runs on old hardware (tested on GTX 950M)
- Privacy: Local inference means no data leaves the machine
3. Architecture Overview
Pipeline Flow
Scan sources -> Check LLM -> Loop words -> Skip if exists -> Create skeleton
-> Call LLM -> Parse JSON -> Merge -> Write atomically -> Verify schema
Resumability
- Skip existing: Checks if
{word}.jsonexists with non-emptysenses - Atomic writes:
.tmp-> rename, no partial files on crash - Cleanup on failure: Deletes incomplete file, continues to next word
Directory Structure
data-pipeline/
|-- pipeline.ts # Entry point / orchestrator
|-- config/
| |-- llm.ts # API URL, model params
| |-- prompt.ts # System prompt
|-- utils/ # See source files (provided separately)
|-- source-data/
| |-- {language}/
| |-- {pos} # One word per line, no extension
|-- worddata/
|-- {language}/
|-- {pos}/
|-- {word}.json # One self-contained file per word
Output Schema
Each .json file contains: word, language, pos, senses[] (each with sense, example, difficulty_level, translations per target language), enrichedAt, model.
Full TypeScript interfaces: utils/merge-enriched-data.ts.
Error Handling
| Failure | Behavior |
|---|---|
| LLM server offline | Hard fail at startup |
| LLM returns bad JSON | Log, delete skeleton, continue |
| Schema validation fails | Log warnings, keep file |
| Individual word fails | Does not stop pipeline |
Metrics
Per-run: words processed/skipped/failed, duration, throughput, LLM token counts and speeds. See utils/pipeline-timer.ts.
4. Current Implementation
Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Runtime | Node.js + tsx |
Direct TypeScript execution, no build step |
| HTTP client | Native fetch |
Works for local llama.cpp and online APIs |
| File I/O | fs + readline |
Streaming line reader for large wordlists |
| JSON | Native JSON.parse/stringify |
Simple, no schema library needed |
Configuration
| File | Purpose |
|---|---|
config/llm.ts |
LLM_API_URL, LLM_DEFAULT_PARAMS (temperature, top_p, max_tokens) |
config/prompt.ts |
System prompt with CEFR mapping, required fields, example output |
Key Modules
| File | Responsibility |
|---|---|
utils/enrich-word.ts |
Calls LLM, strips markdown, parses JSON array, builds EnrichedSense[] with composite IDs |
utils/merge-enriched-data.ts |
Merges skeleton {word, language, pos} with LLM senses, adds enrichedAt and model |
utils/verify-enriched-file.ts |
Schema validation: required fields, array lengths, gender enum, translation structure |
utils/pipeline-timer.ts |
Tracks per-word and global metrics (duration, tokens, throughput) |
Current Model
| Property | Value |
|---|---|
| Model | qwen2.5-1.5b-instruct-q4_k_m.gguf |
| Size | ~1.0GB |
| Quantization | Q4_K_M |
| Server | llama.cpp (llama-server) |
| API | OpenAI-compatible /v1/chat/completions |
llama-server Flags: History & Rationale
The server flags evolved through trial and error on the target hardware (Intel i7-6500U, GTX 950M 4GB, 8GB RAM). Below is what was tried, what failed, and why the current flags were chosen.
Hardware Constraints
| Component | Spec | Implication |
|---|---|---|
| CPU | i7-6500U (2 physical cores, 4 threads HT) | -t 2 matches physical cores; HT hurts more than helps |
| GPU | GTX 950M (Maxwell, 2015) | 32 GB/s memory bandwidth, 4GB VRAM - bandwidth-starved, not compute-starved |
| RAM | 8GB (3.95GB usable) | --mlock pins model in RAM; system must not swap |
Flag Evolution
| Flag | Value Tried | Result | Why |
|---|---|---|---|
-m |
qwen3.5-4b-q4_k_m.gguf |
Works, ~47s/word | Baseline. Correct genders. 2.6GB file, tight on VRAM. |
-m |
Ministral-3b-instruct.Q4_K_M.gguf |
Broken | Tokenizer mismatch (Tekken). Outputs gibberish regardless of template. See Known Issues. |
-m |
qwen2.5-1.5b-instruct-q4_k_m.gguf |
Works, ~8s/word | Current. Fast but gender accuracy degraded. |
-ngl |
999 |
Keeps | Offload all layers to GPU. Required for any speed. |
-c |
4096 |
Wasteful | 4K context for 300-token dictionary entries wastes ~400MB VRAM. |
-c |
2048 |
Current | Sufficient headroom. Frees VRAM for KV cache. |
-b / -ub |
512 |
Keeps | Sweet spot for Maxwell. Larger batches (1024+) add overhead on old GPUs. |
-b / -ub |
2048 |
Slower on 950M | Tested briefly. No improvement, possibly worse due to memory pressure. |
-t |
4 |
Slower | Hyperthreading cores hurt llama.cpp performance. |
-t |
2 |
Current | Matches 2 physical cores. |
--threads-batch |
(default) | Risky | Defaults to same as -t, but explicit is safer. |
--threads-batch |
2 |
Current | Explicit match to -t. |
--flash-attn |
(omitted) | Correct | On Maxwell (compute 5.0), Flash Attention adds overhead. Not used. |
--flash-attn |
(tested) | No gain | Briefly tried with Qwen3.5-4B. No speedup, possibly regression. |
--mlock |
Keeps | Pins model weights in RAM. Prevents OS swapping on memory pressure. | |
--prio |
2 |
Keeps | Raises process priority. Marginal on this hardware, harmless. |
--reasoning |
off |
(Qwen3.5 only) | Qwen3.5 has reasoning mode. Disabling it speeds up non-reasoning tasks. Irrelevant for Qwen2.5. |
Current Command
./build/bin/llama-server \
-m models/qwen2.5-1.5b-instruct-q4_k_m.gguf \
-ngl 999 \
-c 2048 \
-b 512 \
-ub 512 \
-t 2 \
--threads-batch 2 \
--host 127.0.0.1 \
--port 8080 \
--mlock \
--prio 2
What Was Not Tried (And Why)
| Flag | Reason Skipped |
|---|---|
-fa / --flash-attn |
Maxwell architecture lacks efficient FA kernels. Benchmarks show regression or no gain on pre-Ampere GPUs. |
--no-mmap |
--mlock achieves the same (pin in RAM) without the I/O overhead of disabling mmap. |
-np / --parallel |
Not needed. Single sequential pipeline, no concurrent requests. |
--cont-batching |
Default in recent llama.cpp. No need to toggle. |
--defrag-thold |
KV cache defragmentation. Only relevant for very long contexts or heavy reuse. Not needed for 2048 ctx. |
-ot / --override-tensor |
Expert-level. No tensor-specific issues observed. |
Future Flag Experiments
| Experiment | Expected Outcome |
|---|---|
-c 1024 |
Further VRAM savings. Risk: insufficient for batching larger prompt sizes. |
-b 256 -ub 256 |
Test if smaller batches reduce latency on bandwidth-starved Maxwell. |
--metrics |
Enable Prometheus endpoint for automated performance tracking. |
Performance Baseline
| Metric | Qwen3.5-4B | Qwen2.5-1.5B |
|---|---|---|
| Time/word | ~47s | ~8s |
| Completion tok/s | ~6.4 | ~18.8 |
| Prompt tok/s | ~81 | ~549 |
| Avg completion tokens | ~274 | ~132 |
| Avg prompt tokens | ~327 | ~308 |
Known Limitations (Current)
- Gender accuracy: Qwen2.5-1.5B systematically defaults to
neuterfor Romance languages. Under evaluation whether larger models fix this. - No batching: One word = one LLM call. System prompt re-processed every time.
- No retry logic: LLM parse failures are logged and skipped, not retried.
- Single POS: Only nouns tested. Verbs/adjectives/adverbs need prompt adjustments.
5. The LLM Layer
5.1 Local Model Evaluation
Models are evaluated on three criteria in order of priority: quality (definitions, examples, translations, gender accuracy), speed (completion tokens/sec), VRAM fit (must run on GTX 950M 4GB).
| Model | Size | VRAM | Speed | Quality | Status |
|---|---|---|---|---|---|
| Qwen3.5-4B Q4_K_M | 2.6GB | Tight | ~6.4 tok/s | Baseline (assumed good) | Baseline - too slow |
| Qwen2.5-1.5B Q4_K_M | 1.0GB | Easy | ~18.8 tok/s | Gender systematically wrong | Current - fast, needs validation |
| Ministral-3B Q4_K_M | 1.9GB | Fits | - | Broken (tokenizer) | Abandoned |
| Qwen2.5-3B Q4_K_M | 1.9GB | Fits | ~12-15 tok/s (est.) | Unknown | Pending download |
| Gemma 4 E2B Q4_K_M | 3.46GB | No | - | - | Too large for 4GB VRAM |
| Gemma 4 E2B IQ2_M | 2.62GB | Fits | ~8-12 tok/s (est.) | "Low quality" per Google | Not worth it |
Qwen2.5-1.5B Test Results (3 words)
| Word | Definition | Example | Gender (de/it/es/fr) | Verdict |
|---|---|---|---|---|
| time | "A period of duration..." | "The meeting was scheduled..." | neuter/neuter/neuter/neuter | All wrong |
| year | "A period of time consisting of 365 days..." | "The year 2023 is a leap year." | neuter/neuter/neuter/neuter | All wrong |
| people | "Individuals who are part of a group." | "The people gathered at the park." | neuter/feminine/feminine/feminine | Mixed |
Pattern: Defaults to neuter when uncertain. Only correct when obvious (feminine endings in Romance languages). German "Jahr" is genuinely neuter - only correct by accident.
Pending Tests
- Qwen2.5-3B: Same architecture, 2x params. If gender fixes, it was a size problem.
- 20-word torture suite: concrete, abstract, polysemous, technical, false friends. Will run on all candidate models.
5.2 Online API Options
Evaluated as fallbacks if local models fail quality or speed targets. All support OpenAI-compatible API.
| Provider | Model | Input $/1M | Output $/1M | Free Tier | Rate Limit | Est. Cost (100k words) | Est. Time |
|---|---|---|---|---|---|---|---|
| DeepSeek | V4 Flash | $0.14 | $0.28 | 5M tokens | None | $0-15 | ~1-2 days |
| Gemini | 2.5 Flash-Lite | $0.10 | $0.40 | 1,500 req/day | 1M TPM | $0 (free tier) | ~1.5 days (batched) |
| Qwen/Alibaba | Qwen-Turbo | $0.05 | $0.20 | Unknown | 600 RPM | ~$11 | ~1-2 days |
| Groq | Llama 3.1 8B Instant | $0.05 | $0.08 | Yes | High | ~$7 | ~3-4 hours |
| OpenRouter free | Various | $0 | $0 | 200 req/day | 20 RPM | $0 | ~10 days |
| OpenRouter paid | DeepSeek V4 Flash | $0.14 | $0.28 | - | Same as provider | ~$16 (+5.5% fee) | ~2-3 days |
Notes:
- Costs assume ~550 tokens/word (300 prompt + 250 completion).
- Gemini free tier: 1,500 requests/day x 50 words/batch = 75k words/day.
- Groq: 500+ tok/s inference speed. Fastest option if cost is acceptable.
- DeepSeek: 5M free tokens ~ 9,000 words. Remainder at $0.14/$0.28 per million.
5.3 Model Selection Criteria
Decision flow for 100,000 words:
Start
|
v
Run 20-word torture suite
on Qwen2.5-3B (local)
|
|-- Quality good? -----> Use Qwen2.5-3B locally
| (gender correct) ~20 days, $0
|
|-- Quality meh? -------> Test Gemini 2.5 Flash-Lite (free)
|
|-- Quality good? --> Batch 50, free tier
| ~1.5 days, $0
|
|-- Quality meh? ---> Test Groq or DeepSeek paid
|
|-- Speed priority? --> Groq
| ~$7, 3-4 hours
|
|-- Cost priority? ---> DeepSeek
~$15, 1-2 days
Quality gates:
-
=90% gender accuracy (de/it/es/fr)
- 100% JSON parse rate
- No hallucinated definitions on polysemous words
- Natural, contextually appropriate example sentences
- Sensible difficulty classification (CEFR mapping)
6. The Gender Problem & Kaikki Integration
The Problem
Grammatical gender is embedded in the translations object of each sense:
"translations": {
"de": [{"word": "Haus", "gender": "neuter"}],
"it": [{"word": "casa", "gender": "feminine"}],
"es": [{"word": "casa", "gender": "feminine"}],
"fr": [{"word": "maison", "gender": "feminine"}]
}
Early testing with Qwen2.5-1.5B showed systematic failure: the model defaults to neuter for any translation where it is uncertain. This is particularly broken for Romance languages (Italian, Spanish, French), which do not have a neuter grammatical gender at all - only masculine and feminine.
Whether this is a model size issue (1.5B too small to retain gender facts) or a training data gap (Qwen2.5 family lacks gender-annotated multilingual data) is unresolved. Pending the Qwen2.5-3B evaluation.
Two Approaches Under Consideration
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| A. LLM-only | Trust the model to generate correct gender | Zero additional complexity | Unreliable at small sizes; hallucination risk |
| B. LLM + Kaikki lookup | LLM generates word + translation string; post-processing step looks up gender from Kaikki JSONL dump | 100% deterministic; decouples gender from model quality | Adds pipeline stage; requires Kaikki data for each target language; must handle missing entries |
Kaikki Data
Kaikki provides processed Wiktionary dumps as JSONL files, one per language. Each line is a lexical entry with structured data including gender.
| Language | Kaikki File | Coverage |
|---|---|---|
| German | kaikki.org-dictionary-German.jsonl |
High |
| Italian | kaikki.org-dictionary-Italian.jsonl |
High |
| Spanish | kaikki.org-dictionary-Spanish.jsonl |
High |
| French | kaikki.org-dictionary-French.jsonl |
High |
Lookup logic: match on word (the translated string) -> extract gender field -> map to "masculine" | "feminine" | "neuter" | null.
Decision Pending
- If Qwen2.5-3B or an online model produces >=90% accurate gender: Approach A, no Kaikki needed.
- If all tested models fail gender: Approach B, implement Kaikki lookup as a post-processing step after LLM enrichment.
No implementation work started until the 20-word torture suite resolves this.
7. Batching Strategy
Why Batching is Necessary
At 1 word per call, 100,000 words = 100,000 LLM requests. Each call re-processes the ~300-token system prompt. Batching amortizes this cost.
Configurable Batch Size
Single config point controls batch size everywhere:
// config/batch.ts (planned)
export const BATCH_CONFIG = {
size: 5, // Change to 15 or 50 to test
get maxTokens() {
return Math.ceil(this.size * 250 * 1.2); // 250 tok/word + 20% buffer
},
} as const;
Prompt Structure
Single word:
Word: house
Batch of 5:
Words: ["house", "car", "tree", "water", "book"]
LLM returns a JSON object with word keys:
{
"house": [ { "sense": "...", "example": "...", ... } ],
"car": [ { ... } ],
"tree": [ { ... } ],
"water": [ { ... } ],
"book": [ { ... } ]
}
Retry Strategy
If a batch fails (bad JSON, missing key, etc.):
Batch of 50 fails
|
v
Retry as 2 batches of 25
|
v
If a 25 fails, retry as 5 batches of 5
|
v
If a 5 fails, retry as individual words (fallback)
This gives resilience without losing the speed benefit of large batches.
Expected Impact by Environment
| Environment | Batch Size | Expected Speedup | Notes |
|---|---|---|---|
| Local GTX 950M | 3 | ~1.05-1.15x | Memory bandwidth limited. KV cache pressure on 4GB VRAM. |
| Local GTX 950M | 5 | ~1.10-1.20x | Sweet spot for this hardware. |
| Local GTX 950M | 15 | Risky | May OOM. Test carefully. |
| Local GTX 950M | 50 | Unlikely | VRAM insufficient. |
| Cloud API (Gemini) | 50 | 5x fewer calls | Unlocks free tier viability. |
| Cloud API (Groq) | 50 | 5x fewer calls | Minimal gain - already fast. |
Batching is Non-Negotiable
Regardless of local vs cloud, batching is required for 100k words:
- Local: Better GPU utilization, amortized prompt processing
- Cloud: Slams into rate limits slower, unlocks free tiers, some APIs offer 50% batch discounts
8. Hardware Constraints
Current Machine
| Component | Spec |
|---|---|
| OS | Debian GNU/Linux 13 (trixie) x86_64 |
| CPU | Intel Core i7-6500U (2 physical cores, 4 threads via HT) @ 3.10 GHz |
| GPU | NVIDIA GeForce GTX 950M (Maxwell, 2015) |
| GPU VRAM | 4GB |
| RAM | 8GB (3.95GB usable at idle) |
| Disk | 102GB ext4 (~63GB used) |
What Fits in 4GB VRAM
| Model | File Size | KV Cache (2048 ctx) | Total VRAM | Fits? |
|---|---|---|---|---|
| Qwen2.5-1.5B Q4_K_M | ~1.0GB | ~0.5GB | ~1.5GB | Yes |
| Qwen2.5-3B Q4_K_M | ~1.9GB | ~0.8GB | ~2.7GB | Yes |
| Ministral-3B Q4_K_M | ~1.9GB | ~0.8GB | ~2.7GB | Yes (but broken) |
| Qwen3.5-4B Q4_K_M | 2.6GB | ~1.0GB | ~3.6GB | Tight |
| Gemma 4 E2B Q4_K_M | 3.46GB | ~1.2GB | ~4.7GB | No |
| Gemma 4 E2B IQ2_M | 2.62GB | ~1.0GB | ~3.6GB | Maybe, low quality |
GPU Rental Alternatives
If local hardware becomes the bottleneck:
| Provider | GPU | VRAM | Price/Hour | Time for 100k Words | Total Cost |
|---|---|---|---|---|---|
| Vast.ai | RTX 4090 | 24GB | ~$0.30-0.60 | ~6-8 hours | ~$2-5 |
| RunPod | RTX 4090 | 24GB | ~$0.50-0.80 | ~6-8 hours | ~$4-6 |
| Vast.ai | RTX 3090 | 24GB | ~$0.20-0.40 | ~8-10 hours | ~$2-4 |
With an RTX 4090, Qwen2.5-1.5B runs at ~100-150 tok/s. 100k words in under a day.
9. Testing & Quality Assurance
20-Word Torture Suite
Planned test set covering edge cases:
| Category | Words | Why |
|---|---|---|
| Easy concrete | house, water, book |
Baseline |
| Easy abstract | time, love, hope |
Abstract nouns harder to define |
| Polysemous | bank, run, light |
Multiple senses test disambiguation |
| Hard/technical | democracy, photosynthesis, entropy |
Complex definitions |
| False friends | actual (en/es), sensible (en/fr), fabric (en/de) |
Cross-lingual traps |
Evaluation Criteria
For each word and each candidate model:
| Criterion | Pass Threshold |
|---|---|
| Definition accuracy | Factually correct, max 15 words, student-friendly |
| Example quality | Natural sentence, word used correctly in context |
| Translation correctness | Correct word sense match |
| Gender accuracy (de/it/es/fr) | >=90% correct |
| Difficulty classification | Sensible per CEFR mapping |
| JSON reliability | 100% parse rate, valid schema |
Verification
verify-enriched-file.ts checks:
- Required top-level fields:
word,language,pos,senses - Each sense:
sense(string),example(string),difficulty_levelin {easy, medium, hard} - Each translation:
word(string),genderin {masculine, feminine, neuter, null}
10. Future Extensions & Roadmap
Near-Term (Next 2-4 Weeks)
| Item | Status | Notes |
|---|---|---|
| Configurable batching | In progress | Single BATCH_CONFIG.size value |
| 20-word torture suite | Pending | Decides gender approach and model selection |
| Qwen2.5-3B evaluation | Pending | Download and test |
| Online API testing | Pending | Gemini free tier, DeepSeek, Groq |
| Retry logic | Pending | Exponential backoff on LLM failures |
Medium-Term (1-3 Months)
| Item | Notes |
|---|---|
| Multi-POS support | Verbs, adjectives, adverbs need prompt variants |
| Multi-language source | German -> French, Italian -> Spanish, etc. |
| Parallel wordlist processing | Run english/nouns and english/verbs simultaneously |
| Incremental enrichment | Only process new/changed words in a wordlist |
| Model auto-switching | Fallback to online API if local server fails |
Long-Term (3-6 Months)
| Item | Notes |
|---|---|
| Batch API discounts | Gemini, Qwen, Azure offer 50% off for 24h SLA |
| GPU rental integration | Script to spin up Vast.ai/RunPod, run pipeline, download results |
| Quality regression tests | Run torture suite on every model change |
| Community contributions | Open-source the pipeline for other language learners |
11. Decisions Log
| Date | Decision | Context | Rationale |
|---|---|---|---|
| 2026-01-04 | TanStack Router for frontend | Previous project used React Router | Simpler, type-safe routing for the trainer app |
| 2026-01-04 | Vite dev server (no Nginx) | Docker setup for glossa-web | Nginx unnecessary for dev; Vite handles HMR and proxying |
| 2026-01-17 | Backend answer verification | Security vulnerability: correctAnswer exposed in API | Moved verification to server-side, shared schemas |
| 2026-03-26 | Multi-stage Docker builds | glossa-api and glossa-web containers | Smaller images, faster deploys |
| 2026-06-16 | llama.cpp for local LLM | Need local inference on old laptop | GGUF format, OpenAI-compatible API, no dependencies |
| 2026-06-16 | Q4_K_M quantization | Balance size vs quality | Q4_K_M is the community standard for 4-bit inference |
| 2026-06-16 | -c 2048 context |
Default was 4096 | Dictionary entries need ~500 tokens max; frees VRAM |
| 2026-06-16 | -t 2 physical cores |
Default was 4 (HT threads) | Hyperthreading hurts llama.cpp performance |
| 2026-06-17 | Qwen2.5-1.5B as current model | Qwen3.5-4B too slow (47s/word) | 6x speedup (8s/word), quality under evaluation |
| 2026-06-17 | Skip Gemma 4 | E2B Q4_K_M is 3.46GB | Does not fit in 4GB VRAM; lower quants sacrifice quality |
| 2026-06-17 | Skip Ministral-3B | Tokenizer mismatch (Tekken) | Outputs gibberish regardless of template; not fixable without re-conversion |
12. Known Issues & Dev Notes
glossa-web (Frontend)
| Issue | Details |
|---|---|
| No healthcheck | Vite dev server has no health endpoint. Docker HEALTHCHECK cannot verify running state. |
| Valkey memory overcommit | Harmless warning in dev: vm.overcommit_memory = 1 recommended before production. |
Data Pipeline
| Issue | Details | Severity |
|---|---|---|
| Ministral-3B tokenizer mismatch | Tekken tokenizer not properly converted to GGUF. Model outputs gibberish. | Blocker - abandoned |
| Qwen2.5-1.5B gender hallucination | Systematic neuter default for Romance languages. |
Under evaluation |
| No batching | 1 word = 1 call. System prompt re-processed every time. | In progress |
| No retry logic | LLM parse failures are logged and skipped. | Planned |
| Single POS tested | Only nouns validated. Verbs/adjectives need prompt changes. | Known limitation |
Hardware
| Issue | Details |
|---|---|
| GTX 950M VRAM ceiling | 4GB hard limit. Models >3GB risk OOM. |
| Maxwell GPU aging | No Flash Attention support, bandwidth-starved. |
| Laptop thermals | Cannot run 24/7 for weeks. Batch processing required. |
13. How to Run
Prerequisites
- Node.js + npm
tsxinstalled globally:npm install -g tsx- llama.cpp built from source
- GGUF model downloaded to
~/Downloads/llama.cpp/models/
Start the LLM Server
cd ~/Downloads/llama.cpp
./build/bin/llama-server \
-m models/qwen2.5-1.5b-instruct-q4_k_m.gguf \
-ngl 999 \
-c 2048 \
-b 512 \
-ub 512 \
-t 2 \
--threads-batch 2 \
--host 127.0.0.1 \
--port 8080 \
--mlock \
--prio 2
Run the Pipeline
cd /path/to/data-pipeline
npx tsx pipeline.ts
Expected Output
Starting data pipeline...
step 1: scanning the source files...
Scan complete! Found 1 wordlist(s):
- ENGLISH (nouns)
...
Pipeline Summary
Duration: 23.0s
Processed: 3
Skipped: 0
Failed: 0
Total: 3
Throughput: 0.13 words/sec
LLM Metrics
Calls: 3
Avg prompt tokens: 308
Avg completion tokens: 132
Avg prompt speed: 549.4 tok/s
Avg completion speed: 18.8 tok/s
Global data pipeline run completed successfully.
Environment Variables (Online Mode)
export DEEPSEEK_API_KEY="sk-..."
export GEMINI_API_KEY="..."
export GROQ_API_KEY="..."
Then update config/llm.ts to point to the online API URL.
14. Roadmap
Phase 1: Batching (Current)
| Task | Status | Notes |
|---|---|---|
| Implement configurable batch size | In progress | Single BATCH_CONFIG.size value. Prompt formatting: single word -> word array. Response parsing: keyed JSON object. Retry: 50 -> 25 -> 5 -> 1. |
| Verify batching doesn't break quality | Pending | Run 20-word torture suite on Qwen2.5-1.5B with batch sizes 1, 5, 15. Compare output. |
| Measure speedup vs batch size | Pending | Track throughput at 1, 5, 15 on local hardware. |
Goal: Unlock 5-15x speedup on local, unlock free online API tiers.
Phase 2: Model Selection
| Task | Status | Notes |
|---|---|---|
| Download Qwen2.5-3B Q4_K_M | Pending | ~1.9GB, fits in 4GB VRAM. |
| Run torture suite on Qwen2.5-3B (batched) | Pending | Check if gender accuracy improves with 2x params. |
| Test Gemini 2.5 Flash-Lite free tier (batched) | Pending | 1,500 req/day x 50 words = 75k words/day. Zero cost. |
| Test DeepSeek V4 Flash free tier (batched) | Pending | 5M tokens free. ~9k words. |
| Test Groq Llama 3.1 8B (batched, paid if needed) | Pending | Fastest option. ~$7 for 100k words. |
| Decide: local vs online, which model | Pending | Criteria: quality >= 90% gender, 100% JSON, sensible definitions. Then speed, then cost. |
Goal: Pick the model and provider for the 100k word run.
Phase 3: Scale
| Task | Status | Notes |
|---|---|---|
| Run 100k word pipeline | Pending | Estimated time depends on Phase 2 decision: ~10 days (local 1.5B) to ~1.5 days (Gemini batched free) to ~3-4 hours (Groq). |
| Spot-check output quality | Pending | Random sample of 100 entries. |
| Fix gender if needed | Pending | Kaikki lookup post-processing if LLM gender remains unreliable. |
| Handle failures & retries | Pending | Exponential backoff, split-and-retry for batch failures. |
Goal: Complete 100k word dataset.
Phase 4: Extend
| Task | Status | Notes |
|---|---|---|
| Multi-POS support | Pending | Verbs, adjectives, adverbs. Each needs prompt variants (conjugations, agreement, etc.). |
| Multi-language source | Pending | German -> French, Italian -> Spanish, etc. Schema already supports any source/target combo. |
| Parallel wordlist processing | Pending | Run english/nouns and english/verbs simultaneously. |
| Incremental enrichment | Pending | Only process new/changed words in a wordlist. |
| GPU rental integration | Pending | Script to spin up Vast.ai/RunPod, run pipeline, download results. |
| Quality regression tests | Pending | Run torture suite on every model change. |
Goal: Generalize pipeline for any language direction and POS.
Backlog (Unscheduled)
| Task | Context |
|---|---|
| Batch API discounts | Gemini, Qwen, Azure offer 50% off for 24h SLA. Relevant if running recurring large batches. |
| Model auto-switching | Fallback to online API if local server fails mid-run. |
| Community open-source | Clean up, document, publish for other language learners. |
| Prometheus metrics | --metrics flag on llama-server for automated performance tracking. |
-c 1024 / -b 256 experiments |
Further VRAM optimization on GTX 950M. Low priority if moving to cloud. |