feat: query life writing at segment level

생활수기는 문서 단위로만 검사되고 있었다. 코퍼스에는 1694개 세그먼트가
적재돼 있는데 질의는 12개 문서를 통째로 던지는 방식이어서, 한 대목이
겹쳐도 글 전체 길이에 희석돼 유사도가 0.28~0.33 에 머물렀다. 그 상태의
미탐지 0건은 근거가 되지 못한다.

--life-writing-segments 를 주면 자서전과 같은 세그먼트 단위로 펼친다.
검사 대상 7786건(자서전 6092 + 생활수기 1694) 으로 재실행한 결과 생활수기
침해의심은 0건이다. 다만 이번 0건은 근거가 있다. 결합유사도 최대 0.5745,
평균 0.3677 로 임계값 0.65 에 못 미치고 0.6 이상도 없다. 연속일치·커버리지
조건을 충족한 건도 없다.

자서전 결과는 106건으로 변화 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-04 14:40:53 +09:00
parent 4e650d59ad
commit 6b27fff509
3 changed files with 7808 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,7 +40,7 @@ def build_or_load(store, index_dir: Path, model_name: str, device: str, batch: i
return vectors, meta, model return vectors, meta, model
def main(): def main():
p=argparse.ArgumentParser(); p.add_argument("--database",type=Path,required=True); p.add_argument("--index-dir",type=Path,required=True); p.add_argument("--out-jsonl",type=Path,required=True); p.add_argument("--out-xlsx",type=Path,required=True); p.add_argument("--first-jsonl",type=Path,required=True); p.add_argument("--model",default="BM-K/KoSimCSE-roberta-multitask"); p.add_argument("--batch-size",type=int,default=64); p.add_argument("--progress-every",type=int,default=25); p.add_argument("--exclude-segments",type=Path,help="검사·색인에서 뺄 segment_id 목록 파일 (중복 제거용)"); p.add_argument("--min-exact-span",type=int,help="연속 일치 판정 기준(공백 포함 글자 수). 미지정 시 설정값"); a=p.parse_args() p=argparse.ArgumentParser(); p.add_argument("--database",type=Path,required=True); p.add_argument("--index-dir",type=Path,required=True); p.add_argument("--out-jsonl",type=Path,required=True); p.add_argument("--out-xlsx",type=Path,required=True); p.add_argument("--first-jsonl",type=Path,required=True); p.add_argument("--model",default="BM-K/KoSimCSE-roberta-multitask"); p.add_argument("--batch-size",type=int,default=64); p.add_argument("--progress-every",type=int,default=25); p.add_argument("--exclude-segments",type=Path,help="검사·색인에서 뺄 segment_id 목록 파일 (중복 제거용)"); p.add_argument("--min-exact-span",type=int,help="연속 일치 판정 기준(공백 포함 글자 수). 미지정 시 설정값"); p.add_argument("--life-writing-segments",action="store_true",help="생활수기도 자서전과 같이 세그먼트 단위로 검사"); a=p.parse_args()
logging.basicConfig(level=logging.INFO,format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO,format="%(asctime)s %(levelname)s %(message)s")
if os.getenv("USE_LLM_LEGAL_JUDGE", "").lower() not in ("", "0", "false", "no", "off"): raise RuntimeError("USE_LLM_LEGAL_JUDGE=false required") if os.getenv("USE_LLM_LEGAL_JUDGE", "").lower() not in ("", "0", "false", "no", "off"): raise RuntimeError("USE_LLM_LEGAL_JUDGE=false required")
import torch import torch
@ -54,7 +54,7 @@ def main():
if a.min_exact_span: if a.min_exact_span:
settings=settings.model_copy(update={"persistent_min_exact_span":a.min_exact_span}) settings=settings.model_copy(update={"persistent_min_exact_span":a.min_exact_span})
LOG.info("판정 기준: 유사도>=%.2f | 연속일치>=%d자 | 커버리지>=%.2f",SCORE_MIN,span_min,COVERAGE_MIN) LOG.info("판정 기준: 유사도>=%.2f | 연속일치>=%d자 | 커버리지>=%.2f",SCORE_MIN,span_min,COVERAGE_MIN)
detector=PlagiarismDetector(settings); groups=store.document_source_groups(); records=store.get_segments(meta["segment_ids"]); items=build_query_plan(store) detector=PlagiarismDetector(settings); groups=store.document_source_groups(); records=store.get_segments(meta["segment_ids"]); items=build_query_plan(store,life_writing_segments=a.life_writing_segments)
# 중복 제거: 검사 대상과 색인 후보에서 동시에 뺀다. 한쪽만 빼면 지운 원고가 # 중복 제거: 검사 대상과 색인 후보에서 동시에 뺀다. 한쪽만 빼면 지운 원고가
# 여전히 상대로 잡혀 중복이 결과에 남는다. # 여전히 상대로 잡혀 중복이 결과에 남는다.
if a.exclude_segments: if a.exclude_segments:

View File

@ -59,7 +59,13 @@ def _reconstruct_document(segments: list[SegmentRecord]) -> str:
return output return output
def build_query_plan(store: CorpusStore) -> list[QueryItem]: def build_query_plan(store: CorpusStore, *, life_writing_segments: bool = False) -> list[QueryItem]:
"""검사 대상 목록을 만든다.
생활수기는 기본이 문서 단위다. 다만 문서 전체를 번에 비교하면 대목이
통째로 겹쳐도 전체 길이에 희석되어 잡히지 않는다. 자서전과 같은 조건으로
검사하려면 life_writing_segments 세그먼트 단위로 펼친다.
"""
documents = {record.document_id: record for record in store.documents_with_metadata()} documents = {record.document_id: record for record in store.documents_with_metadata()}
grouped: dict[str, list[SegmentRecord]] = defaultdict(list) grouped: dict[str, list[SegmentRecord]] = defaultdict(list)
for segment in store.iter_segments(): for segment in store.iter_segments():
@ -73,6 +79,19 @@ def build_query_plan(store: CorpusStore) -> list[QueryItem]:
).strip() ).strip()
source_kind = str(metadata.get("corpus_kind") or metadata.get("provenance") or "unknown") source_kind = str(metadata.get("corpus_kind") or metadata.get("provenance") or "unknown")
segments = grouped.get(document_id, []) segments = grouped.get(document_id, [])
if source_kind == "life_writing" and life_writing_segments:
for segment in segments:
if segment.text.strip():
items.append(QueryItem(
query_key=segment.segment_id,
document_id=document_id,
title=document.title,
source_group=source_group,
source_kind="life_writing",
text=segment.text,
source_segment_id=segment.segment_id,
))
continue
if source_kind == "life_writing": if source_kind == "life_writing":
text = _reconstruct_document(segments).strip() text = _reconstruct_document(segments).strip()
if text: if text: