Sparse Embeddings API
텍스트를 희소 벡터로 바꿉니다. 단어(어휘 항목)마다 가중치를 주고 대부분은 0으로 두는 벡터라, BM25처럼 역색인에 넣어 빠르게 검색할 수 있습니다. BM25와 달리 모델이 가중치를 학습했고, 문장에 없던 관련 단어(동의어·관련어)도 스스로 넣어 표현이 달라도 찾아냅니다.
인증과 한도는 Embeddings API와 같습니다. 이 문서의 예시는 kure-sparse-base를 씁니다. KURE-v3와 함께 하이브리드 검색에 쓸 때 더 정확합니다.
희소 벡터 만들기
POST https://kure.kullm.ai/api/v1/sparse-embeddings요청 본문
| 파라미터 | 타입 | 구분 | 설명 |
|---|---|---|---|
model | string | 필수 | kure-sparse-base 또는 kure-sparse-nano (스냅샷은 -20261016, 모델 ID와 버전) |
input | string | string[] | 필수 | 인코딩할 텍스트. 배열은 최대 8개이며 빈 문자열은 안 됩니다. |
input_type | "query" | "document" | 필수 | 검색 질의는 query, 검색 대상 문서는 document. 모델이 둘을 다르게 인코딩합니다. |
max_nnz | integer | 선택 | 가중치가 큰 순으로 N개만 남깁니다. 기본값은 전부입니다. |
return_tokens | boolean | 선택 | 번호마다 어휘 이름(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-nano | 149M | 36,282 | 약 130 / 450 | 0.7716 |
kure-sparse-base | 593M | 280,524 | 약 80 / 450~1,000 | 0.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로 다시 정렬하면 정확도가 더 올라갑니다.
- 질의 벡터에 어떤 단어가 들어가는지는 플레이그라운드에서 바로 볼 수 있습니다.
