Private, on-device search routing.
divid3 is a meta-search router that runs entirely in your browser. It uses a small local ML model to read your intent, then sends you to whichever search engine you've decided handles that kind of query best — without a server, an account, or a middleman.
Try the hosted instance at divid3.com
- Private by design. Classification happens in your browser's WASM heap. No telemetry, no server logs, no query stream for someone else to monetize.
- You decide where queries go. All routing is configured by a single JSON file you control. Self-host with only the destinations you trust; remove anything you don't.
- Semantic intent. A 22 MB
all-MiniLM-L6-v2model embeds your query and picks the best-matching destination from your configured set. - Bangs. DuckDuckGo-style shortcuts (
!yt,!hn,!m, …) that always win over the semantic router. - Rule-based fallbacks. Bare domains (
github.com) andlocalhost:3000route directly. Low-memory phones get a deterministic keyword router instead of the model. - Choose-don't-autoroute on mobile +
?q=URLs. No 4-second countdown. On mobile and on any link with a query string (e.g. browser-bar searches), the router shows you the top match plus the ranked alternatives and waits for you to tap. Desktop typing still routes immediately because the live score chips already let you click any destination. - No build step. Pure HTML / CSS / vanilla JS. Easy to audit. Easy to self-host.
divid3 is built for people who want a search bar they actually trust — even when the destination itself isn't end-to-end private. The threat model is:
- Where the query is read. On your device, by a model whose weights are committed to the repo. You can read the script that builds the embeddings (
scripts/generate_search_embeddings.py) — there is no remote inference. - What gets sent over the network. Only the final HTTPS request to whatever engine you tapped. divid3 itself doesn't see any of it; there's no
/api/routeto intercept. - What you control. The full set of destinations, their URL templates, their bang shortcuts, and the keyword/embedding rules that pick between them. Delete an engine from
scripts/search_phrases.jsonand it's gone everywhere, including from the on-device classifier.
You're welcome to ship a fork that points only at Kagi, Brave, SearXNG, your own engine, or anything else. Same code, your engines.
- Bangs. Regex match for
!yt,!hn, etc. Always wins. - Explicit rules. Bare-domain (
github.com) andlocalhost:portdetection. - Semantic match. Transformers.js embeds the query and scores each destination by the mean of its top-3 cosine similarities against that destination's phrase corpus. Top-3 pooling (instead of plain nearest-neighbor) means a single stray phrase can't hijack a route — three examples have to agree.
- Keyword fallback. When the model isn't usable (low-memory device, repeated crashes,
?lite=1), a deterministic weighted-keyword scorer takes over. - DDG as the universal fallback. In keyword mode, queries with no matching keywords fall back to DuckDuckGo (HTML) by default.
Routing quality is measured by scripts/eval_routing.py against a held-out benchmark of 353 labeled real-world queries (scripts/routing_benchmark.json — the validator enforces that no benchmark query is ever copied into the training phrases):
| Router | Accuracy |
|---|---|
| Semantic (top-3 cosine) | 98.9% |
Keyword (?lite=1) |
97.5% |
Run it yourself after any corpus edit:
python3 scripts/eval_routing.py # full report + misroute analysis
python3 scripts/eval_routing.py --query "best ramen" # probe a single queryThe misroute report names the exact phrase that "won" each wrong routing, which makes corpus debugging mechanical: find the hijacking phrase, sharpen or remove it, re-run.
The shipped configuration routes between:
| Engine | Used for | Bang(s) |
|---|---|---|
| DuckDuckGo | Generic web search; fallback for anything ambiguous | !d, !ddg |
| Bing Images | Image queries | !i, !img |
| Grok | Conversational / agentic AI answers, research, social search | !g, !gr, !p, !px |
| Google Maps | Locations, "near me", directions | !m, !map |
| YouTube | Music, video, tutorials | !y, !yt |
| Wirecutter | Buying advice, product recommendations | !wc, !nyt |
| eBay | Used / vintage / parts / hard-to-find items | !eb, !ebay |
| Hacker News | Tech news, startup discussion | !h, !hn |
Plus the direct virtual engine, which opens a typed URL (github.com) literally instead of searching for it.
Add as a custom search engine:
https://divid3.com/?q=%s
Setup instructions for Chrome, Firefox, Safari, and Arc live at divid3.com/setup.html.
Keyboard shortcuts: / focus search · ↑/↓ change destination (while typing, or on the routing overlay) · Enter route to selection · Esc close overlay / revert selection · T toggle theme · ? help.
# install deps (Playwright + serve + ESLint)
npm ci
# local dev server
npm run serve
# end-to-end tests (Chromium / Firefox / WebKit / mobile Safari)
npm test
# lint everything (ESLint + Ruff + JSON config validator)
npm run lintEverything is driven by one file: scripts/search_phrases.json. That file is the source of truth for engines, bangs, keyword fallback rules, and the training phrases that build the semantic index. The Python script reads it and emits two runtime artifacts:
search-config.json— small lookup tables the page loads at bootsearch-embeddings.json— L2-normalized phrase vectors for the ML router
Notes:
- Every engine must define
nameandurlTemplate. No other fields are accepted — the validator rejects strays (includingcolor, which the UI no longer uses). - The
directengine is special: itsurlTemplateis"{q}"and it opens a literal URL the user typed. - Every engine used by a bang, keyword rule, or
_routesentry must be declared inengines. The validator and the generator both enforce this. ddgmust exist; it's the universal fallback when nothing else is confident.
- No single-word phrases outside the
ddgfallback route. Under nearest-neighbor pooling a bare word (buy,music,guide) becomes a universal attractor that hijacks unrelated queries — removing them was worth ~10 points of measured accuracy. - No phrase may appear in two routes — that's a guaranteed conflict.
- Every phrase must be unambiguous about its destination on its own. If it could plausibly belong to two engines, sharpen it or drop it.
- Never copy a query from
scripts/routing_benchmark.jsoninto the corpus; the benchmark must stay held-out (also enforced).
# 1. Edit scripts/search_phrases.json — add engines, tweak bangs, drop phrases.
$EDITOR scripts/search_phrases.json
# 2. Measure the change against the routing benchmark.
python3 scripts/eval_routing.py
# 3. Regenerate the runtime artifacts.
python3 scripts/generate_search_embeddings.py
# 4. Bump EMBEDDINGS_VERSION in index.html so caches invalidate atomically.
# (search the file for EMBEDDINGS_VERSION = '...')
# 5. Sanity-check the change locally.
npm run lint # validates schema + corpus rules + drift with search-config.json
npm run serve
npm testThat's it — no JS edits required.
Three checks run on every PR via .github/workflows/lint.yml:
- ESLint over the
<script>blocks inindex.htmlandsetup.html(viaeslint-plugin-html). - Ruff over the
scripts/directory. - Config validator (
scripts/validate_config.py) that catches missing engines, broken bang references, urlTemplates without{q}, and drift betweensearch_phrases.jsonand the generatedsearch-config.json.
The Playwright E2E suite (.github/workflows/search-tests.yml) runs the full router on Chromium, Firefox, WebKit, and mobile Safari.
Code: MIT · Bundled model weights: Apache 2.0 (see models/).
{ "engines": { "my_engine": { "name": "My Custom Search", "urlTemplate": "https://example.com/?q={q}" // {q} is the URL-encoded query } }, "bangs": { "my": "my_engine" // !my → my_engine }, "keywordRules": [ { "engine": "my_engine", "weight": 5, "kw": ["custom topic", "thing i want here"] } ], "_routes": [ { "key": "my_engine", "label": "Display label for the index", "phrases": [ "search for my custom topic", "lookup something on my search engine" ] } ] }