Sparse Embeddings API
Turns text into a sparse vector: a weight for each vocabulary term, with almost all of them zero, so it goes into an inverted index and searches fast like BM25. Unlike BM25, the weights are learned, and the model also adds related terms (synonyms, related words) that are not in the text, so documents match even when the wording differs.
Authentication and limits are the same as the Embeddings API. The examples on this page use kure-sparse-base, the more accurate choice for hybrid search with KURE-v3.
Create sparse embeddings
POST https://kure.kullm.ai/api/v1/sparse-embeddingsRequest body
| Parameter | Type | Required | Description |
|---|---|---|---|
model | string | Yes | kure-sparse-base or kure-sparse-nano (snapshots end in -20261016, see Model IDs and versions) |
input | string | string[] | Yes | Text to encode. At most 8 items, no empty strings. |
input_type | "query" | "document" | Yes | query for search queries, document for texts to index. The model encodes them differently. |
max_nnz | integer | No | Keep only the N largest weights. Defaults to all. |
return_tokens | boolean | No | Include the vocabulary term for each index (tokens). Defaults to false. |
Each text can be up to 8,192 tokens. Longer texts are truncated at the end and encoded, not rejected.
Response
{
"object": "list",
"model": "kure-sparse-base-20261016",
"dimension": 280524,
"data": [
{
"object": "sparse_embedding",
"index": 0,
"indices": [2118, 2522, 3295, 12849],
"values": [1.8276, 1.1394, 1.6759, 1.1394],
"tokens": ["e_university", "e_co", "e_location", "e_ko"]
}
],
"usage": { "prompt_tokens": 9, "total_tokens": 9 }
}indicesare vocabulary numbers (ascending) andvaluestheir weights (all positive). Every other dimension is zero.dimensionis the vocabulary size; every index is below it.- Relevance is the dot product of the query and document vectors (the sum of weight products over shared indices). The vectors are not normalized, so use the dot product, not cosine.
tokensis present only whenreturn_tokensistrue. Use it to show which terms matched, or for search engines keyed by term names (Elasticsearch/OpenSearchrank_features, …).
Models
| Model | Parameters | Vocabulary (dimension) | Non-zeros (query / document) | MTEB(kor) Retrieval |
|---|---|---|---|---|
kure-sparse-nano | 149M | 36,282 | ~130 / 450 | 0.7716 |
kure-sparse-base | 593M | 280,524 | ~80 / 450–1,000 | 0.7984 |
- Non-zero counts are rough figures measured from short sentences up to a 4,000-character document; longer documents have a few more.
- Indices differ between models. Vectors from nano and base cannot be compared.
tokensnames: nano uses Korean/English word pieces as is (고려대,위치); base prefixes its English vocabulary withe_and its multilingual vocabulary withm_(e_university).
Example
# pip install requests
import os
import requests
API = "https://kure.kullm.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['KURE_API_KEY']}"}
def encode(texts, input_type):
res = requests.post(f"{API}/sparse-embeddings", headers=HEADERS, json={
"model": "kure-sparse-base",
"input": texts,
"input_type": input_type,
})
res.raise_for_status()
return [dict(zip(d["indices"], d["values"])) for d in res.json()["data"]]
docs = [
"세종대왕은 1443년에 훈민정음을 창제하고 1446년에 이를 반포하였다.",
"김치는 배추나 무를 소금에 절인 뒤 발효시킨 음식이다.",
]
doc_vecs = encode(docs, "document")
query = encode(["한글은 언제 만들어졌나요?"], "query")[0]
for text, d in zip(docs, doc_vecs):
print(sum(w * d.get(i, 0) for i, w in query.items()), text) # dot productStore in a vector database
Qdrant
Qdrant takes sparse vectors as is and searches by dot product.
# pip install qdrant-client
from qdrant_client import QdrantClient, models
qdrant = QdrantClient(":memory:") # or QdrantClient(url="http://localhost:6333")
qdrant.create_collection(
"docs",
vectors_config={},
sparse_vectors_config={"kure-sparse": models.SparseVectorParams()},
)
def sparse(texts, input_type): # API response → Qdrant SparseVector
res = requests.post(f"{API}/sparse-embeddings", headers=HEADERS, json={
"model": "kure-sparse-base", "input": texts, "input_type": input_type,
})
res.raise_for_status()
return [models.SparseVector(indices=d["indices"], values=d["values"]) for d in res.json()["data"]]
qdrant.upsert("docs", [
models.PointStruct(id=i, vector={"kure-sparse": v}, payload={"text": t})
for i, (t, v) in enumerate(zip(docs, sparse(docs, "document")))
])
hits = qdrant.query_points("docs", query=sparse(["한글은 언제 만들어졌나요?"], "query")[0], using="kure-sparse", limit=5)
for p in hits.points:
print(p.score, p.payload["text"])PostgreSQL (pgvector)
Use pgvector's sparsevec type. Its text format is {index:value,...}/dimensions with indices starting at 1, so add 1 to the API indices. An HNSW index only accepts vectors with at most 1,000 non-zeros, so pass max_nnz: 1000 to be safe (base can exceed that on long documents).
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (id bigserial PRIMARY KEY, text text, embedding sparsevec(280524));
CREATE INDEX ON docs USING hnsw (embedding sparsevec_ip_ops);# pip install "psycopg[binary]"
import psycopg
def to_sparsevec(d, dim=280524):
return "{" + ",".join(f"{i + 1}:{v}" for i, v in zip(d["indices"], d["values"])) + f"}}/{dim}"
def encode_raw(texts, input_type):
res = requests.post(f"{API}/sparse-embeddings", headers=HEADERS, json={
"model": "kure-sparse-base", "input": texts, "input_type": input_type, "max_nnz": 1000,
})
res.raise_for_status()
return res.json()["data"]
with psycopg.connect("postgresql://localhost/mydb") as conn:
for text, d in zip(docs, encode_raw(docs, "document")):
conn.execute("INSERT INTO docs (text, embedding) VALUES (%s, %s::sparsevec)", (text, to_sparsevec(d)))
q = to_sparsevec(encode_raw(["한글은 언제 만들어졌나요?"], "query")[0])
# <#> is the negative inner product → ascending order is most relevant first
rows = conn.execute(
"SELECT text, -(embedding <#> %s::sparsevec) AS score FROM docs ORDER BY embedding <#> %s::sparsevec LIMIT 5",
(q, q),
).fetchall()Hybrid search
Sparse vectors are strong when exact terms overlap (names, product codes, statute numbers); KURE-v3 embeddings are strong when the meaning matches in different words. Combining them misses fewer documents than either alone. The score scales differ (dot products in the tens to hundreds, cosine 0–1), so fusing by rank with RRF (Reciprocal Rank Fusion) is the simple way.
import numpy as np
from openai import OpenAI
client = OpenAI(base_url=API, api_key=os.environ["KURE_API_KEY"])
def dense(texts, input_type):
res = client.embeddings.create(model="kure-v3", input=texts, dimensions=1024,
extra_body={"input_type": input_type})
return np.array([d.embedding for d in res.data])
# Index once
dense_docs = dense(docs, "document")
sparse_docs = encode(docs, "document")
def hybrid_search(query, k=60, n=10):
dense_scores = dense_docs @ dense([query], "query")[0]
q = encode([query], "query")[0]
sparse_scores = np.array([sum(w * d.get(i, 0) for i, w in q.items()) for d in sparse_docs])
fused = np.zeros(len(docs))
for scores in (dense_scores, sparse_scores):
for rank, i in enumerate(np.argsort(-scores)):
fused[i] += 1 / (k + rank + 1) # RRF: only each retriever's ranks are used
return [docs[i] for i in np.argsort(-fused)[:n]]- Pair KURE-v3 with
kure-sparse-base. RRF pays off most when the two retrievers are about equally strong: with KURE-v3, sparse-base improves on v3 alone, while the smaller sparse-nano barely helps. - In production, let your vector database do the searching instead of the dot products above. Qdrant can keep dense and sparse vectors in one collection and fuse them in one call with
query_pointsprefetch+Fusion.RRF. - Rerank the fused top results with the Rerank API for higher precision.
- See which terms go into a query vector in the playground.
