KURE

Sparse Embeddings API

텍스트를 희소 벡터로 바꿉니다. 단어(어휘 항목)마다 가중치를 주고 대부분은 0으로 두는 벡터라, BM25처럼 역색인에 넣어 빠르게 검색할 수 있습니다. BM25와 달리 모델이 가중치를 학습했고, 문장에 없던 관련 단어(동의어·관련어)도 스스로 넣어 표현이 달라도 찾아냅니다.

인증과 한도는 Embeddings API와 같습니다. 이 문서의 예시는 kure-sparse-base를 씁니다. KURE-v3와 함께 하이브리드 검색에 쓸 때 더 정확합니다.

희소 벡터 만들기

POST https://kure.kullm.ai/api/v1/sparse-embeddings

요청 본문

파라미터타입구분설명
modelstring필수kure-sparse-base 또는 kure-sparse-nano (스냅샷은 -20261016, 모델 ID와 버전)
inputstring | string[]필수인코딩할 텍스트. 배열은 최대 8개이며 빈 문자열은 안 됩니다.
input_type"query" | "document"필수검색 질의는 query, 검색 대상 문서는 document. 모델이 둘을 다르게 인코딩합니다.
max_nnzinteger선택가중치가 큰 순으로 N개만 남깁니다. 기본값은 전부입니다.
return_tokensboolean선택번호마다 어휘 이름(tokens)을 함께 돌려줍니다. 기본값 false.

텍스트 하나는 최대 8,192 토큰이며, 넘으면 에러 없이 뒷부분을 잘라 인코딩합니다.

응답

{
  "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는 어휘 번호(오름차순), values는 그 가중치(모두 0보다 큼)입니다. 나머지 차원은 0입니다.
  • dimension은 어휘 크기입니다. 모든 번호는 이보다 작습니다.
  • 관련도는 질의 벡터와 문서 벡터의 내적(겹치는 번호의 가중치 곱의 합)입니다. 벡터를 정규화하지 않으므로 코사인이 아니라 내적을 쓰세요.
  • tokens는 return_tokens가 true일 때만 들어갑니다. 어떤 단어로 찾았는지 보여 주거나, 단어 이름을 쓰는 검색 엔진(Elasticsearch·OpenSearch의 rank_features 등)에 넣을 때 씁니다.

모델

모델파라미터어휘(dimension)0이 아닌 값 (질의 / 문서)MTEB(kor) Retrieval
kure-sparse-nano149M36,282약 130 / 4500.7716
kure-sparse-base593M280,524약 80 / 450~1,0000.7984
  • 0이 아닌 값의 수는 짧은 문장에서 4천 자 문서까지 잰 대략적인 값입니다. 문서가 길수록 조금씩 늘어납니다.
  • 번호는 모델마다 다릅니다. nano로 만든 벡터와 base로 만든 벡터는 비교할 수 없습니다.
  • tokens 이름: nano는 한국어·영어 단어 조각 그대로(고려대, 위치), base는 영어 어휘에 e_, 다국어 어휘에 m_이 붙습니다(e_university).

예시

# 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)  # 내적

벡터 DB에 넣기

Qdrant

Qdrant는 희소 벡터를 그대로 받습니다. 내적으로 검색합니다.

# pip install qdrant-client
from qdrant_client import QdrantClient, models
 
qdrant = QdrantClient(":memory:")  # 서버라면 QdrantClient(url="http://localhost:6333")
qdrant.create_collection(
    "docs",
    vectors_config={},
    sparse_vectors_config={"kure-sparse": models.SparseVectorParams()},
)
 
 
def sparse(texts, input_type):  # API 응답 → 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)

pgvector의 sparsevec 타입을 씁니다. 텍스트 형식은 {번호:값,...}/차원이고 번호가 1부터 시작하므로 API의 번호에 1을 더하세요. HNSW 인덱스는 0이 아닌 값이 1,000개 이하인 벡터만 넣을 수 있어 max_nnz: 1000을 주면 안전합니다(base는 긴 문서에서 1,000개를 넘기도 합니다).

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])
    # <#>는 음의 내적 → 작은 순이 관련도 높은 순
    rows = conn.execute(
        "SELECT text, -(embedding <#> %s::sparsevec) AS score FROM docs ORDER BY embedding <#> %s::sparsevec LIMIT 5",
        (q, q),
    ).fetchall()

하이브리드 검색

희소 벡터는 단어가 정확히 겹치는 문서(고유명사, 제품 번호, 법조문)에 강하고, KURE-v3 임베딩은 표현이 달라도 의미가 같은 문서에 강합니다. 둘의 결과를 합치면 한쪽만 쓸 때보다 놓치는 문서가 줄어듭니다. 점수 척도가 달라서(내적 수십수백, 코사인 01) 점수 대신 순위로 합치는 RRF(Reciprocal Rank Fusion)가 간단합니다.

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])
 
 
# 색인 (미리 한 번)
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: 각 검색 결과에서의 순위만 쓴다
    return [docs[i] for i in np.argsort(-fused)[:n]]
  • 상대 모델로는 kure-sparse-base를 권합니다. RRF는 두 검색기의 성능이 비슷할 때 이득이 큽니다. KURE-v3와 함께 쓰면 sparse-base는 v3 단독보다 정확도를 높이지만, 더 작은 sparse-nano는 거의 도움이 되지 않습니다.
  • 실제 서비스에서는 위의 내적 계산 대신 벡터 DB의 검색을 쓰면 됩니다. Qdrant는 dense·sparse 벡터를 한 컬렉션에 두고 query_points의 prefetch + Fusion.RRF로 한 번에 합칠 수 있습니다.
  • 합친 상위 결과를 Rerank API로 다시 정렬하면 정확도가 더 올라갑니다.
  • 질의 벡터에 어떤 단어가 들어가는지는 플레이그라운드에서 바로 볼 수 있습니다.