KURE

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-embeddings

Request body

ParameterTypeRequiredDescription
modelstringYeskure-sparse-base or kure-sparse-nano (snapshots end in -20261016, see Model IDs and versions)
inputstring | string[]YesText to encode. At most 8 items, no empty strings.
input_type"query" | "document"Yesquery for search queries, document for texts to index. The model encodes them differently.
max_nnzintegerNoKeep only the N largest weights. Defaults to all.
return_tokensbooleanNoInclude 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 }
}
  • indices are vocabulary numbers (ascending) and values their weights (all positive). Every other dimension is zero.
  • dimension is 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.
  • tokens is present only when return_tokens is true. Use it to show which terms matched, or for search engines keyed by term names (Elasticsearch/OpenSearch rank_features, …).

Models

ModelParametersVocabulary (dimension)Non-zeros (query / document)MTEB(kor) Retrieval
kure-sparse-nano149M36,282~130 / 4500.7716
kure-sparse-base593M280,524~80 / 450–1,0000.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.
  • tokens names: nano uses Korean/English word pieces as is (고려대, 위치); base prefixes its English vocabulary with e_ and its multilingual vocabulary with m_ (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 product

Store 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()

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_points prefetch + 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.