feat: sweep exact-span threshold from 3 to 9 eojeol

상무님 요청대로 어절 기준을 3~9로 바꿔가며 재판정한다. 기준값은 검색
후보를 바꾸지 않고 채택 여부만 정하므로, 후보 판정재료를 한 번 남겨두면
7회 실행 없이 오프라인에서 전부 계산할 수 있다. 27자로 재계산한 값이
실제 실행 결과 85건과 일치해 방식을 검증했다.

배치 스크립트에 하드코딩돼 있던 80 을 걷어낸다. --min-exact-span 을 줘도
이 줄이 설정을 읽지 않아 값이 적용되지 않았다.

겹침률 측정도 바로잡는다. 앞서 7어절 0% 로 봤던 것은 4만 쌍에서 히트가
0~1건이라 해상도가 없었고, 그 1건조차 같은 문서 내부의 반복이었다.
15만 쌍으로 늘리고 엔진과 같이 자기 문서 쌍을 빼면 9어절에서도 4.0% 다.
겹침이 사라지는 지점은 없으므로 '오탐 0 인 최저점' 논리는 성립하지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-03 13:27:10 +09:00
parent f4fab1089e
commit bf3bd5b99b
4 changed files with 6172 additions and 7 deletions

File diff suppressed because it is too large Load Diff

View File

