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,583 @@
|
|
|
1
|
+
# [2026-05-14 | 이수민] memory/v1: KOSIS API + pgvector 결과 metadata 머지
|
|
2
|
+
# - 같은 stat_id가 두 경로에서 모두 나올 때 metadata 머지
|
|
3
|
+
# - KOSIS API 결과(먼저 추가)에 pgvector 결과의 category_path를 보강
|
|
4
|
+
# - 도메인 가드 인프라용 (verifier에서 evidence.category_path 사용)
|
|
5
|
+
"""
|
|
6
|
+
retrieval/catalog_search.py — kosis_stat_catalog pgvector 검색 모듈 (Step 7-0)
|
|
7
|
+
|
|
8
|
+
[박재윤 - 2026-04-30]
|
|
9
|
+
- kosis_stat_catalog 테이블 pgvector 검색 구현 (factcheck_test.py 참고)
|
|
10
|
+
· search_pgvector(): category_path ILIKE 필터 + embedding 유사도 검색
|
|
11
|
+
· get_embedding(): HCX 임베딩 API 호출
|
|
12
|
+
|
|
13
|
+
[김예슬 - 2026-04-30]
|
|
14
|
+
- extract_category_and_keyword(): LLM이 indicator → KOSIS 카테고리 + 검색어 추출
|
|
15
|
+
- CatalogSearchTool: KOSISConnector가 호출하는 Tool 인터페이스
|
|
16
|
+
· search(): ConnectorQuery → 후보 StatRecord 목록 반환
|
|
17
|
+
· 내부: keyword 검색(KOSIS API vwCd=MT_ZTITLE) + pgvector 필터 검색 + pgvector 전체 검색 조합
|
|
18
|
+
|
|
19
|
+
[설계]
|
|
20
|
+
kosis_stat_catalog는 공식 데이터 저장소가 아니라
|
|
21
|
+
"어떤 stat_id를 써야 하는지 찾는 검색 인덱스"로 사용.
|
|
22
|
+
|
|
23
|
+
검색 3단계:
|
|
24
|
+
1) KOSIS 통합검색(vwCd=MT_ZTITLE): 국가통계만 + RANK 순
|
|
25
|
+
2) pgvector (category_path 필터 + embedding): 카테고리 범위 좁힌 유사도 검색
|
|
26
|
+
3) pgvector (필터 없이): 전체 유사도 검색 (폴백)
|
|
27
|
+
|
|
28
|
+
중복 제거 후 최대 top_k 반환 → LLM Agent가 최적 stat_id 선택
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
from structverify.retrieval.base_connector import ConnectorQuery, StatRecord
|
|
40
|
+
from structverify.utils.logger import get_logger
|
|
41
|
+
from structverify.utils.embedding_client import EmbeddingClient
|
|
42
|
+
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
# KOSIS 통합검색에서 국가통계만 (지역통계 제외)
|
|
46
|
+
_KOSIS_SEARCH_VW_CD = "MT_ZTITLE"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── [v6.21] 수록주기 인지 재정렬 ──────────────────────────────────────
|
|
50
|
+
# catalog 인덱스(kosis_stat_catalog)에 수록주기 컬럼이 없어, 표 이름
|
|
51
|
+
# 텍스트에서 추론한다. claim이 월/분기 시점인데 검색 결과 상위가 전부
|
|
52
|
+
# 연 단위 표면 fetch가 모두 실패하므로(표 시점 단위 불일치 거부),
|
|
53
|
+
# 월/분기 claim일 때 월간 신호가 있는 표를 앞으로 끌어올린다.
|
|
54
|
+
# 도메인 무관: 표 이름의 보편적 주기 어휘만 사용 (인구·기온·고용 공통).
|
|
55
|
+
_MONTHLY_NAME_HINTS = ("월별", "월간", "월.", "월·", "/월", "매월", "월말")
|
|
56
|
+
_YEARLY_NAME_HINTS = ("연간", "연도별", "연도말", "장래", "추계", "년간")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _claim_period_unit(time_period: str | None) -> str:
|
|
60
|
+
"""claim time_period의 단위 판별: month / quarter / year / unknown."""
|
|
61
|
+
if not time_period:
|
|
62
|
+
return "unknown"
|
|
63
|
+
s = str(time_period).strip()
|
|
64
|
+
# YYYY-MM, YYYY.MM, YYYYMM → month
|
|
65
|
+
if re.match(r"^\d{4}[-./]?\d{2}$", s):
|
|
66
|
+
return "month"
|
|
67
|
+
# YYYY-Q1 등 분기
|
|
68
|
+
if re.search(r"[Qq][1-4]", s) or re.match(r"^\d{4}[-./]?[1-4]$", s):
|
|
69
|
+
return "quarter"
|
|
70
|
+
if re.match(r"^\d{4}$", s):
|
|
71
|
+
return "year"
|
|
72
|
+
return "unknown"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _name_periodicity(stat_name: str) -> str:
|
|
76
|
+
"""표 이름 텍스트에서 수록주기 추론: monthly / yearly / mixed / unknown.
|
|
77
|
+
|
|
78
|
+
"월.분기.연간 인구동향" → mixed (월 신호 있음 → 월 claim에 사용 가능)
|
|
79
|
+
"장래 합계출산율" → yearly
|
|
80
|
+
"시도/혼인종류별 혼인" → unknown (주기 표기 없음)
|
|
81
|
+
"""
|
|
82
|
+
if not stat_name:
|
|
83
|
+
return "unknown"
|
|
84
|
+
s = str(stat_name)
|
|
85
|
+
has_month = any(h in s for h in _MONTHLY_NAME_HINTS)
|
|
86
|
+
has_year = any(h in s for h in _YEARLY_NAME_HINTS)
|
|
87
|
+
if has_month and has_year:
|
|
88
|
+
return "mixed" # "월.분기.연간" — 월 데이터 포함
|
|
89
|
+
if has_month:
|
|
90
|
+
return "monthly"
|
|
91
|
+
if has_year:
|
|
92
|
+
return "yearly"
|
|
93
|
+
return "unknown"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _rerank_by_periodicity(
|
|
97
|
+
records: list[StatRecord], claim_unit: str
|
|
98
|
+
) -> list[StatRecord]:
|
|
99
|
+
"""claim 시점 단위에 맞춰 후보를 재정렬.
|
|
100
|
+
|
|
101
|
+
claim이 월/분기인데 상위가 전부 연 단위 표면 검증이 불가능하므로,
|
|
102
|
+
월간 신호가 있는 표(monthly/mixed)를 앞으로, 명백한 연 단위 표
|
|
103
|
+
(yearly)를 뒤로 보낸다. embedding 유사도 순서는 같은 등급 안에서 유지.
|
|
104
|
+
|
|
105
|
+
claim이 연 단위거나 unknown이면 원순서 그대로 (가드 불필요).
|
|
106
|
+
"""
|
|
107
|
+
if claim_unit not in ("month", "quarter"):
|
|
108
|
+
return records
|
|
109
|
+
|
|
110
|
+
def _priority(r: StatRecord) -> int:
|
|
111
|
+
p = _name_periodicity(getattr(r, "stat_name", "") or "")
|
|
112
|
+
if p in ("monthly", "mixed"):
|
|
113
|
+
return 0 # 월 데이터 있음 — 최우선
|
|
114
|
+
if p == "unknown":
|
|
115
|
+
return 1 # 판별 불가 — 중립
|
|
116
|
+
return 2 # yearly — 월 claim엔 부적합, 후순위
|
|
117
|
+
|
|
118
|
+
# stable sort: 같은 우선순위는 기존(유사도) 순서 유지
|
|
119
|
+
return sorted(records, key=_priority)
|
|
120
|
+
|
|
121
|
+
# LLM 카테고리/검색어 추출 프롬프트 (parent_path 없을 때만 fallback으로 사용)
|
|
122
|
+
_CATEGORY_EXTRACT_PROMPT = """다음 뉴스 수치 주장을 KOSIS 검색에 적합한 형태로 분석하세요.
|
|
123
|
+
|
|
124
|
+
indicator: {indicator}
|
|
125
|
+
population: {population}
|
|
126
|
+
원문: {claim_text}
|
|
127
|
+
|
|
128
|
+
[추출 항목]
|
|
129
|
+
1) 카테고리 키워드: 이 통계가 속할 KOSIS 분야 키워드 1~3개 (쉼표 구분)
|
|
130
|
+
KOSIS 대분류: 인구, 가구, 고용, 노동, 임금, 물가, 가계, 보건, 사회, 복지,
|
|
131
|
+
교육, 환경, 농림, 수산, 건설, 주택, 토지, 교통, 정보통신, 경제, 산업, 무역
|
|
132
|
+
|
|
133
|
+
2) 검색 키워드: KOSIS 통계표 이름에 들어갈 *핵심 명사* 2~3 단어
|
|
134
|
+
- 숫자/연도/월/일 절대 포함 금지
|
|
135
|
+
- "증가율/변화/차이/상승/하락" 같은 측정 행위 단어 *제외*
|
|
136
|
+
- "출생아 수" "혼인 건수" "쉬었음 인구" 같이 측정 대상 자체만
|
|
137
|
+
|
|
138
|
+
[좋은 예]
|
|
139
|
+
indicator="출생아 수 증가율" → 검색어="출생아 수"
|
|
140
|
+
indicator="혼인 건수 증가율" → 검색어="혼인 건수"
|
|
141
|
+
indicator="연평균 기온" → 검색어="연평균 기온"
|
|
142
|
+
indicator="쉬었음 청년" → 검색어="쉬었음 인구"
|
|
143
|
+
|
|
144
|
+
[형식 — 이 두 줄만 출력]
|
|
145
|
+
카테고리: 키워드1, 키워드2
|
|
146
|
+
검색어: 핵심 명사 두세개"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CatalogSearchTool:
|
|
150
|
+
"""
|
|
151
|
+
kosis_stat_catalog pgvector DB를 KOSIS API 검색 인덱스로 사용하는 Tool.
|
|
152
|
+
|
|
153
|
+
KOSISConnector.search_and_fetch()에서 1단계로 호출됨.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, config: dict | None = None):
|
|
157
|
+
self.config = config or {}
|
|
158
|
+
self.api_key = os.environ.get(
|
|
159
|
+
self.config.get("api_key_env", "KOSIS_API_KEY"), ""
|
|
160
|
+
)
|
|
161
|
+
self.hcx_key = os.environ.get(
|
|
162
|
+
self.config.get("llm", {}).get("api_key_env", "CLOVASTUDIO_API_KEY"), ""
|
|
163
|
+
)
|
|
164
|
+
self.pg_dsn = os.environ.get(
|
|
165
|
+
self.config.get("pgvector_dsn_env", "PGVECTOR_DSN"),
|
|
166
|
+
"postgresql://structverify:svpass123@localhost:5432/structverify",
|
|
167
|
+
)
|
|
168
|
+
self.timeout = self.config.get("timeout", 30)
|
|
169
|
+
# [#67-D A-1] 공용 EmbeddingClient. config.embedding 있으면 그것, 없으면
|
|
170
|
+
# 현재 키 소스(config.llm.api_key_env, 기본 CLOVASTUDIO_API_KEY)로 폴백(동작 보존).
|
|
171
|
+
emb_cfg = self.config.get("embedding") or {
|
|
172
|
+
"api_key_env": self.config.get("llm", {}).get("api_key_env", "CLOVASTUDIO_API_KEY"),
|
|
173
|
+
}
|
|
174
|
+
self._embedder = EmbeddingClient(emb_cfg)
|
|
175
|
+
|
|
176
|
+
async def search(
|
|
177
|
+
self,
|
|
178
|
+
query: ConnectorQuery,
|
|
179
|
+
top_k: int = 10,
|
|
180
|
+
) -> list[StatRecord]:
|
|
181
|
+
"""
|
|
182
|
+
ConnectorQuery → 후보 StatRecord 목록.
|
|
183
|
+
|
|
184
|
+
검색 3단계:
|
|
185
|
+
1) KOSIS 통합검색 (vwCd=MT_ZTITLE, 국가통계만)
|
|
186
|
+
2) pgvector category_path 필터 + embedding 검색
|
|
187
|
+
3) pgvector 전체 embedding 검색 (폴백)
|
|
188
|
+
|
|
189
|
+
중복 제거 후 top_k 반환.
|
|
190
|
+
"""
|
|
191
|
+
# LLM으로 category 키워드 + 검색어 추출
|
|
192
|
+
category_kws, search_kw = await self._extract_category_and_keyword(query)
|
|
193
|
+
logger.info(f"CatalogSearch: keyword='{search_kw}' category={category_kws}")
|
|
194
|
+
|
|
195
|
+
results: list[StatRecord] = []
|
|
196
|
+
seen_ids: set[str] = set()
|
|
197
|
+
id_to_rec: dict[str, StatRecord] = {} # [이수민 2026-05-14] metadata 머지용
|
|
198
|
+
|
|
199
|
+
def _add(recs: list[StatRecord]) -> None:
|
|
200
|
+
for r in recs:
|
|
201
|
+
if r.stat_id not in seen_ids:
|
|
202
|
+
results.append(r)
|
|
203
|
+
seen_ids.add(r.stat_id)
|
|
204
|
+
id_to_rec[r.stat_id] = r
|
|
205
|
+
else:
|
|
206
|
+
# [이수민 2026-05-14] 같은 stat_id 중복: metadata 머지
|
|
207
|
+
# KOSIS API 결과(먼저)는 category_path 없고 pgvector 결과(나중)는 있음
|
|
208
|
+
# → category_path를 KOSIS API 결과에 채워줌
|
|
209
|
+
existing = id_to_rec.get(r.stat_id)
|
|
210
|
+
if existing and not existing.metadata.get("category_path"):
|
|
211
|
+
cp = r.metadata.get("category_path")
|
|
212
|
+
if cp:
|
|
213
|
+
existing.metadata["category_path"] = cp
|
|
214
|
+
|
|
215
|
+
# 1) KOSIS 통합검색
|
|
216
|
+
kosis_recs = await self._search_kosis_api(search_kw, max_results=top_k)
|
|
217
|
+
_add(kosis_recs)
|
|
218
|
+
|
|
219
|
+
# 2+3) pgvector 검색
|
|
220
|
+
embedding_text = (query.extra_params or {}).get("embedding_text") or (
|
|
221
|
+
" ".join(filter(None, [query.indicator, query.population, search_kw]))
|
|
222
|
+
)
|
|
223
|
+
embedding = await self._get_embedding(embedding_text)
|
|
224
|
+
|
|
225
|
+
if embedding:
|
|
226
|
+
# 2) category 필터 + embedding
|
|
227
|
+
if category_kws:
|
|
228
|
+
cat_recs = await self._search_pgvector(
|
|
229
|
+
embedding,
|
|
230
|
+
category_keywords=category_kws,
|
|
231
|
+
top_k=top_k,
|
|
232
|
+
)
|
|
233
|
+
_add(cat_recs)
|
|
234
|
+
|
|
235
|
+
# 3) 전체 embedding (폴백)
|
|
236
|
+
# [P29' 2026-05-22] top_k 5 → 15. category 필터(2)가 KOSIS 자체 어휘랑
|
|
237
|
+
# 안 맞으면 (예: LLM이 자유어 '의료기기' 추출했는데 KOSIS category_path는
|
|
238
|
+
# '보건 > 의료자원 > 의료장비') (2)가 0건이라 정답이 (3)에 의존하는데, 그
|
|
239
|
+
# (3)이 top_k=5라 cosine 6~15위인 정답 표가 잘림. row-level keyword가
|
|
240
|
+
# 표 이름에 없는 케이스(예: "체외 충격파 쇄석술 장비")는 점수가 어차피
|
|
241
|
+
# 0.5~0.7 수준이라 더 많이 받아야 정답 진입.
|
|
242
|
+
all_recs = await self._search_pgvector(embedding, category_keywords=None, top_k=15)
|
|
243
|
+
_add(all_recs)
|
|
244
|
+
|
|
245
|
+
# 4) [2026-05-27 Fix B] time-aware union — 시점 토큰을 쿼리에 추가해 historical 표 boost
|
|
246
|
+
# 배경: catalog 임베딩에는 *category_path 안의 연도 토큰*만 있고
|
|
247
|
+
# available_periods 메타데이터는 비어있음. 그래서 historical claim
|
|
248
|
+
# (예: '1991 수상운송업 수익')에 query='수상운송업 수익 한국 전체 운송업'
|
|
249
|
+
# 만 보내면 modern 표가 cosine 1~10위 점유 → 정답 historical 표
|
|
250
|
+
# (DT_1IA2075 — category_path에 "1991:6차산업분류기준" 포함)가 surface
|
|
251
|
+
# 못 함.
|
|
252
|
+
# 해결 (4-a): 시점 토큰(YYYY)을 쿼리에 추가한 *두 번째* 임베딩 검색.
|
|
253
|
+
# 해결 (4-b): category_path ILIKE '%YYYY%' SQL 필터 + 동일 임베딩으로 정렬.
|
|
254
|
+
# 임베딩 단독으론 historical 표가 rank ~285 (sim 0.55)까지 깊이 묻혀
|
|
255
|
+
# surface 못 하지만, category_path에 explicit "1991" 토큰이 있는 표는
|
|
256
|
+
# ILIKE로 필터 후 임베딩 sim 정렬 → top K 안에 진입.
|
|
257
|
+
# 회귀 없음 — 기존 검색 결과는 그대로 살아있고 *추가* 후보만 보강.
|
|
258
|
+
_tp = getattr(query, "time_period", None) or (
|
|
259
|
+
(query.extra_params or {}).get("time_period")
|
|
260
|
+
)
|
|
261
|
+
if _tp:
|
|
262
|
+
_year_m = re.search(r"(?:19|20)\d{2}", str(_tp))
|
|
263
|
+
if _year_m:
|
|
264
|
+
_year = _year_m.group(0)
|
|
265
|
+
# (4-a) 시점 토큰 augment 후 임베딩 재검색
|
|
266
|
+
_time_text = f"{embedding_text} {_year}"
|
|
267
|
+
_time_emb = await self._get_embedding(_time_text)
|
|
268
|
+
if _time_emb:
|
|
269
|
+
_before_cnt = len(results)
|
|
270
|
+
_time_recs = await self._search_pgvector(
|
|
271
|
+
_time_emb, category_keywords=None, top_k=15,
|
|
272
|
+
)
|
|
273
|
+
_add(_time_recs)
|
|
274
|
+
_added_a = len(results) - _before_cnt
|
|
275
|
+
# (4-b) category_path ILIKE '%YYYY%' + 임베딩 sort
|
|
276
|
+
_before_cnt = len(results)
|
|
277
|
+
_year_recs = await self._search_pgvector(
|
|
278
|
+
embedding, category_keywords=[_year], top_k=15,
|
|
279
|
+
)
|
|
280
|
+
_add(_year_recs)
|
|
281
|
+
_added_b = len(results) - _before_cnt
|
|
282
|
+
logger.info(
|
|
283
|
+
f"CatalogSearch time-aware union (year={_year}): "
|
|
284
|
+
f"emb_aug={len(_time_recs)}→{_added_a} new, "
|
|
285
|
+
f"path_ilike={len(_year_recs)}→{_added_b} new"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# [v6.21] 수록주기 인지 재정렬 — claim이 월/분기 시점이면
|
|
289
|
+
# 월간 데이터가 있는 표를 상위로. 연 단위 표만 상위에 오면
|
|
290
|
+
# fetch가 모두 '표 시점 단위 불일치'로 거부돼 검증 불가가 된다.
|
|
291
|
+
claim_unit = _claim_period_unit(getattr(query, "time_period", None))
|
|
292
|
+
if claim_unit in ("month", "quarter"):
|
|
293
|
+
before = [r.stat_id for r in results[:3]]
|
|
294
|
+
results = _rerank_by_periodicity(results, claim_unit)
|
|
295
|
+
after = [r.stat_id for r in results[:3]]
|
|
296
|
+
if before != after:
|
|
297
|
+
logger.info(
|
|
298
|
+
f"CatalogSearch 수록주기 재정렬 (claim={claim_unit}): "
|
|
299
|
+
f"top3 {before} → {after}"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
logger.info(f"CatalogSearch 완료: {len(results)}개 후보")
|
|
303
|
+
return results[:top_k]
|
|
304
|
+
|
|
305
|
+
# ── 카테고리/검색어 추출 ────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
async def _extract_category_and_keyword(
|
|
308
|
+
self, query: ConnectorQuery
|
|
309
|
+
) -> tuple[list[str], str]:
|
|
310
|
+
"""
|
|
311
|
+
ConnectorQuery → (category_keywords, search_keyword) 추출.
|
|
312
|
+
|
|
313
|
+
[v6.11] schema에 parent_path가 있으면 *LLM 호출 없이* 그대로 분해.
|
|
314
|
+
없을 때만 LLM 호출 (박재유 방식의 fallback).
|
|
315
|
+
|
|
316
|
+
· 4자리 연도 제거
|
|
317
|
+
· 공백 정리
|
|
318
|
+
"""
|
|
319
|
+
# [2026-05-26 회귀] 옵션 B(parent_path + LLM union)가 LLM 자유 추출의 노이즈
|
|
320
|
+
# 때문에 catalog 결과 망가뜨림 — 옛 동작(parent_path 우선, LLM은 fallback만)으로
|
|
321
|
+
# 되돌림. parent_path 있으면 그대로 분해해 사용하고 LLM 호출 안 함.
|
|
322
|
+
parent_path = (query.extra_params or {}).get("parent_path")
|
|
323
|
+
if parent_path:
|
|
324
|
+
parts = [p.strip() for p in re.split(r"\s*>\s*", parent_path) if p.strip()]
|
|
325
|
+
if parts:
|
|
326
|
+
search_kw = _minimal_clean(parts[-1])
|
|
327
|
+
_raw_cats = parts[:-1] if len(parts) > 1 else parts
|
|
328
|
+
category_kws = [
|
|
329
|
+
_minimal_clean(c) for c in _raw_cats if _minimal_clean(c)
|
|
330
|
+
]
|
|
331
|
+
if search_kw:
|
|
332
|
+
logger.debug(f"parent_path 활용: category={category_kws}, kw={search_kw}")
|
|
333
|
+
return (category_kws, search_kw)
|
|
334
|
+
|
|
335
|
+
# parent_path 없을 때만 LLM 호출 (fallback)
|
|
336
|
+
path_category_kws: list[str] = []
|
|
337
|
+
path_search_kw = ""
|
|
338
|
+
llm_category_kws: list[str] = []
|
|
339
|
+
llm_search_kw = ""
|
|
340
|
+
if self.hcx_key:
|
|
341
|
+
|
|
342
|
+
raw_claim = (query.extra_params or {}).get("raw_claim", "")
|
|
343
|
+
prompt = _CATEGORY_EXTRACT_PROMPT.format(
|
|
344
|
+
indicator=query.indicator or "",
|
|
345
|
+
population=query.population or "",
|
|
346
|
+
claim_text=raw_claim[:200] or query.keyword,
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
350
|
+
resp = await client.post(
|
|
351
|
+
"https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX-DASH-002",
|
|
352
|
+
headers={
|
|
353
|
+
"Authorization": f"Bearer {self.hcx_key}",
|
|
354
|
+
"Content-Type": "application/json",
|
|
355
|
+
},
|
|
356
|
+
json={
|
|
357
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
358
|
+
"maxTokens": 80,
|
|
359
|
+
"temperature": 0,
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
content = resp.json()["result"]["message"]["content"].strip()
|
|
363
|
+
for line in content.split("\n"):
|
|
364
|
+
line = line.strip()
|
|
365
|
+
if "카테고리" in line and ":" in line:
|
|
366
|
+
cats = line.split(":", 1)[1].strip()
|
|
367
|
+
llm_category_kws = [
|
|
368
|
+
_minimal_clean(c) for c in cats.split(",")
|
|
369
|
+
if _minimal_clean(c)
|
|
370
|
+
]
|
|
371
|
+
elif "검색어" in line and ":" in line:
|
|
372
|
+
kw = line.split(":", 1)[1].strip().strip("\"'")
|
|
373
|
+
kw = _minimal_clean(kw)
|
|
374
|
+
if kw:
|
|
375
|
+
llm_search_kw = kw
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.debug(f"카테고리 LLM 추출 실패 (path category로 폴백): {e}")
|
|
378
|
+
|
|
379
|
+
# 3) union 결합 — path 우선, LLM은 보강용
|
|
380
|
+
# [2026-05-26 보정] LLM 자유 추출이 가끔 쉼표 풀어쓴 긴 문자열(예: '체외충격파
|
|
381
|
+
# 쇄석술장비수, 강원도의료기기현황')을 search_kw로 박거나, KOSIS 분야와 어긋난
|
|
382
|
+
# 카테고리(예: '의료기기', '지역통계')를 만들어서 catalog 검색이 완전 무관한
|
|
383
|
+
# 표(장애인 거주시설, 노숙인 시설 등)를 잡아오는 사고 발생. 그래서:
|
|
384
|
+
# - search_kw: path_search_kw 우선 (단순 단어), LLM은 fallback 또는 단어 1개
|
|
385
|
+
# - category: path + LLM 한 단어짜리만 union (긴 풀어쓰기 거부)
|
|
386
|
+
|
|
387
|
+
def _is_clean_token(s: str) -> bool:
|
|
388
|
+
"""검색어/카테고리로 안전한 단순 토큰인지. 쉼표/공백 6자 이상 + 너무 길면 거부."""
|
|
389
|
+
if not s:
|
|
390
|
+
return False
|
|
391
|
+
if "," in s or "、" in s:
|
|
392
|
+
return False # 쉼표 풀어쓰기는 의도 모호
|
|
393
|
+
if len(s) > 20:
|
|
394
|
+
return False # 너무 긴 자유 텍스트는 검색 노이즈
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
# category: path가 우선, LLM은 *깨끗한 토큰*만 추가
|
|
398
|
+
_clean_llm_cats = [c for c in llm_category_kws if _is_clean_token(c)]
|
|
399
|
+
category_keywords: list[str] = list(dict.fromkeys(
|
|
400
|
+
[*path_category_kws, *_clean_llm_cats]
|
|
401
|
+
))[:5]
|
|
402
|
+
|
|
403
|
+
# search_kw: path 우선, LLM은 깨끗할 때만 fallback
|
|
404
|
+
if path_search_kw and _is_clean_token(path_search_kw):
|
|
405
|
+
search_keyword = path_search_kw
|
|
406
|
+
elif llm_search_kw and _is_clean_token(llm_search_kw):
|
|
407
|
+
search_keyword = llm_search_kw
|
|
408
|
+
else:
|
|
409
|
+
search_keyword = _minimal_clean(query.indicator or query.keyword or "")
|
|
410
|
+
return (category_keywords, search_keyword)
|
|
411
|
+
|
|
412
|
+
# ── HCX 임베딩 생성 ──────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
async def _get_embedding(self, text: str) -> list[float] | None:
|
|
415
|
+
"""텍스트 → 임베딩 벡터. 공용 EmbeddingClient 사용 (#67-D A-1)."""
|
|
416
|
+
return await self._embedder.embed(text)
|
|
417
|
+
|
|
418
|
+
# ── KOSIS 통합검색 ────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
async def _search_kosis_api(
|
|
421
|
+
self, keyword: str, max_results: int = 5
|
|
422
|
+
) -> list[StatRecord]:
|
|
423
|
+
"""KOSIS statisticsSearch.do — vwCd=MT_ZTITLE (국가통계만)"""
|
|
424
|
+
if not self.api_key or not keyword.strip():
|
|
425
|
+
return []
|
|
426
|
+
try:
|
|
427
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
428
|
+
resp = await client.get(
|
|
429
|
+
"https://kosis.kr/openapi/statisticsSearch.do",
|
|
430
|
+
params={
|
|
431
|
+
"method": "getList",
|
|
432
|
+
"apiKey": self.api_key,
|
|
433
|
+
"searchNm": keyword,
|
|
434
|
+
"format": "json",
|
|
435
|
+
"jsonVD": "Y",
|
|
436
|
+
"resultCount": max_results,
|
|
437
|
+
"sort": "RANK",
|
|
438
|
+
"vwCd": _KOSIS_SEARCH_VW_CD, # 국가통계만
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
data = resp.json()
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.debug(f"KOSIS 통합검색 실패: {e}")
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
if isinstance(data, dict) and ("err" in data or "errMsg" in data):
|
|
447
|
+
return []
|
|
448
|
+
if not isinstance(data, list):
|
|
449
|
+
data = [data] if isinstance(data, dict) and "TBL_ID" in data else []
|
|
450
|
+
|
|
451
|
+
records = []
|
|
452
|
+
n = len(data)
|
|
453
|
+
for i, item in enumerate(data):
|
|
454
|
+
tid = (item.get("TBL_ID") or "").strip()
|
|
455
|
+
if not tid or not item.get("ORG_ID"):
|
|
456
|
+
continue
|
|
457
|
+
rel = 1.0 if n <= 1 else max(0.05, 1.0 - (i / (n - 1)) * 0.95)
|
|
458
|
+
records.append(StatRecord(
|
|
459
|
+
stat_id=tid,
|
|
460
|
+
stat_name=item.get("TBL_NM", ""),
|
|
461
|
+
org_id=item.get("ORG_ID"),
|
|
462
|
+
org_name=item.get("ORG_NM"),
|
|
463
|
+
relevance_score=rel,
|
|
464
|
+
metadata={"source": "kosis_api", **item},
|
|
465
|
+
))
|
|
466
|
+
|
|
467
|
+
logger.debug(f"KOSIS API 검색: {len(records)}개")
|
|
468
|
+
return records
|
|
469
|
+
|
|
470
|
+
# ── pgvector 검색 ─────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
async def _search_pgvector(
|
|
473
|
+
self,
|
|
474
|
+
embedding: list[float],
|
|
475
|
+
category_keywords: list[str] | None = None,
|
|
476
|
+
top_k: int = 5,
|
|
477
|
+
) -> list[StatRecord]:
|
|
478
|
+
"""
|
|
479
|
+
kosis_stat_catalog pgvector 유사도 검색.
|
|
480
|
+
|
|
481
|
+
category_keywords 있으면: category_path ILIKE 필터 + embedding 정렬
|
|
482
|
+
없으면: 전체 embedding 정렬
|
|
483
|
+
"""
|
|
484
|
+
try:
|
|
485
|
+
import asyncpg
|
|
486
|
+
except ImportError:
|
|
487
|
+
logger.debug("asyncpg 미설치 → pgvector 검색 skip")
|
|
488
|
+
return []
|
|
489
|
+
|
|
490
|
+
vector_str = "[" + ",".join(str(v) for v in embedding) + "]"
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
conn = await asyncpg.connect(self.pg_dsn)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.debug(f"pgvector 연결 실패: {e}")
|
|
496
|
+
return []
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
if category_keywords:
|
|
500
|
+
# category_path ILIKE 필터 + embedding 거리 정렬
|
|
501
|
+
where_parts = [f"category_path ILIKE ${i+2}" for i in range(len(category_keywords))]
|
|
502
|
+
where_sql = " OR ".join(where_parts)
|
|
503
|
+
params = [vector_str] + [f"%{kw}%" for kw in category_keywords] + [top_k]
|
|
504
|
+
sql = f"""
|
|
505
|
+
SELECT stat_id, stat_name, org_id, org_name, category_path, keywords,
|
|
506
|
+
1 - (embedding <-> $1::vector) AS similarity
|
|
507
|
+
FROM kosis_stat_catalog
|
|
508
|
+
WHERE ({where_sql})
|
|
509
|
+
AND embedding IS NOT NULL
|
|
510
|
+
ORDER BY embedding <-> $1::vector
|
|
511
|
+
LIMIT ${len(params)}
|
|
512
|
+
"""
|
|
513
|
+
else:
|
|
514
|
+
params = [vector_str, top_k]
|
|
515
|
+
sql = """
|
|
516
|
+
SELECT stat_id, stat_name, org_id, org_name, category_path, keywords,
|
|
517
|
+
1 - (embedding <-> $1::vector) AS similarity
|
|
518
|
+
FROM kosis_stat_catalog
|
|
519
|
+
WHERE embedding IS NOT NULL
|
|
520
|
+
ORDER BY embedding <-> $1::vector
|
|
521
|
+
LIMIT $2
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
rows = await conn.fetch(sql, *params)
|
|
525
|
+
await conn.close()
|
|
526
|
+
|
|
527
|
+
records = []
|
|
528
|
+
for row in rows:
|
|
529
|
+
# periods = row["available_periods"] or []
|
|
530
|
+
# if isinstance(periods, str):
|
|
531
|
+
# try:
|
|
532
|
+
# periods = json.loads(periods)
|
|
533
|
+
# except Exception:
|
|
534
|
+
# periods = []
|
|
535
|
+
sim = float(row.get("similarity") or 0.0)
|
|
536
|
+
records.append(StatRecord(
|
|
537
|
+
stat_id=row["stat_id"],
|
|
538
|
+
stat_name=row["stat_name"],
|
|
539
|
+
org_id=row["org_id"],
|
|
540
|
+
org_name=row["org_name"],
|
|
541
|
+
available_periods=[],
|
|
542
|
+
relevance_score=max(0.0, sim),
|
|
543
|
+
metadata={
|
|
544
|
+
"source": "pgvector",
|
|
545
|
+
"category_path": row.get("category_path"),
|
|
546
|
+
"keywords": row.get("keywords"),
|
|
547
|
+
"similarity": sim,
|
|
548
|
+
},
|
|
549
|
+
))
|
|
550
|
+
|
|
551
|
+
label = f"(category={category_keywords})" if category_keywords else "(전체)"
|
|
552
|
+
logger.debug(f"pgvector 검색 {label}: {len(records)}개")
|
|
553
|
+
return records
|
|
554
|
+
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.warning(f"pgvector 검색 실패: {e}")
|
|
557
|
+
try:
|
|
558
|
+
await conn.close()
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# 룰베이스 stopword/action-word 매핑은 *제거*함 (사용자 원칙: LLM이 정제 책임).
|
|
565
|
+
# kw = re.sub(r'\b\d{4}\b', '', kw).strip()
|
|
566
|
+
# kw = re.sub(r'\s+', ' ', kw)
|
|
567
|
+
|
|
568
|
+
def _minimal_clean(kw: str) -> str:
|
|
569
|
+
"""LLM 응답에서 명백한 노이즈만 제거 (연도 + 마크다운 + 공백).
|
|
570
|
+
|
|
571
|
+
의미 정제는 LLM 책임이지만, planner LLM이 검색어/카테고리에
|
|
572
|
+
마크다운 강조(**, *, `, #)를 섞어 출력하는 경우가 잦아
|
|
573
|
+
(예: '** 연평균 기온') 검색을 오염시키므로 여기서 제거한다.
|
|
574
|
+
"""
|
|
575
|
+
if not kw:
|
|
576
|
+
return ""
|
|
577
|
+
s = str(kw)
|
|
578
|
+
# 마크다운 강조/머리표 제거: **bold**, *italic*, `code`, # heading, - bullet
|
|
579
|
+
s = s.replace("*", "").replace("`", "").replace("#", "")
|
|
580
|
+
s = re.sub(r"^\s*[-•·]\s*", "", s) # 줄머리 불릿
|
|
581
|
+
s = re.sub(r"\b\d{4}\b", "", s).strip()
|
|
582
|
+
s = re.sub(r"\s+", " ", s)
|
|
583
|
+
return s.strip(",·\"' -")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""structverify.retrieval.chunking — 문서형(광범위) 데이터를 임베딩용 청크로 분할.
|
|
2
|
+
|
|
3
|
+
사규·규정·계약 등 긴 자연어 문서를 의미 단위(문단/조항)로 자르고, 너무 길면 size로
|
|
4
|
+
슬라이딩 윈도우 분할한다. 각 청크가 임베딩·검색의 단위가 된다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_document(path: str) -> str:
|
|
12
|
+
"""파일 경로 → 텍스트. .pdf는 PyMuPDF(fitz)로 페이지 텍스트 추출, 그 외는 utf-8.
|
|
13
|
+
|
|
14
|
+
custom_docs·indexing agent 공용 — CSV/문서/ PDF 어떤 정답 데이터든 텍스트로.
|
|
15
|
+
"""
|
|
16
|
+
if path.lower().endswith(".pdf"):
|
|
17
|
+
# pdfplumber 우선 — 한글 띄어쓰기 보존 (fitz는 정부 고시 PDF에서 공백을 자주 제거).
|
|
18
|
+
try:
|
|
19
|
+
import pdfplumber
|
|
20
|
+
with pdfplumber.open(path) as pdf:
|
|
21
|
+
text = "\n\n".join((pg.extract_text() or "") for pg in pdf.pages)
|
|
22
|
+
if text.strip():
|
|
23
|
+
return text
|
|
24
|
+
except Exception: # noqa: BLE001
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
import fitz # PyMuPDF 폴백
|
|
28
|
+
with fitz.open(path) as doc:
|
|
29
|
+
return "\n\n".join(p.get_text("text") for p in doc)
|
|
30
|
+
except Exception: # noqa: BLE001
|
|
31
|
+
return ""
|
|
32
|
+
try:
|
|
33
|
+
with open(path, encoding="utf-8") as f:
|
|
34
|
+
return f.read()
|
|
35
|
+
except Exception: # noqa: BLE001
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def chunk_text(
|
|
40
|
+
text: str,
|
|
41
|
+
*,
|
|
42
|
+
chunk_size: int = 400,
|
|
43
|
+
overlap: int = 60,
|
|
44
|
+
) -> list[str]:
|
|
45
|
+
"""text → 청크 리스트.
|
|
46
|
+
|
|
47
|
+
1) 빈 줄(문단) / 조항 마커(제N조, N., -)로 1차 분할
|
|
48
|
+
2) 인접 조각을 chunk_size 이내로 병합
|
|
49
|
+
3) 단일 조각이 chunk_size를 넘으면 overlap 슬라이딩으로 분할
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
chunk_size: 청크 최대 글자수
|
|
53
|
+
overlap: 긴 조각 분할 시 겹치는 글자수(맥락 보존)
|
|
54
|
+
"""
|
|
55
|
+
if not text or not text.strip():
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
# 1) 문단/조항 경계로 1차 분할.
|
|
59
|
+
# 조항 마커(제N조)는 *줄바꿈과 무관하게* 경계로 — PDF 추출 시 줄바꿈이 조 중간에
|
|
60
|
+
# 끼어도 조가 안 깨지게. 각 조각은 공백/줄바꿈 정규화(PDF 줄나눔 아티팩트 제거).
|
|
61
|
+
raw = re.split(r"(?=제\s*\d+\s*조)|\n\s*\n|\n(?=\d+[.)])", text)
|
|
62
|
+
parts = [re.sub(r"\s+", " ", p).strip() for p in raw if p and p.strip()]
|
|
63
|
+
|
|
64
|
+
_article = re.compile(r"^제?\s*\d+\s*조") # 조항 마커로 시작하는 조각
|
|
65
|
+
chunks: list[str] = []
|
|
66
|
+
buf = ""
|
|
67
|
+
for p in parts:
|
|
68
|
+
is_article = bool(_article.match(p))
|
|
69
|
+
if len(p) > chunk_size:
|
|
70
|
+
# 긴 조각 → 먼저 buf flush 후 슬라이딩 분할
|
|
71
|
+
if buf:
|
|
72
|
+
chunks.append(buf)
|
|
73
|
+
buf = ""
|
|
74
|
+
step = max(1, chunk_size - overlap)
|
|
75
|
+
for i in range(0, len(p), step):
|
|
76
|
+
chunks.append(p[i:i + chunk_size].strip())
|
|
77
|
+
elif is_article:
|
|
78
|
+
# 조항(제N조)은 짧아도 *독립 청크* — 인접 조와 병합하지 않음(검색 정확도↑)
|
|
79
|
+
if buf:
|
|
80
|
+
chunks.append(buf)
|
|
81
|
+
buf = ""
|
|
82
|
+
chunks.append(p)
|
|
83
|
+
elif len(buf) + len(p) + 1 <= chunk_size:
|
|
84
|
+
buf = f"{buf}\n{p}".strip() if buf else p
|
|
85
|
+
else:
|
|
86
|
+
if buf:
|
|
87
|
+
chunks.append(buf)
|
|
88
|
+
buf = p
|
|
89
|
+
if buf:
|
|
90
|
+
chunks.append(buf)
|
|
91
|
+
|
|
92
|
+
return [c for c in chunks if c.strip()]
|