structverify 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""structverify.training.tasks — 파인튜닝 대상 태스크 정의 + instruction 템플릿.
|
|
2
|
+
|
|
3
|
+
StructVerify 검증 파이프라인에서 LLM이 하는 하위 작업 중, *회사 도메인/스키마에 특화*될수록
|
|
4
|
+
이득이 큰 3개를 학습 대상으로 잡는다:
|
|
5
|
+
|
|
6
|
+
1. schema — claim 문장 → 구조화 추출 {indicator, value, unit, time_period}
|
|
7
|
+
(회사 용어·표기를 배우면 탐지/스키마 정확도↑)
|
|
8
|
+
2. sql — 스키마 + claim → 집계/파생 SQL (회사 테이블 스키마를 배우면 SQL 정확도↑)
|
|
9
|
+
3. verdict — claim + 근거값 → 판정(match/mismatch) + 근거 (회사 톤/기준을 배움)
|
|
10
|
+
|
|
11
|
+
각 예시는 chat 포맷(messages: system/user/assistant)으로 표준화 — TRL/unsloth가
|
|
12
|
+
apply_chat_template으로 바로 학습 가능. 백엔드·모델에 안 종속.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
TASKS = ("schema", "sql", "verdict")
|
|
19
|
+
|
|
20
|
+
_SYS = {
|
|
21
|
+
"schema": "너는 문서의 수치 주장에서 검증에 필요한 구조를 추출하는 분석기다. JSON만 출력한다.",
|
|
22
|
+
"sql": "너는 회사 데이터베이스로 주장을 검증하는 SQL 분석가다. 읽기전용 단일 SELECT만 출력한다.",
|
|
23
|
+
"verdict": "너는 주장과 근거 데이터를 대조해 사실 여부를 판정하는 검증관이다.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _schema_user(claim_text: str) -> str:
|
|
28
|
+
return (
|
|
29
|
+
"아래 문장에서 검증 대상 수치를 추출해 JSON으로.\n"
|
|
30
|
+
f'문장: "{claim_text}"\n'
|
|
31
|
+
'{"indicator": "지표명", "value": 숫자, "unit": "단위", "time_period": "시점 또는 null", '
|
|
32
|
+
'"population": "대상/지역 또는 null"}'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sql_user(indicator: str, schema_hint: str, claim_text: str, dialect: str = "snowflake") -> str:
|
|
37
|
+
return (
|
|
38
|
+
f"[{dialect}] 아래 지표를 검증할 단일 SELECT 집계 쿼리 하나를 작성.\n"
|
|
39
|
+
f"[스키마]\n{schema_hint}\n"
|
|
40
|
+
f"[검증할 지표] {indicator}\n"
|
|
41
|
+
f'[주장 원문] "{claim_text}"\n'
|
|
42
|
+
"결과는 스칼라 값 1개. 비율/증감율이면 %로 직접 계산."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _verdict_user(claim_text: str, evidence_value: Any, evidence_unit: str) -> str:
|
|
47
|
+
return (
|
|
48
|
+
"주장과 근거 데이터를 대조해 판정.\n"
|
|
49
|
+
f'주장: "{claim_text}"\n'
|
|
50
|
+
f"근거 데이터: {evidence_value} {evidence_unit}\n"
|
|
51
|
+
'판정(match/mismatch/unverifiable)과 한 줄 근거를 JSON으로: '
|
|
52
|
+
'{"verdict": "...", "reason": "..."}'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_example(task: str, **kw: Any) -> dict:
|
|
57
|
+
"""(task, 필드) → chat 포맷 학습 예시 dict.
|
|
58
|
+
|
|
59
|
+
schema: claim_text, output(JSON str)
|
|
60
|
+
sql: indicator, schema_hint, claim_text, output(SQL str), [dialect]
|
|
61
|
+
verdict: claim_text, evidence_value, evidence_unit, output(JSON str)
|
|
62
|
+
"""
|
|
63
|
+
if task == "schema":
|
|
64
|
+
user = _schema_user(kw["claim_text"])
|
|
65
|
+
elif task == "sql":
|
|
66
|
+
user = _sql_user(kw["indicator"], kw["schema_hint"], kw["claim_text"],
|
|
67
|
+
kw.get("dialect", "snowflake"))
|
|
68
|
+
elif task == "verdict":
|
|
69
|
+
user = _verdict_user(kw["claim_text"], kw["evidence_value"], kw["evidence_unit"])
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"미지원 task: {task!r} (schema/sql/verdict 중 하나)")
|
|
72
|
+
return {
|
|
73
|
+
"task": task,
|
|
74
|
+
"messages": [
|
|
75
|
+
{"role": "system", "content": _SYS[task]},
|
|
76
|
+
{"role": "user", "content": user},
|
|
77
|
+
{"role": "assistant", "content": str(kw["output"]).strip()},
|
|
78
|
+
],
|
|
79
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""structverify.utils.embedding_client — 공용 임베딩 클라이언트 (#67-A 골격).
|
|
2
|
+
|
|
3
|
+
config.embedding({provider, model, api_key_env, base_url?})을 읽어 provider 분기.
|
|
4
|
+
utils/llm_client.py 의 provider dispatch 패턴을 따름.
|
|
5
|
+
이번 단계는 *골격만* — 실제 HTTP 호출(_embed_hcx 등)은 NotImplementedError 스텁.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from structverify.utils.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
EMBEDDING_DIM = 1024 # HCX-EMB-V2 차원 (memory/embedder.py:26 과 동일)
|
|
20
|
+
|
|
21
|
+
# HCX 호출 수치 — 일단 상수, 나중에 config화 여지 (kosis_crawler 기준)
|
|
22
|
+
_HCX_TIMEOUT = 30 # 초
|
|
23
|
+
_BATCH_CONCURRENCY = 3 # asyncio.Semaphore 동시 호출 수
|
|
24
|
+
_BATCH_MAX_RETRY = 5 # 429 등 재시도 횟수
|
|
25
|
+
|
|
26
|
+
# provider별 기본값 (config 미지정 시). base_url은 다음 단계 호출에서 사용.
|
|
27
|
+
_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
|
|
28
|
+
"hcx": {
|
|
29
|
+
"api_key_env": "CLOVASTUDIO_API_KEY",
|
|
30
|
+
"base_url": "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2",
|
|
31
|
+
},
|
|
32
|
+
"openai": {
|
|
33
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
34
|
+
"base_url": "https://api.openai.com/v1",
|
|
35
|
+
"model": "text-embedding-3-small",
|
|
36
|
+
},
|
|
37
|
+
"upstage": {
|
|
38
|
+
"api_key_env": "UPSTAGE_API_KEY",
|
|
39
|
+
"base_url": "https://api.upstage.ai/v1",
|
|
40
|
+
# Upstage 임베딩: 검색어=embedding-query, 문서색인=embedding-passage (4096-dim).
|
|
41
|
+
# (구 solar-embedding-1-large 는 무효). 기본은 query — 문서 색인 시 config로 passage 지정.
|
|
42
|
+
"model": "embedding-query",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_async_openai():
|
|
48
|
+
"""openai SDK AsyncOpenAI 로드. 없으면 None(httpx 폴백). 테스트 monkeypatch 지점."""
|
|
49
|
+
try:
|
|
50
|
+
from openai import AsyncOpenAI
|
|
51
|
+
return AsyncOpenAI
|
|
52
|
+
except ImportError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EmbeddingClient:
|
|
57
|
+
"""텍스트 → 임베딩 벡터. provider(hcx/openai/upstage) 분기.
|
|
58
|
+
|
|
59
|
+
config 예 (config/default.yaml의 embedding 섹션):
|
|
60
|
+
provider: "hcx"
|
|
61
|
+
model: "HCX-EMB-V2"
|
|
62
|
+
api_key_env: "NCP_API_KEY"
|
|
63
|
+
base_url: "..." # 선택 (provider 기본값 있음)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, config: dict | None = None):
|
|
67
|
+
self.config = config or {}
|
|
68
|
+
# provider 디폴트는 두지 않되, 없으면 "hcx" (기존 동작 보존)
|
|
69
|
+
self.provider = self.config.get("provider", "hcx")
|
|
70
|
+
_defaults = _PROVIDER_DEFAULTS.get(self.provider, {})
|
|
71
|
+
# [#64 후속] provider만 바꿔도 동작하도록, provider!=hcx인데 config 값이 HCX 잔재면 무시.
|
|
72
|
+
# (default.yaml embedding: model=HCX-EMB-V2, api_key_env=NCP_API_KEY 이 남아있어도)
|
|
73
|
+
_user_model = self.config.get("model")
|
|
74
|
+
if _user_model and self.provider != "hcx" and str(_user_model).upper().startswith("HCX"):
|
|
75
|
+
_user_model = None
|
|
76
|
+
self.model = _user_model or _defaults.get("model")
|
|
77
|
+
# 키는 *값*이 아니라 api_key_env *이름*으로 보관 → 호출 시점에 os.environ 조회 (lazy)
|
|
78
|
+
_user_key_env = self.config.get("api_key_env")
|
|
79
|
+
if _user_key_env and self.provider != "hcx" and _user_key_env in ("NCP_API_KEY", "CLOVASTUDIO_API_KEY"):
|
|
80
|
+
_user_key_env = None
|
|
81
|
+
self._api_key_env = _user_key_env or _defaults.get("api_key_env", "")
|
|
82
|
+
# [BYO] 테넌트 키 직접 주입 (env 대신). SaaS 사용자별 키 주입용.
|
|
83
|
+
self._direct_api_key = self.config.get("_direct_api_key") or ""
|
|
84
|
+
self.base_url = self.config.get("base_url") or _defaults.get("base_url", "")
|
|
85
|
+
logger.info(
|
|
86
|
+
f"[EmbeddingClient] provider={self.provider}, model={self.model!r}, "
|
|
87
|
+
f"api_key_env={self._api_key_env!r}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def api_key(self) -> str:
|
|
92
|
+
"""직접 주입된 키(_direct_api_key) 우선, 없으면 api_key_env 환경변수 값."""
|
|
93
|
+
if self._direct_api_key:
|
|
94
|
+
return self._direct_api_key
|
|
95
|
+
if not self._api_key_env:
|
|
96
|
+
return ""
|
|
97
|
+
return os.environ.get(self._api_key_env, "")
|
|
98
|
+
|
|
99
|
+
# ── 공개 인터페이스 ──
|
|
100
|
+
|
|
101
|
+
async def embed(self, text: str, role: str = "query") -> list[float] | None:
|
|
102
|
+
"""단건 임베딩 — provider 분기.
|
|
103
|
+
|
|
104
|
+
role: "query"(검색어) | "passage"(문서/청크 색인). upstage는 role별 모델
|
|
105
|
+
(embedding-query / embedding-passage)로 비대칭 검색을 지원 → 검색 정확도↑.
|
|
106
|
+
hcx/openai는 단일 모델(role 무시).
|
|
107
|
+
"""
|
|
108
|
+
if self.provider == "hcx":
|
|
109
|
+
return await self._embed_hcx(text)
|
|
110
|
+
elif self.provider == "openai":
|
|
111
|
+
return await self._embed_openai(text)
|
|
112
|
+
elif self.provider == "upstage":
|
|
113
|
+
return await self._embed_upstage(text, role)
|
|
114
|
+
raise ValueError(f"미지원 embedding provider: {self.provider}")
|
|
115
|
+
|
|
116
|
+
async def embed_batch(self, texts: list[str], role: str = "passage") -> list[list[float]]:
|
|
117
|
+
"""배치 임베딩 — provider 분기. role은 upstage 비대칭(query/passage)에만 적용."""
|
|
118
|
+
if self.provider == "hcx":
|
|
119
|
+
return await self._embed_batch_hcx(texts)
|
|
120
|
+
elif self.provider == "openai":
|
|
121
|
+
return await self._embed_batch_openai(texts)
|
|
122
|
+
elif self.provider == "upstage":
|
|
123
|
+
return await self._embed_batch_upstage(texts, role)
|
|
124
|
+
raise ValueError(f"미지원 embedding provider: {self.provider}")
|
|
125
|
+
|
|
126
|
+
# ── provider별 실제 호출 (다음 단계 구현) ──
|
|
127
|
+
|
|
128
|
+
async def _embed_hcx(self, text: str) -> list[float] | None:
|
|
129
|
+
# catalog_search._get_embedding 이식. 키 없으면 None, 예외 → 로그 후 None.
|
|
130
|
+
if not self.api_key:
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
async with httpx.AsyncClient(timeout=_HCX_TIMEOUT) as client:
|
|
134
|
+
resp = await client.post(
|
|
135
|
+
self.base_url, # config base_url 우선 (기본=hcx 엔드포인트)
|
|
136
|
+
headers={
|
|
137
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
},
|
|
140
|
+
json={"text": text},
|
|
141
|
+
)
|
|
142
|
+
return resp.json()["result"]["embedding"]
|
|
143
|
+
except Exception as e: # noqa: BLE001
|
|
144
|
+
logger.debug(f"[EmbeddingClient] hcx 임베딩 실패: {e}")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
async def _embed_openai(self, text: str) -> list[float] | None:
|
|
148
|
+
res = await self._embed_openai_compatible([text])
|
|
149
|
+
return res[0] if res else None
|
|
150
|
+
|
|
151
|
+
async def _embed_upstage(self, text: str, role: str = "query") -> list[float] | None:
|
|
152
|
+
# upstage 비대칭: 사용자가 model 미지정이면 role별 기본(embedding-query/passage).
|
|
153
|
+
model = self.model
|
|
154
|
+
if not self.config.get("model"):
|
|
155
|
+
model = "embedding-passage" if role == "passage" else "embedding-query"
|
|
156
|
+
res = await self._embed_openai_compatible([text], model_override=model)
|
|
157
|
+
return res[0] if res else None
|
|
158
|
+
|
|
159
|
+
async def _embed_openai_compatible(
|
|
160
|
+
self, inputs: list[str], model_override: str | None = None,
|
|
161
|
+
) -> list[list[float]] | None:
|
|
162
|
+
"""openai·upstage 공유. inputs(list) → 임베딩 list. 키 없으면 None, 예외→로그→None.
|
|
163
|
+
|
|
164
|
+
dimensions=EMBEDDING_DIM(1024) 강제 — pgvector vector(1024) 고정에 맞춤.
|
|
165
|
+
⚠️ upstage(solar-embedding-1-large)는 dimensions 미지원일 수 있음 → 원차원(예: 4096)
|
|
166
|
+
반환 가능. 그 경우 아래 길이 검증에서 경고하고 그대로 반환(호출부/DB가 불일치 판단:
|
|
167
|
+
pgvector vector(1024)에 INSERT 시 차원 안 맞으면 거부됨).
|
|
168
|
+
"""
|
|
169
|
+
if not self.api_key:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
_AsyncOpenAI = _load_async_openai()
|
|
173
|
+
if _AsyncOpenAI is not None:
|
|
174
|
+
# OpenAI 공식 SDK 경로
|
|
175
|
+
client = _AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
176
|
+
# dimensions는 openai만 지원 (upstage embedding은 미지원 → 보내면 에러).
|
|
177
|
+
_kw = {"model": model_override or self.model, "input": inputs}
|
|
178
|
+
if self.provider == "openai":
|
|
179
|
+
_kw["dimensions"] = EMBEDDING_DIM
|
|
180
|
+
resp = await client.embeddings.create(**_kw)
|
|
181
|
+
vectors = [d.embedding for d in resp.data]
|
|
182
|
+
else:
|
|
183
|
+
# SDK 없으면 httpx 로 POST {base_url}/embeddings
|
|
184
|
+
async with httpx.AsyncClient(timeout=_HCX_TIMEOUT) as client:
|
|
185
|
+
resp = await client.post(
|
|
186
|
+
f"{self.base_url.rstrip('/')}/embeddings",
|
|
187
|
+
headers={
|
|
188
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
},
|
|
191
|
+
json={
|
|
192
|
+
"model": model_override or self.model,
|
|
193
|
+
"input": inputs,
|
|
194
|
+
# dimensions는 openai만 지원 (upstage 미지원)
|
|
195
|
+
**({"dimensions": EMBEDDING_DIM} if self.provider == "openai" else {}),
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
data = resp.json()
|
|
199
|
+
vectors = [d["embedding"] for d in data["data"]]
|
|
200
|
+
if vectors and len(vectors[0]) != EMBEDDING_DIM:
|
|
201
|
+
logger.warning(
|
|
202
|
+
f"[EmbeddingClient] {self.provider} 임베딩 차원 {len(vectors[0])} "
|
|
203
|
+
f"≠ EMBEDDING_DIM({EMBEDDING_DIM}) — pgvector(1024) 불일치 위험"
|
|
204
|
+
)
|
|
205
|
+
return vectors
|
|
206
|
+
except Exception as e: # noqa: BLE001
|
|
207
|
+
logger.debug(f"[EmbeddingClient] {self.provider} 임베딩 실패: {e}")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
async def _embed_batch_hcx(self, texts: list[str]) -> list[list[float]]:
|
|
211
|
+
# kosis_crawler.get_embedding_safe 이식: Semaphore + 429 지수백오프 + 제로벡터 폴백.
|
|
212
|
+
if not self.api_key:
|
|
213
|
+
logger.warning("[EmbeddingClient] api_key 없음 → batch 제로벡터 반환")
|
|
214
|
+
return [[0.0] * EMBEDDING_DIM for _ in texts]
|
|
215
|
+
sem = asyncio.Semaphore(_BATCH_CONCURRENCY)
|
|
216
|
+
headers = {
|
|
217
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async def _one(client: httpx.AsyncClient, text: str) -> list[float]:
|
|
222
|
+
async with sem:
|
|
223
|
+
for retry in range(_BATCH_MAX_RETRY):
|
|
224
|
+
resp = await client.post(
|
|
225
|
+
self.base_url, headers=headers, json={"text": text},
|
|
226
|
+
)
|
|
227
|
+
data = resp.json()
|
|
228
|
+
if data.get("result") is not None:
|
|
229
|
+
return data["result"]["embedding"]
|
|
230
|
+
if resp.status_code == 429:
|
|
231
|
+
await asyncio.sleep(2 ** retry)
|
|
232
|
+
return [0.0] * EMBEDDING_DIM
|
|
233
|
+
|
|
234
|
+
async with httpx.AsyncClient(timeout=_HCX_TIMEOUT) as client:
|
|
235
|
+
return list(await asyncio.gather(*[_one(client, t) for t in texts]))
|
|
236
|
+
|
|
237
|
+
async def _embed_batch_openai(self, texts: list[str]) -> list[list[float]]:
|
|
238
|
+
# OpenAI는 input=list 배치 지원 → 한 번 호출. 실패 시 제로벡터 폴백(hcx와 동일 규약).
|
|
239
|
+
res = await self._embed_openai_compatible(texts)
|
|
240
|
+
return res if res is not None else [[0.0] * EMBEDDING_DIM for _ in texts]
|
|
241
|
+
|
|
242
|
+
async def _embed_batch_upstage(self, texts: list[str], role: str = "passage") -> list[list[float]]:
|
|
243
|
+
# 인덱싱은 passage 모델(문서), 검색은 query. 사용자가 model 지정하면 그대로.
|
|
244
|
+
model = self.model
|
|
245
|
+
if not self.config.get("model"):
|
|
246
|
+
model = "embedding-passage" if role == "passage" else "embedding-query"
|
|
247
|
+
res = await self._embed_openai_compatible(texts, model_override=model)
|
|
248
|
+
return res if res is not None else [[0.0] * EMBEDDING_DIM for _ in texts]
|