@ -389,6 +389,66 @@ def build_duplicate_sheet(wb: Workbook, excerpts: list[dict], records: dict[str,
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
def build_sweep_sheet(wb: Workbook, sweep: list[dict], rows: list[dict]) -> None:
"""어절 기준을 3~9로 바꿔가며 재판정한 결과.
기준값은 검색 후보를 바꾸지 않고 채택 여부만 정하므로, 실행해 남긴
후보 판정재료로 모든 기준을 다시 계산할 있다.
"""
sheet = wb.create_sheet("어절 기준 비교")
sheet.append(["어절 기준별 탐지 건수와 겹침률"])
sheet.cell(row=1, column=1).font = Font(bold=True, size=12)
for line in (
"연속 일치 기준을 3~9어절로 바꿔가며 다시 판정한 결과입니다.",
"겹침률은 서로 다른 문서의 원고를 임의로 15만 쌍 짝지어, 그 기준만큼 연속으로 겹치는 비율을 잰 값입니다.",
"임의 조합이라 표절 관계일 가능성이 낮으므로 오탐 지표로 봅니다. 다만 각 쌍이 실제로 무관한지 검증한 값은 아닙니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
sheet.append([])
header = ["어절", "글자 수", "탐지 건수", "연속일치로만 걸린 건",
"임의 조합 겹침률", "6087건 대조 시 겹칠 확률", "판단"]
sheet.append(header)
for index in range(1, len(header) + 1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = Font(bold=True)
cell.fill = HEADER_FILL
for item in sweep:
cumulative = item["누적"]
verdict = ("사용 불가" if cumulative >= 90 else
"위험" if cumulative >= 30 else
"판단 필요" if cumulative >= 10 else "권장 구간")
sheet.append([
f"{item['어절']}어절", item[""], item["탐지"], item["연속일치만"],
item["쌍당"] / 100, cumulative / 100, verdict,
])
sheet.cell(row=sheet.max_row, column=5).number_format = "0.00000%"
sheet.cell(row=sheet.max_row, column=6).number_format = "0.0%"
sheet.append([])
# 어절 기준을 아무리 바꿔도 유사도·커버리지만으로 걸리는 건은 그대로다.
base = sum(
1 for row in rows
if any(c["combined"] >= SCORE_MIN or c["coverage"] >= COVERAGE_MIN
for c in (row.get("후보 판정재료") or []))
)
sheet.append([f"기준과 무관하게 걸리는 건 (유사도 0.65 또는 커버리지 30% 충족): {base}"])
sheet.cell(row=sheet.max_row, column=1).font = Font(bold=True)
for line in (
"어절 기준을 아무리 낮춰도 이 건수는 줄지 않습니다. 어절 기준이 더하는 몫이 '연속일치로만 걸린 건'입니다.",
"3어절과 4어절은 임의 조합의 99% 이상이 겹쳐, 침해 신호로 쓸 수 없습니다. 겹친 표현은 '할 수 있는', '할 수 있을 것' 같은 상투어였습니다.",
"8어절과 9어절은 탐지 건수가 같습니다. 더 올려도 얻는 것이 없다는 뜻입니다.",
"겹침률이 0이 되는 지점은 9어절까지 없습니다. 따라서 '오탐이 사라지는 값'이 아니라 탐지력과 오탐의 교환으로 정해야 합니다.",
"정확한 오탐률은 사람이 표본을 판별해 정답을 만든 뒤에만 산출할 수 있습니다.",
):
sheet.append([line])
sheet.cell(row=sheet.max_row, column=1).font = NOTE_FONT
for index, width in enumerate((12, 10, 12, 20, 18, 24, 12), start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
SCORE_MIN = 0.65
SPAN_MIN = 80
COVERAGE_MIN = 0.30
@ -465,9 +525,12 @@ COMPARE_COLUMNS = [
def build_compare_sheet(wb: Workbook, first: dict[str, dict], second: dict[str, dict]) -> None:
sheet = wb.create_sheet("1차_vs_2차 비교")
flagged_first = {key for key, row in first.items() if row["침해 의심"]}
flagged_second = {key for key, row in second.items() if row["침해 의심"]}
shared = sorted(set(first) & set(second))
# 중복 제거 회차는 검사 대상이 줄어 키 집합이 다르다. 양쪽에 다 있는
# 건만 비교해야 없는 키를 참조하지 않는다.
shared_keys = set(first) & set(second)
flagged_first = {k for k in shared_keys if first[k]["침해 의심"]}
flagged_second = {k for k in shared_keys if second[k]["침해 의심"]}
shared = sorted(shared_keys)
deltas = [(second[key]["결합유사도"] or 0) - (first[key]["결합유사도"] or 0) for key in shared]
mean_delta = sum(deltas) / len(deltas) if deltas else 0
@ -527,6 +590,9 @@ def main() -> int:
parser.add_argument("--compare-jsonl", type=Path, help="비교 시트를 붙일 이전 회차 JSONL")
parser.add_argument("--backend", default="", help="검색 백엔드 표기 (미지정 시 JSONL 값 사용)")
parser.add_argument("--note", action="append", default=[], help="요약 시트에 남길 유의사항")
parser.add_argument("--sweep-json", type=Path, help="어절 기준 비교 시트용 데이터")
parser.add_argument("--dup-batch-jsonl", type=Path,
help="중복 원고 시트를 만들 때 참조할 이전 회차 배치 JSONL")
parser.add_argument("--no-excerpt-sheet", action="store_true",
help="원문 대조 시트는 만들지 않고 중복 원고 시트만 붙인다")
parser.add_argument("--excerpts-jsonl", type=Path,
@ -550,7 +616,12 @@ def main() -> int:
]
if not args.no_excerpt_sheet:
build_excerpt_sheet(wb, excerpts, records)
build_duplicate_sheet(wb, excerpts, records)
dup_records = load(args.dup_batch_jsonl) if args.dup_batch_jsonl else records
build_duplicate_sheet(wb, excerpts, dup_records)
if args.sweep_json:
build_sweep_sheet(
wb, json.loads(args.sweep_json.read_text(encoding="utf-8")), rows,
)
build_criteria_sheet(wb, rows, excerpts if args.excerpts_jsonl else None)
build_summary_sheet(wb, rows, backend=backend, notes=args.note)
if args.compare_jsonl:

View File

@ -49,9 +49,11 @@ def main():
store=CorpusStore(a.database); vectors,meta,model=build_or_load(store,a.index_dir,a.model,device,a.batch_size)
get_settings.cache_clear(); settings=get_settings().model_copy(update={"corpus_db_path":str(a.database),"persistent_index_dir":"/nonexistent","use_persistent_index":False,"use_llm_legal_judge":False})
# 법령/태그/규칙 엔진만 재사용한다. CPU 운영 인덱스를 변경하지 않는다.
span_min=a.min_exact_span or settings.persistent_min_exact_span
SCORE_MIN=settings.persistent_similarity_threshold; COVERAGE_MIN=settings.persistent_min_coverage
if a.min_exact_span:
settings=settings.model_copy(update={"persistent_min_exact_span":a.min_exact_span})
LOG.info("min_exact_span=%d (기본값 대신 적용)",a.min_exact_span)
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)
# 중복 제거: 검사 대상과 색인 후보에서 동시에 뺀다. 한쪽만 빼면 지운 원고가
# 여전히 상대로 잡혀 중복이 결과에 남는다.
@ -73,15 +75,20 @@ def main():
seg=records[meta["segment_ids"][int(i)]]; ev,ints,longest=_evidence_spans(item.text,seg.text); intervals.extend(ints); doc_intervals.setdefault(seg.document_id,[]).extend(ints); relem, reels=(seg.lemmas or extract_lemmas(seg.text)), (seg.elements or detector._extractor.extract(seg.text).model_dump()); es=_element_similarities(qe, type(qe)(**reels)); combined=.30*float(scores[int(i)])+.45*lemma_overlap_ratio(ql,relem)+.15*es["characters"]+.10*es["motifs"]
hits.append((combined,seg,float(scores[int(i)]),lemma_overlap_ratio(ql,relem),es,ev,longest))
hits.sort(key=lambda x:x[0],reverse=True); matches=[]
# 기준값은 검색 후보를 바꾸지 않으므로, 상위 후보의 판정재료를 남겨두면
# 어절 기준을 바꿔가며 다시 돌리지 않고 오프라인에서 재판정할 수 있다.
candidates=[{"seg":s2.segment_id,"doc":s2.document_id,"combined":round(c,4),
"longest":l,"coverage":round(_covered_length(doc_intervals.get(s2.document_id,[]))/max(1,len(item.text)),4)}
for c,s2,_,_,_,_,l in hits[:5]]
for combined,seg,textsim,lemmasim,es,ev,longest in hits[:5]:
coverage=_covered_length(doc_intervals.get(seg.document_id,[]))/max(1,len(item.text));
if combined < .65 and longest < 80 and coverage < .30: continue
if combined < SCORE_MIN and longest < span_min and coverage < COVERAGE_MIN: continue
sh=SimilarityHit(seg.segment_id,str(seg.metadata.get("document_title",seg.document_id)),combined,textsim,lemmasim,es,[])
match=detector._to_match(sh,True,None,None).model_copy(update={"source_document_id":seg.document_id,"source_segment_id":seg.segment_id,"matched_coverage":round(coverage,4),"longest_span":longest,"evidence_spans":ev,"match_reasons":["embedding_score" if combined>=.65 else "exact_span_or_coverage"]})
matches.append(match)
coverage=_covered_length(intervals)/max(1,len(item.text)); legal=detector._legal_engine.assess(max_similarity=matches[0].similarity if matches else 0,coverage=coverage,longest_span=max((m.longest_span for m in matches),default=0),legal_tags=[t.tag for m in matches for t in m.tags],query_text=item.text,evidence=[])
class R: pass
r=R(); r.matches=matches; r.is_infringement=bool(matches); r.confidence=round(matches[0].similarity if matches else (hits[0][0] if hits else 0),4); r.legal_risk=legal; r.score_semantics=type("S",(),{"union_coverage":round(coverage,4)})(); rows.append(result_row(item,r,retrieval_backend=f"KoSimCSE GPU ({a.model}, {device})"))
r=R(); r.matches=matches; r.is_infringement=bool(matches); r.confidence=round(matches[0].similarity if matches else (hits[0][0] if hits else 0),4); r.legal_risk=legal; r.score_semantics=type("S",(),{"union_coverage":round(coverage,4)})(); row=result_row(item,r,retrieval_backend=f"KoSimCSE GPU ({a.model}, {device})"); row["후보 판정재료"]=candidates; rows.append(row)
if n%a.progress_every==0: LOG.info("progress processed=%d/%d",n,len(items))
a.out_jsonl.write_text("\n".join(json.dumps(r,ensure_ascii=False) for r in rows)+"\n",encoding="utf-8")
build_xlsx(rows,a.out_xlsx,backend=f"KoSimCSE GPU ({a.model}, {device})")