Spaces:
Running
Running
Deploy vectorization service from GitHub be8a53f8cee433cb3de44a139be2fab8e47b1ccb
Browse files- README.md +49 -0
- VERSION +1 -1
- app/batching.py +10 -4
- app/main.py +2 -0
- app/models.py +13 -0
- app/openai_api.py +111 -0
README.md
CHANGED
|
@@ -42,6 +42,55 @@ curl -sS -X POST 'http://localhost:7860/colbert/encode' \
|
|
| 42 |
|
| 43 |
E5 keeps `query: ` / `passage: ` prefixes, attention-mask-aware mean pooling, **no extra L2 normalization**. Qwen uses its official query instruction + last-token pooling + L2 normalization; documents have no instruction. PyLate mLateOn handles query and passage separately, retaining token vectors and their model-native normalization. Token limits are checked *including model markers and special tokens*, without silent truncation. Exceeding limits or using invalid input returns HTTP 422. When the shared inference gate is occupied, requests return HTTP 429 with `Retry-After`.
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
## Official Voyage AI SDK compatibility
|
| 46 |
|
| 47 |
The **unmodified Voyage Python client** can talk directly to our service.
|
|
|
|
| 42 |
|
| 43 |
E5 keeps `query: ` / `passage: ` prefixes, attention-mask-aware mean pooling, **no extra L2 normalization**. Qwen uses its official query instruction + last-token pooling + L2 normalization; documents have no instruction. PyLate mLateOn handles query and passage separately, retaining token vectors and their model-native normalization. Token limits are checked *including model markers and special tokens*, without silent truncation. Exceeding limits or using invalid input returns HTTP 422. When the shared inference gate is occupied, requests return HTTP 429 with `Retry-After`.
|
| 44 |
|
| 45 |
+
## OpenAI-compatible API (separate from Voyage)
|
| 46 |
+
|
| 47 |
+
**OpenAI SDK-compatible base URL:** \`https://YOUR-SPACE.hf.space/openai/v1\`.
|
| 48 |
+
This separate namespace is necessary because the existing Voyage-compatible
|
| 49 |
+
\`/v1/embeddings\` endpoint is already in production and must retain its default
|
| 50 |
+
\`input_type\` handling. Point OpenAI-compatible custom-provider integrations
|
| 51 |
+
to the base URL ending in \`/openai/v1\`, not to the Voyage \`/v1\` URL.
|
| 52 |
+
|
| 53 |
+
- \`POST /openai/v1/embeddings\`: accepts \`model\`, \`input\` (string or string array),
|
| 54 |
+
optional \`dimensions\`, \`encoding_format\` (\`float\` or \`base64\`) and \`user\`.
|
| 55 |
+
Responds in the OpenAI \`data[].embedding\` / \`usage.prompt_tokens\` /
|
| 56 |
+
\`usage.total_tokens\` format. Only fixed native dimensions are supported.
|
| 57 |
+
- \`GET /openai/v1/models\`: dense model aliases for provider selection.
|
| 58 |
+
- Bearer authentication uses the **same** \`API_KEY\` as Voyage; no new secrets.
|
| 59 |
+
- Dense E5 and Qwen3 only. ColBERT retains \`/colbert/encode\` and Voyage extension.
|
| 60 |
+
- **Crucial:** this endpoint forwards raw input to the tokenizer **without adding any
|
| 61 |
+
query/document instructions**. Supply prefixes in the client configuration.
|
| 62 |
+
It never silently truncates, and unsupported dimensions return an error.
|
| 63 |
+
|
| 64 |
+
For E5 configure:
|
| 65 |
+
- Document prefix: \`passage: \`
|
| 66 |
+
- Query prefix: \`query: \`
|
| 67 |
+
|
| 68 |
+
For Qwen3:
|
| 69 |
+
- Document prefix: *empty*
|
| 70 |
+
- Query prefix: \`Instruct: Given a question, retrieve relevant passages that answer the question\nQuery:\`
|
| 71 |
+
|
| 72 |
+
The Qwen query prefix contains an actual newline before \`Query:\`; ensure the
|
| 73 |
+
client inserts a newline character rather than the two characters backslash+n.
|
| 74 |
+
Use \`Query:\` immediately before the question, matching the model's original
|
| 75 |
+
instruction template.
|
| 76 |
+
|
| 77 |
+
Example with the standard Python OpenAI SDK:
|
| 78 |
+
|
| 79 |
+
\`\`\`python
|
| 80 |
+
from openai import OpenAI
|
| 81 |
+
|
| 82 |
+
client = OpenAI(
|
| 83 |
+
base_url="https://YOUR-SPACE.hf.space/openai/v1",
|
| 84 |
+
api_key="YOUR_API_KEY",
|
| 85 |
+
)
|
| 86 |
+
# The calling application has to apply the configured prefix.
|
| 87 |
+
embedding = client.embeddings.create(
|
| 88 |
+
model="e5",
|
| 89 |
+
input=["passage: Первое предложение", "passage: Второе предложение"],
|
| 90 |
+
)
|
| 91 |
+
print(len(embedding.data[0].embedding)) # 384
|
| 92 |
+
\`\`\`
|
| 93 |
+
|
| 94 |
## Official Voyage AI SDK compatibility
|
| 95 |
|
| 96 |
The **unmodified Voyage Python client** can talk directly to our service.
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
be8a53f8cee433cb3de44a139be2fab8e47b1ccb
|
app/batching.py
CHANGED
|
@@ -33,8 +33,12 @@ def _token_count(tokenizer, text):
|
|
| 33 |
return len(encoded["input_ids"])
|
| 34 |
|
| 35 |
|
| 36 |
-
def _dense_batch(engine, texts, kind, is_qwen):
|
| 37 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
strings = [
|
| 39 |
f"Instruct: {INSTRUCTION}\nQuery:{text}" if kind == "query" else text
|
| 40 |
for text in texts
|
|
@@ -122,7 +126,7 @@ def _colbert_batch(engine, texts, kind):
|
|
| 122 |
return results
|
| 123 |
|
| 124 |
|
| 125 |
-
def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False):
|
| 126 |
"""Native tensor batch for real models, single-item fallback for test doubles."""
|
| 127 |
if is_colbert:
|
| 128 |
if not hasattr(engine, "model") or not hasattr(engine.model, "tokenizer"):
|
|
@@ -130,4 +134,6 @@ def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False):
|
|
| 130 |
return _colbert_batch(engine, texts, kind)
|
| 131 |
if not hasattr(engine, "tokenizer") or not hasattr(engine, "model"):
|
| 132 |
return [engine.encode(text, kind) for text in texts]
|
| 133 |
-
|
|
|
|
|
|
|
|
|
| 33 |
return len(encoded["input_ids"])
|
| 34 |
|
| 35 |
|
| 36 |
+
def _dense_batch(engine, texts, kind, is_qwen, raw=False):
|
| 37 |
+
if raw:
|
| 38 |
+
# OpenAI-compatible callers provide preformatted text; never add prompts.
|
| 39 |
+
strings = list(texts)
|
| 40 |
+
budget, max_size = (4096, 16) if is_qwen else (8192, 64)
|
| 41 |
+
elif is_qwen:
|
| 42 |
strings = [
|
| 43 |
f"Instruct: {INSTRUCTION}\nQuery:{text}" if kind == "query" else text
|
| 44 |
for text in texts
|
|
|
|
| 126 |
return results
|
| 127 |
|
| 128 |
|
| 129 |
+
def batch_encode(engine, texts, kind, is_qwen=False, is_colbert=False, raw=False):
|
| 130 |
"""Native tensor batch for real models, single-item fallback for test doubles."""
|
| 131 |
if is_colbert:
|
| 132 |
if not hasattr(engine, "model") or not hasattr(engine.model, "tokenizer"):
|
|
|
|
| 134 |
return _colbert_batch(engine, texts, kind)
|
| 135 |
if not hasattr(engine, "tokenizer") or not hasattr(engine, "model"):
|
| 136 |
return [engine.encode(text, kind) for text in texts]
|
| 137 |
+
if raw and (not hasattr(engine, "tokenizer") or not hasattr(engine, "model")):
|
| 138 |
+
raise RuntimeError("Raw embeddings require the loaded model and tokenizer")
|
| 139 |
+
return _dense_batch(engine, texts, kind, is_qwen, raw=raw)
|
app/main.py
CHANGED
|
@@ -41,6 +41,7 @@ async def lifespan(application: FastAPI):
|
|
| 41 |
|
| 42 |
|
| 43 |
from app.voyage_api import router as voyage_router
|
|
|
|
| 44 |
|
| 45 |
app = FastAPI(
|
| 46 |
title="Vectorization API",
|
|
@@ -51,6 +52,7 @@ app = FastAPI(
|
|
| 51 |
|
| 52 |
|
| 53 |
app.include_router(voyage_router)
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
class TextRequest(BaseModel):
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
from app.voyage_api import router as voyage_router
|
| 44 |
+
from app.openai_api import router as openai_router
|
| 45 |
|
| 46 |
app = FastAPI(
|
| 47 |
title="Vectorization API",
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
app.include_router(voyage_router)
|
| 55 |
+
app.include_router(openai_router)
|
| 56 |
|
| 57 |
|
| 58 |
class TextRequest(BaseModel):
|
app/models.py
CHANGED
|
@@ -165,6 +165,19 @@ class ModelRegistry:
|
|
| 165 |
result = self._run(lambda: engine.encode(text, kind))
|
| 166 |
return (QWEN_ID if engine is self.qwen else E5_ID), engine.dimensions, result
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def colbert_encode(self, text, kind):
|
| 169 |
return self._run(lambda: self.colbert.encode(text, kind))
|
| 170 |
|
|
|
|
| 165 |
result = self._run(lambda: engine.encode(text, kind))
|
| 166 |
return (QWEN_ID if engine is self.qwen else E5_ID), engine.dimensions, result
|
| 167 |
|
| 168 |
+
def openai_encode(self, texts, canonical_model):
|
| 169 |
+
"""Raw OpenAI-compatible embeddings: client supplies any query/document prefix."""
|
| 170 |
+
from app.batching import batch_encode
|
| 171 |
+
if canonical_model == E5_ID:
|
| 172 |
+
engine, is_qwen = self.e5, False
|
| 173 |
+
elif canonical_model == QWEN_ID:
|
| 174 |
+
engine, is_qwen = self.qwen, True
|
| 175 |
+
else:
|
| 176 |
+
raise ValueError("OpenAI embeddings only support dense E5 and Qwen models")
|
| 177 |
+
return self._run(lambda: batch_encode(
|
| 178 |
+
engine, texts, "passage", is_qwen=is_qwen, raw=True,
|
| 179 |
+
))
|
| 180 |
+
|
| 181 |
def colbert_encode(self, text, kind):
|
| 182 |
return self._run(lambda: self.colbert.encode(text, kind))
|
| 183 |
|
app/openai_api.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI SDK-compatible embeddings API, intentionally separate from Voyage routes.
|
| 2 |
+
|
| 3 |
+
Configure OpenAI(base_url="https://HOST/openai/v1", api_key="...").
|
| 4 |
+
The client is responsible for model-specific query/document prefixes.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import base64
|
| 9 |
+
import logging
|
| 10 |
+
import struct
|
| 11 |
+
from typing import Literal
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 16 |
+
|
| 17 |
+
from app.embedding import MODEL_ID as E5_ID, TooManyTokens
|
| 18 |
+
from app.models import ModelRegistry, QWEN_ID, ServiceBusy
|
| 19 |
+
from app.voyage_api import authenticate, resident_models
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/openai/v1", tags=["OpenAI-compatible"])
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
DenseModelName = Literal[
|
| 24 |
+
"e5", "qwen", "intfloat/multilingual-e5-small", "Qwen/Qwen3-Embedding-0.6B"
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class OpenAIEmbeddingRequest(BaseModel):
|
| 29 |
+
model_config = ConfigDict(extra="forbid")
|
| 30 |
+
model: DenseModelName
|
| 31 |
+
input: str | list[str] = Field(
|
| 32 |
+
description="Raw input; client must add model-specific query/document prefixes"
|
| 33 |
+
)
|
| 34 |
+
dimensions: int | None = Field(default=None, ge=1)
|
| 35 |
+
encoding_format: Literal["float", "base64"] = "float"
|
| 36 |
+
user: str | None = None
|
| 37 |
+
|
| 38 |
+
@field_validator("input")
|
| 39 |
+
@classmethod
|
| 40 |
+
def validate_input(cls, value):
|
| 41 |
+
texts = [value] if isinstance(value, str) else value
|
| 42 |
+
if not texts or len(texts) > 1000:
|
| 43 |
+
raise ValueError("input must contain between 1 and 1000 texts")
|
| 44 |
+
if any(not text.strip() for text in texts):
|
| 45 |
+
raise ValueError("input cannot contain blank strings")
|
| 46 |
+
return value
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def api_error(status: int, message: str, error_type="invalid_request_error", code=None):
|
| 50 |
+
return JSONResponse(status_code=status, content={
|
| 51 |
+
"error": {"message": message, "type": error_type, "param": None, "code": code}
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/embeddings", dependencies=[Depends(authenticate)])
|
| 56 |
+
def create_embedding(
|
| 57 |
+
request: OpenAIEmbeddingRequest,
|
| 58 |
+
registry: ModelRegistry = Depends(resident_models),
|
| 59 |
+
):
|
| 60 |
+
model = request.model
|
| 61 |
+
canonical = QWEN_ID if model in ("qwen", QWEN_ID) else E5_ID
|
| 62 |
+
engine = registry.qwen if canonical == QWEN_ID else registry.e5
|
| 63 |
+
if request.dimensions is not None and request.dimensions != engine.dimensions:
|
| 64 |
+
return api_error(
|
| 65 |
+
400,
|
| 66 |
+
f"Model {model} supports exactly {engine.dimensions} dimensions",
|
| 67 |
+
code="unsupported_dimensions",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
texts = [request.input] if isinstance(request.input, str) else request.input
|
| 71 |
+
try:
|
| 72 |
+
results = registry.openai_encode(texts, canonical)
|
| 73 |
+
except TooManyTokens as exc:
|
| 74 |
+
return api_error(400, str(exc), code="context_length_exceeded")
|
| 75 |
+
except ValueError as exc:
|
| 76 |
+
return api_error(400, str(exc))
|
| 77 |
+
except ServiceBusy as exc:
|
| 78 |
+
response = api_error(429, str(exc), "rate_limit_error", "rate_limit_exceeded")
|
| 79 |
+
response.headers["Retry-After"] = "2"
|
| 80 |
+
return response
|
| 81 |
+
except Exception:
|
| 82 |
+
log.exception("OpenAI-compatible embedding failed")
|
| 83 |
+
return api_error(500, "Embedding generation failed", "server_error")
|
| 84 |
+
|
| 85 |
+
def encode(vector):
|
| 86 |
+
if request.encoding_format == "float":
|
| 87 |
+
return vector
|
| 88 |
+
raw = struct.pack(f"<{len(vector)}f", *vector)
|
| 89 |
+
return base64.b64encode(raw).decode("ascii")
|
| 90 |
+
|
| 91 |
+
total_tokens = sum(result.tokens_count for result in results)
|
| 92 |
+
return {
|
| 93 |
+
"object": "list",
|
| 94 |
+
"data": [
|
| 95 |
+
{"object": "embedding", "index": index, "embedding": encode(item.embedding)}
|
| 96 |
+
for index, item in enumerate(results)
|
| 97 |
+
],
|
| 98 |
+
"model": model,
|
| 99 |
+
"usage": {"prompt_tokens": total_tokens, "total_tokens": total_tokens},
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.get("/models", dependencies=[Depends(authenticate)])
|
| 104 |
+
def list_models():
|
| 105 |
+
return {
|
| 106 |
+
"object": "list",
|
| 107 |
+
"data": [
|
| 108 |
+
{"id": mid, "object": "model", "created": 0, "owned_by": "local"}
|
| 109 |
+
for mid in ("e5", "qwen", E5_ID, QWEN_ID)
|
| 110 |
+
],
|
| 111 |
+
}
|