Compare commits

...

1 Commits

Author SHA1 Message Date
hyungi 686f78cc08 feat(safety): C-1 freshness — law_365d 폐기 + incident(재해사례) 흡수
★ranking 변경(의도 기록): freshness soft multiplier(floor 0.7) 정책 갱신.
- law_365d 폐기: 법령 현행성은 version_status(B-1 버전체인 current/superseded)가 처리.
  age-decay 는 current 법령을 부당 강등 → law_monitor/law 비적용으로 전환.
- incident 흡수(1행): material_type='incident'(KOSHA 재해사례/사망사고) → news_90d.
  시간 민감(최근 재해 가중), source_channel 무관(업로드 incident 포함).
- _DocMeta/_fetch_meta 에 material_type 추가(getattr 로 mock-safe).
테스트: law 3건(policy/decay/apply) 비적용 전환 + incident 2건 신규.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:23:04 +09:00
2 changed files with 58 additions and 27 deletions
+19 -14
View File
@@ -1,6 +1,6 @@
"""Time-aware retrieval freshness decay (PR-RAG-Time-1). """Time-aware retrieval freshness decay (PR-RAG-Time-1).
뉴스(source_channel='news') / 법령 알림(source_channel='law_monitor') 도메인은 뉴스(source_channel='news') / 재해사례(material_type='incident', KOSHA) 도메인은
시간이 중요한 문서. 단순 relevance score 만으로는 오래된 문서가 상위에 머물러 시간이 중요한 문서. 단순 relevance score 만으로는 오래된 문서가 상위에 머물러
검색 품질이 떨어짐. 본 모듈은 reranker 이후 final score 합성 단계에서 검색 품질이 떨어짐. 본 모듈은 reranker 이후 final score 합성 단계에서
soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 demote. soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 demote.
@@ -9,9 +9,10 @@ soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 de
- reranker = 의미 관련도, freshness decay = 운영 정책. 두 단계 분리 유지. - reranker = 의미 관련도, freshness decay = 운영 정책. 두 단계 분리 유지.
- floor 0.7 (multiplier 가 0.7 미만으로 안 떨어짐) — 오래되어도 죽지 않음. - floor 0.7 (multiplier 가 0.7 미만으로 안 떨어짐) — 오래되어도 죽지 않음.
- 일반 업로드 / 학습 자료 / KGS Code 원문 / ai_drafted 는 비적용 (no-op). - 일반 업로드 / 학습 자료 / KGS Code 원문 / ai_drafted 는 비적용 (no-op).
- ★법령(law)은 C-1 후속에서 freshness 제외 — 현행성은 version_status(B-1 버전체인)가 처리.
published_date 컬럼이 documents 에 없음 → created_at(수집 시점) 을 임시 proxy. published_date 컬럼이 documents 에 없음 → created_at(수집 시점) 을 임시 proxy.
news/law_monitor 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date. news/KOSHA 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date.
정확도 향상은 후속 PR (worker 가 published_date 메타 채우기) 로 분리. 정확도 향상은 후속 PR (worker 가 published_date 메타 채우기) 로 분리.
""" """
@@ -32,10 +33,10 @@ if TYPE_CHECKING:
# ─── Policy ──────────────────────────────────────────────────────── # ─── Policy ────────────────────────────────────────────────────────
# half-life (일). 90 일: 한 달 ~0.79 / 6개월 ~0.25. # half-life (일). 90 일: 한 달 ~0.79 / 6개월 ~0.25.
# 365 일: 1년 ~0.5 / 3년 ~0.13. # C-1 후속(2026-06-13): law_365d 폐기 — 법령 현행성은 version_status(B-1 버전체인)가 처리,
# age-decay 는 current 법령을 부당 강등(의도 변경 기록). 재해사례(incident)는 news_90d 흡수.
HALF_LIFE_DAYS: dict[str, int] = { HALF_LIFE_DAYS: dict[str, int] = {
"news_90d": 90, "news_90d": 90,
"law_365d": 365,
} }
# soft multiplier — final = base * (FLOOR + (1-FLOOR) * decay). # soft multiplier — final = base * (FLOOR + (1-FLOOR) * decay).
@@ -52,32 +53,35 @@ class _DocMeta:
source_channel: str | None source_channel: str | None
content_origin: str | None content_origin: str | None
created_at: datetime | None created_at: datetime | None
material_type: str | None = None
def freshness_policy(meta: _DocMeta | None) -> str | None: def freshness_policy(meta: _DocMeta | None) -> str | None:
"""문서 메타 → freshness 정책 이름 또는 None (no-op). """문서 메타 → freshness 정책 이름 또는 None (no-op).
적용: 적용:
- source_channel='news' → news_90d - material_type='incident' (KOSHA 재해사례/사망사고) → news_90d (C-1 후속 흡수, 시간 민감)
- source_channel='law_monitor' → law_365d - source_channel='news' → news_90d
비적용 (None 반환): 비적용 (None 반환):
- meta 자체가 None - meta 자체가 None
- content_origin='ai_drafted' (생성 시점 = 가치 시점, 시간 demote 부적합) - content_origin='ai_drafted' (생성 시점 = 가치 시점, 시간 demote 부적합)
- 그 외 모든 source_channel (manual, drive_sync, inbox_route, memo, - ★법령(source_channel='law_monitor'/material_type='law'): C-1 후속에서 law_365d 폐기.
Study/Manual/Reference/Academic/Checklist 류 — 자연 비적용) 법령 현행성은 version_status(B-1 버전체인 current/superseded)가 처리 — age-decay 는
current 법령을 부당 강등(의도 변경 기록). law 검색 ranking = version_status decorate.
- 그 외 모든 source_channel (manual, drive_sync, inbox_route, memo 등 — 자연 비적용)
""" """
if meta is None: if meta is None:
return None return None
# 가드 2: content_origin='ai_drafted' 비적용 # 가드 2: content_origin='ai_drafted' 비적용
if meta.content_origin == "ai_drafted": if meta.content_origin == "ai_drafted":
return None return None
sc = meta.source_channel # 재해사례/사망사고 = 시간 민감 → news 와 동일 90d (source 무관, 업로드 incident 도 포함)
if sc == "news": if meta.material_type == "incident":
return "news_90d" return "news_90d"
if sc == "law_monitor": if meta.source_channel == "news":
return "law_365d" return "news_90d"
# 가드 6: unknown source_channel → no decay # 법령 law_365d 폐기 + unknown source_channel → no decay
return None return None
@@ -129,7 +133,7 @@ async def _fetch_meta(
text( text(
""" """
SELECT id, source_channel::text AS source_channel, SELECT id, source_channel::text AS source_channel,
content_origin, created_at content_origin, material_type, created_at
FROM documents FROM documents
WHERE id = ANY(:ids) WHERE id = ANY(:ids)
""" """
@@ -141,6 +145,7 @@ async def _fetch_meta(
source_channel=row.source_channel, source_channel=row.source_channel,
content_origin=row.content_origin, content_origin=row.content_origin,
created_at=row.created_at, created_at=row.created_at,
material_type=getattr(row, "material_type", None),
) )
for row in rows for row in rows
} }
+39 -13
View File
@@ -37,7 +37,8 @@ from services.search.freshness_decay import (
NOW = datetime(2026, 5, 3, 12, 0, 0, tzinfo=timezone.utc) NOW = datetime(2026, 5, 3, 12, 0, 0, tzinfo=timezone.utc)
def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None) -> _DocMeta: def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None,
material_type: str | None = None) -> _DocMeta:
if days_ago is None: if days_ago is None:
created = None created = None
elif days_ago < 0: elif days_ago < 0:
@@ -45,7 +46,8 @@ def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | N
created = NOW + timedelta(days=-days_ago) created = NOW + timedelta(days=-days_ago)
else: else:
created = NOW - timedelta(days=days_ago) created = NOW - timedelta(days=days_ago)
return _DocMeta(source_channel=channel, content_origin=origin, created_at=created) return _DocMeta(source_channel=channel, content_origin=origin, created_at=created,
material_type=material_type)
# ─── policy dispatcher ──────────────────────────────────────────── # ─── policy dispatcher ────────────────────────────────────────────
@@ -55,8 +57,15 @@ def test_policy_news():
assert freshness_policy(_meta("news")) == "news_90d" assert freshness_policy(_meta("news")) == "news_90d"
def test_policy_law_monitor(): def test_policy_law_monitor_now_unaffected():
assert freshness_policy(_meta("law_monitor")) == "law_365d" # C-1 후속: law_365d 폐기 → law_monitor 비적용 (현행성은 version_status 가 처리)
assert freshness_policy(_meta("law_monitor")) is None
def test_policy_incident():
# C-1 후속: 재해사례/사망사고(material_type='incident') → news_90d 흡수 (source 무관)
assert freshness_policy(_meta("crawl", material_type="incident")) == "news_90d"
assert freshness_policy(_meta("inbox_route", material_type="incident")) == "news_90d"
def test_policy_manual_unaffected(): def test_policy_manual_unaffected():
@@ -123,8 +132,9 @@ def test_decay_at_half_life_news():
assert compute_decay(90.0, "news_90d") == pytest.approx(0.5, rel=1e-6) assert compute_decay(90.0, "news_90d") == pytest.approx(0.5, rel=1e-6)
def test_decay_at_half_life_law(): def test_decay_law_365d_removed_returns_one():
assert compute_decay(365.0, "law_365d") == pytest.approx(0.5, rel=1e-6) # C-1 후속: law_365d 폐기 → HALF_LIFE_DAYS 미등록 policy → decay 1.0 (no-op)
assert compute_decay(365.0, "law_365d") == 1.0
def test_decay_age_zero_full(): def test_decay_age_zero_full():
@@ -212,22 +222,38 @@ async def test_apply_news_recent_vs_old_recent_higher():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_apply_law_monitor_recent_vs_old_recent_higher(): async def test_apply_law_monitor_now_unaffected():
# 가드 2: law_monitor recent 가 위 # C-1 후속: law_monitor freshness 폐기 → recent/old 동일 score (재정렬 없음)
base = 0.50 base = 0.50
rows = [ rows = [
{"id": 1, "source_channel": "law_monitor", "content_origin": "extracted", {"id": 1, "source_channel": "law_monitor", "content_origin": "extracted",
"created_at": NOW - timedelta(days=10)}, "material_type": "law", "created_at": NOW - timedelta(days=10)},
{"id": 2, "source_channel": "law_monitor", "content_origin": "extracted", {"id": 2, "source_channel": "law_monitor", "content_origin": "extracted",
"created_at": NOW - timedelta(days=730)}, # 2년 "material_type": "law", "created_at": NOW - timedelta(days=730)},
]
session = _MockSession(rows)
results = [_result(1, base), _result(2, base)]
out = await apply_freshness_decay(results, session, now=NOW)
assert out[0].score == base and out[1].score == base
assert out[0].freshness_debug["freshness_policy"] is None
@pytest.mark.asyncio
async def test_apply_incident_recent_vs_old_recent_higher():
# C-1 후속: 재해사례(incident) recent 가 위 (news_90d 흡수, source_channel='crawl')
base = 0.50
rows = [
{"id": 1, "source_channel": "crawl", "content_origin": "extracted",
"material_type": "incident", "created_at": NOW - timedelta(days=5)},
{"id": 2, "source_channel": "crawl", "content_origin": "extracted",
"material_type": "incident", "created_at": NOW - timedelta(days=400)},
] ]
session = _MockSession(rows) session = _MockSession(rows)
results = [_result(1, base), _result(2, base)] results = [_result(1, base), _result(2, base)]
out = await apply_freshness_decay(results, session, now=NOW) out = await apply_freshness_decay(results, session, now=NOW)
assert out[0].id == 1 assert out[0].id == 1
assert out[0].freshness_debug["freshness_policy"] == "law_365d" assert out[0].score > out[1].score
# 2년 → law_365d 반감기 1년 → decay ~0.25 → multiplier ~ 0.775 assert out[0].freshness_debug["freshness_policy"] == "news_90d"
assert out[1].score < out[0].score
@pytest.mark.asyncio @pytest.mark.asyncio