Skip to content

Commit c4c2c36

Browse files
hanxiaoclaude
andauthored
add classify command, dedup --local, update embed default model (#1)
- change embed default model from jina-embeddings-v3 to jina-embeddings-v5-text-small - add classify subcommand: POST /v1/classify with --labels, stdin support - add --local flag to dedup using local embeddings + cosine similarity - extract _deduplicate_from_embeddings to share between API and local dedup - add classify and pipe integration tests - update README with classify docs, dedup --local, model reference Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1eb9d5f commit c4c2c36

5 files changed

Lines changed: 241 additions & 34 deletions

File tree

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export JINA_API_KEY=your-key-here
2828
| `jina search QUERY` | Web search (also --arxiv, --ssrn, --images, --blog) |
2929
| `jina embed TEXT` | Generate embeddings |
3030
| `jina rerank QUERY` | Rerank documents from stdin by relevance |
31+
| `jina classify TEXT` | Classify text into labels |
3132
| `jina dedup` | Deduplicate text from stdin |
3233
| `jina screenshot URL` | Capture screenshot of a URL |
3334
| `jina bibtex QUERY` | Search BibTeX citations (DBLP + Semantic Scholar) |
@@ -86,7 +87,7 @@ jina search "LLMs" --gl us --hl en # US, English
8687
jina embed "hello world"
8788
jina embed "text1" "text2" "text3"
8889
cat texts.txt | jina embed
89-
jina embed "hello" --model jina-embeddings-v3 --task retrieval.query
90+
jina embed "hello" --model jina-embeddings-v5-text-small --task retrieval.query
9091
```
9192

9293
### Rerank
@@ -96,6 +97,14 @@ cat docs.txt | jina rerank "machine learning"
9697
jina search "AI" | jina rerank "embeddings" --top-n 5
9798
```
9899

100+
### Classify
101+
102+
```bash
103+
jina classify "I love this product" --labels positive,negative,neutral
104+
echo "stock prices rose sharply" | jina classify --labels business,sports,tech
105+
cat texts.txt | jina classify --labels cat1,cat2,cat3 --json
106+
```
107+
99108
### Deduplicate
100109

101110
```bash
@@ -195,7 +204,7 @@ jina grep serve stop # stop when done
195204

196205
## Local mode
197206

198-
`jina embed` and `jina rerank` support `--local` to run on Apple Silicon via the jina-grep embedding server instead of the Jina API. No API key needed.
207+
`jina embed`, `jina rerank`, and `jina dedup` support `--local` to run on Apple Silicon via the jina-grep embedding server instead of the Jina API. No API key needed.
199208

200209
```bash
201210
# Start the local server first
@@ -207,6 +216,9 @@ cat texts.txt | jina embed --local --json
207216

208217
# Local reranking (cosine similarity on local embeddings)
209218
cat docs.txt | jina rerank --local "machine learning"
219+
220+
# Local deduplication
221+
cat items.txt | jina dedup --local
210222
```
211223

212224
Local mode uses `jina-embeddings-v5-nano` by default. Override with `--model jina-embeddings-v5-small`.

jina_cli/api.py

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ def expand_query(
354354
def embed(
355355
texts: list[str],
356356
api_key: str | None = None,
357-
model: str = "jina-embeddings-v3",
357+
model: str = "jina-embeddings-v5-text-small",
358358
task: str = "text-matching",
359359
dimensions: int | None = None,
360360
late_chunking: bool = False,
@@ -385,6 +385,37 @@ def embed(
385385
return data.get("data", [])
386386

387387

388+
# -- Classify API --
389+
390+
391+
def classify(
392+
texts: list[str],
393+
labels: list[str],
394+
api_key: str | None = None,
395+
model: str = "jina-embeddings-v5-text-small",
396+
) -> list[dict]:
397+
"""Classify texts into labels using Jina classify API."""
398+
key = require_api_key(api_key)
399+
headers = {
400+
"Content-Type": "application/json",
401+
**_auth_headers(key),
402+
}
403+
404+
body: dict = {
405+
"model": model,
406+
"input": texts,
407+
"labels": labels,
408+
}
409+
410+
with _client() as client:
411+
resp = _request_with_retry(
412+
"POST", f"{API_BASE}/v1/classify",
413+
client, headers=headers, json=body,
414+
)
415+
data = resp.json()
416+
return data.get("data", [])
417+
418+
388419
# -- Local Embeddings (via jina-grep server) --
389420

390421

@@ -493,32 +524,12 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
493524
return dot / (norm_a * norm_b)
494525

495526

496-
def deduplicate(
527+
def _deduplicate_from_embeddings(
497528
strings: list[str],
498-
api_key: str | None = None,
529+
embeddings: list[list[float]],
499530
k: int | None = None,
500531
) -> list[dict]:
501-
"""Deduplicate strings using embeddings + greedy selection.
502-
503-
Uses facility-location submodular optimization:
504-
greedily selects items that maximize coverage diversity.
505-
"""
506-
if not strings:
507-
return []
508-
if len(strings) == 1:
509-
return [{"index": 0, "text": strings[0]}]
510-
511-
key = require_api_key(api_key)
512-
513-
# Get embeddings (v5-text-small is faster and sufficient for dedup)
514-
embeddings_data = embed(
515-
strings,
516-
api_key=key,
517-
model="jina-embeddings-v5-text-small",
518-
task="text-matching",
519-
)
520-
embeddings = [item["embedding"] for item in embeddings_data]
521-
532+
"""Core dedup logic: given embeddings, run submodular selection."""
522533
n = len(embeddings)
523534

524535
# Compute similarity matrix
@@ -530,13 +541,11 @@ def deduplicate(
530541
sim[j][i] = s
531542

532543
# Lazy greedy submodular selection
544+
threshold = 1e-2
533545
if k is None:
534-
# Auto-detect: keep adding until marginal gain drops below threshold
535-
threshold = 1e-2
536546
k = n
537547

538548
selected: list[int] = []
539-
# Track max similarity of each item to any selected item (facility-location)
540549
coverage = [0.0] * n
541550

542551
for _ in range(min(k, n)):
@@ -546,7 +555,6 @@ def deduplicate(
546555
for i in range(n):
547556
if i in selected:
548557
continue
549-
# Marginal gain: how much does adding i improve coverage?
550558
gain = 0.0
551559
for j in range(n):
552560
new_cov = max(coverage[j], sim[j][i])
@@ -566,6 +574,51 @@ def deduplicate(
566574
return [{"index": i, "text": strings[i]} for i in selected]
567575

568576

577+
def deduplicate(
578+
strings: list[str],
579+
api_key: str | None = None,
580+
k: int | None = None,
581+
) -> list[dict]:
582+
"""Deduplicate strings using embeddings + greedy selection.
583+
584+
Uses facility-location submodular optimization:
585+
greedily selects items that maximize coverage diversity.
586+
"""
587+
if not strings:
588+
return []
589+
if len(strings) == 1:
590+
return [{"index": 0, "text": strings[0]}]
591+
592+
key = require_api_key(api_key)
593+
594+
embeddings_data = embed(
595+
strings,
596+
api_key=key,
597+
model="jina-embeddings-v5-text-small",
598+
task="text-matching",
599+
)
600+
embeddings = [item["embedding"] for item in embeddings_data]
601+
602+
return _deduplicate_from_embeddings(strings, embeddings, k=k)
603+
604+
605+
def local_deduplicate(
606+
strings: list[str],
607+
model: str = "jina-embeddings-v5-nano",
608+
k: int | None = None,
609+
) -> list[dict]:
610+
"""Deduplicate strings using local embeddings + greedy selection."""
611+
if not strings:
612+
return []
613+
if len(strings) == 1:
614+
return [{"index": 0, "text": strings[0]}]
615+
616+
embeddings_data = local_embed(strings, model=model, task="text-matching")
617+
embeddings = [item["embedding"] for item in embeddings_data]
618+
619+
return _deduplicate_from_embeddings(strings, embeddings, k=k)
620+
621+
569622
# -- BibTeX Search --
570623

571624

jina_cli/main.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
jina search QUERY Web search
66
jina embed TEXT Generate embeddings
77
jina rerank QUERY Rerank documents by relevance
8+
jina classify TEXT Classify text into labels
89
jina dedup Deduplicate text lines from stdin
910
jina screenshot URL Capture screenshot of a URL
1011
jina bibtex QUERY Search for BibTeX entries
@@ -111,6 +112,7 @@ def cli(ctx, api_key):
111112
" jina search QUERY Web search (also --arxiv, --ssrn, --images, --blog)\n"
112113
" jina embed TEXT Generate embeddings\n"
113114
" jina rerank QUERY Rerank documents from stdin by relevance\n"
115+
" jina classify TEXT Classify text into labels\n"
114116
" jina dedup Deduplicate text from stdin\n"
115117
" jina screenshot URL Capture screenshot of a URL\n"
116118
" jina bibtex QUERY Search BibTeX citations\n"
@@ -266,7 +268,7 @@ def search(ctx, query, arxiv, ssrn, images, blog, num, tbs, location, gl, hl, as
266268

267269
@cli.command()
268270
@click.argument("text", nargs=-1)
269-
@click.option("--model", default=None, help="Model name (default: jina-embeddings-v3, or v5-nano with --local)")
271+
@click.option("--model", default=None, help="Model name (default: jina-embeddings-v5-text-small, or v5-nano with --local)")
270272
@click.option("--task", default=None, help="Embedding task type")
271273
@click.option("--dimensions", type=int, default=None, help="Output dimensions (Matryoshka)")
272274
@click.option("--local", is_flag=True, help="Use local MLX server (requires: jina-grep serve start)")
@@ -308,7 +310,7 @@ def embed(ctx, text, model, task, dimensions, local, as_json, api_key):
308310
_task = task or "text-matching"
309311
result = api.local_embed(texts, model=_model, task=_task)
310312
else:
311-
_model = model or "jina-embeddings-v3"
313+
_model = model or "jina-embeddings-v5-text-small"
312314
_task = task or "text-matching"
313315
result = api.embed(texts, api_key=key, model=_model, task=_task, dimensions=dimensions)
314316
click.echo(utils.format_embeddings(result, as_json=as_json))
@@ -366,10 +368,11 @@ def rerank(ctx, query, top_n, model, local, as_json, api_key):
366368

367369
@cli.command()
368370
@click.option("-k", type=int, default=None, help="Number of unique items to keep (auto if not set)")
371+
@click.option("--local", is_flag=True, help="Use local MLX server (requires: jina-grep serve start)")
369372
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
370373
@click.option("--api-key", default=None, help="Jina API key")
371374
@click.pass_context
372-
def dedup(ctx, k, as_json, api_key):
375+
def dedup(ctx, k, local, as_json, api_key):
373376
"""Deduplicate text lines from stdin.
374377
375378
Uses embeddings to find semantically unique items.
@@ -378,6 +381,7 @@ def dedup(ctx, k, as_json, api_key):
378381
Examples:
379382
cat items.txt | jina dedup
380383
jina search "AI" | jina dedup -k 5
384+
cat items.txt | jina dedup --local
381385
"""
382386
key = api_key or ctx.obj.get("api_key")
383387
lines = utils.read_stdin_lines()
@@ -390,12 +394,71 @@ def dedup(ctx, k, as_json, api_key):
390394
sys.exit(EXIT_USER_ERROR)
391395

392396
try:
393-
result = api.deduplicate(lines, api_key=key, k=k)
397+
if local:
398+
result = api.local_deduplicate(lines, k=k)
399+
else:
400+
result = api.deduplicate(lines, api_key=key, k=k)
394401
click.echo(utils.format_dedup_results(result, as_json=as_json))
395402
except Exception as e:
396403
utils.handle_http_error(e)
397404

398405

406+
# -- classify --
407+
408+
409+
@cli.command()
410+
@click.argument("text", nargs=-1)
411+
@click.option("--labels", required=True, multiple=True,
412+
help="Labels for classification (comma-separated or repeated --labels)")
413+
@click.option("--model", default=None, help="Model name (default: jina-embeddings-v5-text-small)")
414+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
415+
@click.option("--api-key", default=None, help="Jina API key")
416+
@click.pass_context
417+
def classify(ctx, text, labels, model, as_json, api_key):
418+
"""Classify text into labels.
419+
420+
Input from arguments or stdin (one text per line).
421+
422+
\b
423+
Examples:
424+
jina classify "this is great" --labels positive,negative
425+
echo "stock price rose" | jina classify --labels business,sports,tech
426+
jina classify "text1" "text2" --labels cat1 --labels cat2 --labels cat3
427+
"""
428+
key = api_key or ctx.obj.get("api_key")
429+
430+
texts = list(text)
431+
if not texts:
432+
stdin_lines = utils.read_stdin_lines()
433+
texts = stdin_lines
434+
435+
if not texts:
436+
_short_usage(
437+
"Usage: jina classify TEXT --labels label1,label2",
438+
["jina classify \"this is great\" --labels positive,negative",
439+
"echo \"text\" | jina classify --labels label1,label2",
440+
"cat texts.txt | jina classify --labels a,b,c --json"],
441+
)
442+
443+
# Parse labels: support both --labels a,b,c and --labels a --labels b
444+
parsed_labels = []
445+
for lbl in labels:
446+
parsed_labels.extend(l.strip() for l in lbl.split(",") if l.strip())
447+
448+
if not parsed_labels:
449+
click.echo("Error: at least one label required.\n"
450+
"Fix: --labels positive,negative", err=True)
451+
sys.exit(EXIT_USER_ERROR)
452+
453+
_model = model or "jina-embeddings-v5-text-small"
454+
455+
try:
456+
result = api.classify(texts, parsed_labels, api_key=key, model=_model)
457+
click.echo(utils.format_classify_results(result, as_json=as_json))
458+
except Exception as e:
459+
utils.handle_http_error(e)
460+
461+
399462
# -- screenshot --
400463

401464

jina_cli/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,32 @@ def format_embeddings(data: list[dict], as_json: bool = False) -> str:
9191
return "\n".join(lines)
9292

9393

94+
def format_classify_results(results: list[dict], as_json: bool = False) -> str:
95+
"""Format classification results for display."""
96+
if as_json:
97+
return json.dumps(results, indent=2, ensure_ascii=False)
98+
99+
lines = []
100+
for item in results:
101+
predictions = item.get("prediction", item.get("predictions", []))
102+
if isinstance(predictions, str):
103+
# Single prediction
104+
score = item.get("score", item.get("confidence", 0))
105+
lines.append(f"{predictions} ({score:.4f})")
106+
elif isinstance(predictions, list) and predictions:
107+
# List of predictions - take top one
108+
top = predictions[0]
109+
if isinstance(top, dict):
110+
label = top.get("label", "")
111+
score = top.get("score", top.get("confidence", 0))
112+
lines.append(f"{label} ({score:.4f})")
113+
else:
114+
lines.append(str(top))
115+
else:
116+
lines.append(str(item))
117+
return "\n".join(lines)
118+
119+
94120
def format_dedup_results(results: list[dict], as_json: bool = False) -> str:
95121
"""Format deduplication results for display."""
96122
if as_json:

0 commit comments

Comments
 (0)