* feat: add JinaMLXCrossEncoder for native Apple Silicon reranking Adds a new `jina-mlx` reranker provider backed by jinaai/jina-reranker-v3-mlx, a 0.6B multilingual listwise reranker running via the MLX framework on Apple Silicon. The model is downloaded automatically from HuggingFace Hub on first use. Benchmarked latencies (Apple Silicon): 1 doc→32ms, 5→45ms, 10→60ms, 20→94ms. Sub-linear scaling because all docs are ranked in a single forward pass. - Embeds the MLX reranker implementation (_MLXReranker / _MLPProjector) directly in cross_encoder.py with no transformers/PyTorch dependency - Adds `mlx`, `mlx-lm`, `safetensors` to pyproject.toml optional deps (uv add) - Updates configuration.md with provider docs and benchmark table * refactor: import MLXReranker from repo rerank.py instead of duplicating code Use importlib to load MLXReranker directly from the model repo's own rerank.py (downloaded via snapshot_download). Also pin exact minimum versions for mlx>=0.31.0, mlx-lm>=0.31.1, safetensors>=0.6.2 (verified against installed versions). * refactor: move MLX reranker impl to dedicated jina_mlx_reranker.py Replaces the importlib hack with a proper module. jina_mlx_reranker.py is adapted from jinaai/jina-reranker-v3-mlx/rerank.py (CC BY-NC 4.0) with the source clearly documented at the top of the file. * docs: simplify jina-mlx reranker docs * fix: disable GIN fastupdate on source_memory_ids index to prevent deadlocks GIN fastupdate buffers inserts in a pending list and flushes it with AccessExclusiveLock when full. Under concurrent test load (8 xdist workers all running retain_async), two workers can trigger a flush simultaneously and deadlock. Recreating the index with fastupdate=off eliminates the flush/lock cycle at the cost of slightly slower individual inserts. * fix: drop per-bank HNSW indexes after transaction to avoid AccessExclusiveLock deadlock When deleting a bank, the previous code dropped HNSW indexes inside the same transaction as the DELETE FROM memory_units. Since DROP INDEX needs AccessExclusiveLock on the parent table and DELETE holds RowExclusiveLock, two concurrent bank deletions deadlocked on the same table lock. Fix: capture internal_id inside the transaction, commit, then drop the indexes outside the transaction so no row-level locks are held.
144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
"""
|
|
MLX implementation of jina-reranker-v3 for Apple Silicon.
|
|
|
|
This file is adapted from the official model repository:
|
|
https://huggingface.co/jinaai/jina-reranker-v3-mlx/blob/main/rerank.py
|
|
|
|
License: CC BY-NC 4.0 (contact Jina AI for commercial usage)
|
|
|
|
Changes from upstream:
|
|
- Removed the __main__ example block
|
|
- Type annotations added to public methods
|
|
- top_n parameter added to rerank() (upstream only exposed it implicitly)
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
|
|
class _MLPProjector:
|
|
def __init__(self):
|
|
import mlx.nn as nn
|
|
|
|
self.linear1 = nn.Linear(1024, 512, bias=False)
|
|
self.linear2 = nn.Linear(512, 512, bias=False)
|
|
|
|
def __call__(self, x):
|
|
import mlx.nn as nn
|
|
|
|
x = self.linear1(x)
|
|
x = nn.relu(x)
|
|
x = self.linear2(x)
|
|
return x
|
|
|
|
|
|
def _load_projector(projector_path: str) -> _MLPProjector:
|
|
import mlx.core as mx
|
|
from safetensors import safe_open
|
|
|
|
projector = _MLPProjector()
|
|
with safe_open(projector_path, framework="numpy") as f:
|
|
projector.linear1.weight = mx.array(f.get_tensor("linear1.weight"))
|
|
projector.linear2.weight = mx.array(f.get_tensor("linear2.weight"))
|
|
return projector
|
|
|
|
|
|
def _sanitize(text: str, special_tokens: dict[str, str]) -> str:
|
|
for token in special_tokens.values():
|
|
text = text.replace(token, "")
|
|
return text
|
|
|
|
|
|
def _format_prompt(query: str, docs: list[str], special_tokens: dict[str, str]) -> str:
|
|
query = _sanitize(query, special_tokens)
|
|
docs = [_sanitize(d, special_tokens) for d in docs]
|
|
|
|
doc_token = special_tokens["doc_embed_token"]
|
|
query_token = special_tokens["query_embed_token"]
|
|
|
|
prefix = (
|
|
"<|im_start|>system\n"
|
|
"You are a search relevance expert who can determine a ranking of the passages based on how relevant they are to the query. "
|
|
"If the query is a question, how relevant a passage is depends on how well it answers the question. "
|
|
"If not, try to analyze the intent of the query and assess how well each passage satisfies the intent. "
|
|
"If an instruction is provided, you should follow the instruction when determining the ranking."
|
|
"<|im_end|>\n<|im_start|>user\n"
|
|
)
|
|
suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
|
|
|
|
body = (
|
|
f"I will provide you with {len(docs)} passages, each indicated by a numerical identifier. "
|
|
f"Rank the passages based on their relevance to query: {query}\n"
|
|
)
|
|
body += "\n".join(f'<passage id="{i}">\n{doc}{doc_token}\n</passage>' for i, doc in enumerate(docs))
|
|
body += f"\n<query>\n{query}{query_token}\n</query>"
|
|
return prefix + body + suffix
|
|
|
|
|
|
class MLXReranker:
|
|
"""
|
|
MLX-accelerated jina-reranker-v3 for Apple Silicon.
|
|
|
|
Loads the model from a local directory (use huggingface_hub.snapshot_download
|
|
to fetch jinaai/jina-reranker-v3-mlx if you don't have it already).
|
|
"""
|
|
|
|
_SPECIAL_TOKENS = {
|
|
"query_embed_token": "<|rerank_token|>",
|
|
"doc_embed_token": "<|embed_token|>",
|
|
}
|
|
_DOC_TOKEN_ID = 151670
|
|
_QUERY_TOKEN_ID = 151671
|
|
|
|
def __init__(self, model_path: str, projector_path: str):
|
|
from mlx_lm import load
|
|
|
|
self.model, self.tokenizer = load(model_path)
|
|
self.model.eval()
|
|
self.projector = _load_projector(projector_path)
|
|
|
|
def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[dict]:
|
|
"""
|
|
Rank documents by relevance to a query.
|
|
|
|
Returns a list of dicts with keys: document, relevance_score, index.
|
|
Sorted by descending relevance_score.
|
|
"""
|
|
import mlx.core as mx
|
|
|
|
prompt = _format_prompt(query, documents, self._SPECIAL_TOKENS)
|
|
input_ids = self.tokenizer.encode(prompt)
|
|
hidden_states = self.model.model([input_ids])[0] # [seq_len, hidden_size]
|
|
|
|
input_ids_np = np.array(input_ids)
|
|
query_positions = np.where(input_ids_np == self._QUERY_TOKEN_ID)[0]
|
|
doc_positions = np.where(input_ids_np == self._DOC_TOKEN_ID)[0]
|
|
|
|
if len(query_positions) == 0:
|
|
raise ValueError("Query embed token not found in prompt")
|
|
if len(doc_positions) == 0:
|
|
raise ValueError("Document embed tokens not found in prompt")
|
|
|
|
query_hidden = mx.expand_dims(hidden_states[int(query_positions[0])], axis=0)
|
|
doc_hidden = mx.stack([hidden_states[int(p)] for p in doc_positions])
|
|
|
|
query_emb = self.projector(query_hidden) # [1, 512]
|
|
doc_emb = self.projector(doc_hidden) # [num_docs, 512]
|
|
|
|
query_exp = mx.broadcast_to(mx.expand_dims(query_emb, 0), (1, len(documents), 512))
|
|
doc_exp = mx.expand_dims(doc_emb, 0)
|
|
|
|
scores = mx.sum(doc_exp * query_exp, axis=-1) / (
|
|
mx.sqrt(mx.sum(doc_exp * doc_exp, axis=-1)) * mx.sqrt(mx.sum(query_exp * query_exp, axis=-1))
|
|
) # [1, num_docs]
|
|
scores_np = np.array(scores[0])
|
|
|
|
order = np.argsort(scores_np)[::-1]
|
|
n = min(top_n, len(documents)) if top_n is not None else len(documents)
|
|
return [
|
|
{
|
|
"document": documents[order[i]],
|
|
"relevance_score": float(scores_np[order[i]]),
|
|
"index": int(order[i]),
|
|
}
|
|
for i in range(n)
|
|
]
|