feat: add Perplexity contextualized embeddings API as a new model provider (#13709)

### What problem does this PR solve?

Adds Perplexity contextualized embeddings API as a new model provider,
as requested in #13610.

- `PerplexityEmbed` provider in `rag/llm/embedding_model.py` supporting
both standard (`/v1/embeddings`) and contextualized
(`/v1/contextualizedembeddings`) endpoints
- All 4 Perplexity embedding models registered in
`conf/llm_factories.json`: `pplx-embed-v1-0.6b`, `pplx-embed-v1-4b`,
`pplx-embed-context-v1-0.6b`, `pplx-embed-context-v1-4b`
- Frontend entries (enum, icon mapping, API key URL) in
`web/src/constants/llm.ts`
- Updated `docs/guides/models/supported_models.mdx`
- 22 unit tests in `test/unit_test/rag/llm/test_perplexity_embed.py`

Perplexity's API returns `base64_int8` encoded embeddings (not
OpenAI-compatible), so this uses a custom `requests`-based
implementation. Contextualized vs standard model is auto-detected from
the model name.

Closes #13610

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
- [x] Documentation Update
This commit is contained in:
tmimmanuel
2026-03-20 02:47:48 +00:00
committed by GitHub
parent 456b1bbf66
commit 13d0df1562
7 changed files with 441 additions and 16 deletions

View File

@@ -113,7 +113,7 @@ class OpenAIEmbed(Base):
return np.array(ress), total_tokens
def encode_queries(self, text):
res = self.client.embeddings.create(input=[truncate(text, 8191)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True})
res = self.client.embeddings.create(input=[truncate(text, 8191)], model=self.model_name, encoding_format="float", extra_body={"drop_params": True})
try:
return np.array(res.data[0].embedding), total_token_count_from_response(res)
except Exception as _e:
@@ -358,7 +358,7 @@ class JinaMultiVecEmbed(Base):
self.headers = {"Content-Type": "application/json", "Authorization": f"Bearer {key}"}
self.model_name = model_name
def encode(self, texts: list[str|bytes], task="retrieval.passage"):
def encode(self, texts: list[str | bytes], task="retrieval.passage"):
batch_size = 16
ress = []
token_count = 0
@@ -370,9 +370,9 @@ class JinaMultiVecEmbed(Base):
img_b64s = None
try:
base64.b64decode(text, validate=True)
img_b64s = text.decode('utf8')
img_b64s = text.decode("utf8")
except Exception:
img_b64s = base64.b64encode(text).decode('utf8')
img_b64s = base64.b64encode(text).decode("utf8")
input.append({"image": img_b64s}) # base64 encoded image
for i in range(0, len(texts), batch_size):
data = {"model": self.model_name, "input": input[i : i + batch_size]}
@@ -380,20 +380,20 @@ class JinaMultiVecEmbed(Base):
data["return_multivector"] = True
if "v3" in self.model_name or "v4" in self.model_name:
data['task'] = task
data['truncate'] = True
data["task"] = task
data["truncate"] = True
response = requests.post(self.base_url, headers=self.headers, json=data)
try:
res = response.json()
for d in res['data']:
if data.get("return_multivector", False): # v4
token_embs = np.asarray(d['embeddings'], dtype=np.float32)
for d in res["data"]:
if data.get("return_multivector", False): # v4
token_embs = np.asarray(d["embeddings"], dtype=np.float32)
chunk_emb = token_embs.mean(axis=0)
else:
# v2/v3
chunk_emb = np.asarray(d['embedding'], dtype=np.float32)
chunk_emb = np.asarray(d["embedding"], dtype=np.float32)
ress.append(chunk_emb)
@@ -444,6 +444,7 @@ class MistralEmbed(Base):
def encode_queries(self, text):
import time
import random
retry_max = 5
while retry_max > 0:
try:
@@ -462,6 +463,7 @@ class BedrockEmbed(Base):
def __init__(self, key, model_name, **kwargs):
import boto3
# `key` protocol (backend stores as JSON string in `api_key`):
# - Must decode into a dict.
# - Required: `auth_mode`, `bedrock_region`.
@@ -497,10 +499,9 @@ class BedrockEmbed(Base):
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
else: # assume_role
else: # assume_role
self.client = boto3.client("bedrock-runtime", region_name=self.bedrock_region)
def encode(self, texts: list):
texts = [truncate(t, 8196) for t in texts]
embeddings = []
@@ -1038,6 +1039,7 @@ class GiteeEmbed(SILICONFLOWEmbed):
base_url = "https://ai.gitee.com/v1/embeddings"
super().__init__(key, model_name, base_url)
class DeepInfraEmbed(OpenAIEmbed):
_FACTORY_NAME = "DeepInfra"
@@ -1064,6 +1066,7 @@ class CometAPIEmbed(OpenAIEmbed):
base_url = "https://api.cometapi.com/v1"
super().__init__(key, model_name, base_url)
class DeerAPIEmbed(OpenAIEmbed):
_FACTORY_NAME = "DeerAPI"
@@ -1081,16 +1084,90 @@ class JiekouAIEmbed(OpenAIEmbed):
base_url = "https://api.jiekou.ai/openai/v1/embeddings"
super().__init__(key, model_name, base_url)
class RAGconEmbed(OpenAIEmbed):
"""
RAGcon Embedding Provider - routes through LiteLLM proxy
Default Base URL: https://connect.ragcon.ai/v1
"""
_FACTORY_NAME = "RAGcon"
def __init__(self, key, model_name="text-embedding-3-small", base_url=None):
if not base_url:
base_url = "https://connect.ragcon.com/v1"
super().__init__(key, model_name, base_url)
super().__init__(key, model_name, base_url)
class PerplexityEmbed(Base):
_FACTORY_NAME = "Perplexity"
def __init__(self, key, model_name="pplx-embed-v1-0.6b", base_url="https://api.perplexity.ai"):
if not base_url:
base_url = "https://api.perplexity.ai"
self.base_url = base_url.rstrip("/")
self.api_key = key
self.model_name = model_name
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
@staticmethod
def _decode_base64_int8(b64_str):
raw = base64.b64decode(b64_str)
return np.frombuffer(raw, dtype=np.int8).astype(np.float32)
def _is_contextualized(self):
return "context" in self.model_name
def encode(self, texts: list):
batch_size = 512
ress = []
token_count = 0
if self._is_contextualized():
url = f"{self.base_url}/v1/contextualizedembeddings"
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
payload = {
"model": self.model_name,
"input": [[chunk] for chunk in batch],
"encoding_format": "base64_int8",
}
response = requests.post(url, headers=self.headers, json=payload)
try:
res = response.json()
for doc in res["data"]:
for chunk_emb in doc["data"]:
ress.append(self._decode_base64_int8(chunk_emb["embedding"]))
token_count += res.get("usage", {}).get("total_tokens", 0)
except Exception as _e:
log_exception(_e, response)
raise Exception(f"Error: {response.text}")
else:
url = f"{self.base_url}/v1/embeddings"
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
payload = {
"model": self.model_name,
"input": batch,
"encoding_format": "base64_int8",
}
response = requests.post(url, headers=self.headers, json=payload)
try:
res = response.json()
for d in res["data"]:
ress.append(self._decode_base64_int8(d["embedding"]))
token_count += res.get("usage", {}).get("total_tokens", 0)
except Exception as _e:
log_exception(_e, response)
raise Exception(f"Error: {response.text}")
return np.array(ress), token_count
def encode_queries(self, text):
embds, cnt = self.encode([text])
return np.array(embds[0]), cnt