참가자격 매칭 — 추출 정확도 디버깅 5건

2026-07-28 · 팀원이 raw 원문을 직접 대조해 누락을 발견한 뒤 이어진 추적. 대조 기준 파일: data/eligibility_sujeong.json(팀원 수기 보완본) vs data/extracted/eligibility.json(자동 추출본).

발단

98건 요건을 자동 추출해뒀는데, 팀원이 원문과 대조해보니 6개 문서에서 10건이 누락. “왜 이렇게 많이 빠졌나”를 추적하다 원인이 하나가 아니라 다섯 개였다는 걸 확인.


원인 ① 검색이 문서를 다 못 봄

증상: GKL 문서에서 “상호출자제한기업집단 참여 불가” 조항이 빠짐.

확인: 원문에는 있는데, retrieve()가 뽑아온 상위 20청크 안에는 없었다. LLM은 그 문장을 본 적조차 없으니 뽑을 수가 없었던 것.

원인: 참가자격 키워드로 검색해 상위 20개만 LLM에 보여주는 방식이었는데, 요건이 검색어와 단어가 안 겹치는 표현으로 흩어져 있으면 랭킹 밖으로 밀린다.

수정: 검색 폐기 → 문서 전체를 통째로 읽힌다. 15만자 넘는 문서만 겹치는 구간(2천자)을 두고 분할.

요건 추출은 문서당 1회만 도는 캐시 작업이라, 애초에 속도를 아끼려고 검색으로 지름길 낼 이유가 없었다. CLAUDE.md STEP2의 “공고 전체를 처음부터 끝까지 읽어… recall 우선” 원칙을 그제서야 지킨 셈.


원인 ② 비슷한 조항을 하나로 뭉갬

증상

팀원:  「중소기업 범위 및 확인에 관한 규정」에 따라 발급된
       중기업·소기업·소상공인 확인서를 소지한 업체
자동:  중소기업 관련 요건(해당 시 제출) : … 직접생산확인증명서 보유 업체,
       중소기업 확인서(유효기간 내) 등 제출 요건

원인: 직접생산확인증명서(제품 기준)와 중소기업 확인서(회사 규모 기준)는 근거 법령도 다른 별개 서류인데 한 문장으로 요약돼버림. 문서 전체를 한 번에 보여주니 비슷한 주제 조항이 한눈에 들어와, LLM이 알아서 정리(요약·병합)하는 습성이 나온 것. 프롬프트에 “합치지 마라”가 없었다.

수정: 프롬프트에 명시 — “원문 표현 그대로, 근거 법령·서류가 다르면 절대 하나로 합치지 마라. ‘등’, ‘(해당 시)‘로 뭉뚱그리지 마라.”

검증: GKL 재추출 → 팀원 문장과 거의 토씨까지 일치. 24개 → 36개로 세분화.


원인 ③ 섹션 위치로 암묵적 제외

증상: 한영대 문서 재추출 시 오히려 줄었다(10 → 7). 담합·금품·정보누출 관련 자격제한 조항이 사라짐.

원인: 그 조항들이 “참가자격” 섹션이 아니라 “제안서 처리” 섹션에 있었다. 문서 전체를 보게 되니 LLM이 섹션 제목까지 인식해서 “여긴 참가자격 파트가 아니네” 하고 스스로 제외한 것.

아이러니: 검색 방식일 땐 문맥이 없어서 실수로 포함됐던 게, 전체 읽기로 바꾸니 문맥을 알아서 실수로 제외됐다.

수정: “문서 어느 섹션에 있든 판단 기준이 아니다 — 제안서 작성 요령·청렴서약 등에 있어도 ‘입찰참가자격 제한’으로 이어지면 반드시 포함해라.”

검증: 5건 중 4건 복구.


원인 ④ 반복 나열 항목 일부만 뽑고 생략

증상: ③ 수정 후에도 “금품제공 시 자격제한” 1건이 계속 안 잡힘.

원인: 청렴서약서 안에 1. 담합 / 2. 금품제공(금액 4단계) / 3. 허위기재 / 4. 윤리강령이 연달아 나열돼 있는데 1번만 뽑고 2~4번은 스킵. 원인 ②와 뿌리가 같다 — 반복되는 항목을 만나면 대표 하나만 남기고 요약·생략하는 습성.

수정: “비슷한 조항이 연달아 나열돼도(금액 구간별 등) 앞부분만 뽑고 나머지를 건너뛰지 마라 — 전부 참가자격에 영향을 준다면 각각 빠짐없이 포함해라.”

검증: 한영대 31건으로 복구, 금품제공 4단계 전부 포착.


원인 ⑤ reasoning이 예산을 다 먹어 빈 응답

증상: 98건 전체 재추출 중 6건이 0건으로 나옴.

확인

finish_reason: length
completion_tokens: 2500  (전부 reasoning_tokens)
content: 0자

원인: gpt-5 계열은 reasoning 토큰이 completion 예산에 포함된다. 프롬프트에 지시사항을 4개나 얹으면서 추론이 길어져 답변을 쓰기도 전에 예산이 소진됐다. src/query/generate.py에는 이미 “빈 답변이면 예산 2배로 재시도”하는 방어가 있었는데, 새로 만든 eligibility.py가 그걸 빼먹었다. config.py 주석에도 경고돼 있던 함정.

수정: _call_extract() 신설 — 빈 응답 + finish_reason == "length" 이면 예산 2배로 1회 재시도. 기본 예산도 채팅용과 분리해 10,000으로 상향.

검증: 6건 중 케빈랩(원문 자체 없음, 정상)을 뺀 5건 전부 복구. 재시도 경고가 실제 로그에 찍히며 동작 확인.


최종 결과

문서팀원 추가분커버
GKL 그룹웨어3건3/3 ✅
한영대2건2/2 ✅
한국연구재단1건1/1 ✅
한국생산기술연구원1건1/1 ✅
봉화군2건2/2 ✅
한국전기안전공사1건0/1 — 아래 참고

98건 전체 재추출: 성공 98 / 실패 0 · 36분 · $1.80 · 요건 총 873 → 1,560개.

미해결 — 정의 판단이 필요한 1건

한국전기안전공사의 “협상에 의한 계약체결 방식 적용” 은 여전히 안 잡힌다. 근데 이건 버그가 아니라 범위 문제다 — 이건 참가자격(누가 참여 가능한가)이 아니라 계약방식(어떻게 계약을 맺을지) 정보라, 프롬프트가 의도적으로 제외하고 있다. 팀원은 이걸 참가자격 범주에 넣고 싶어 함 → “참가자격을 어디까지로 정의할지” 팀 합의가 필요.

관통하는 교훈

  1. 검색은 “비슷한 것만” 찾는다. 빠짐없이 봐야 하는 일(참가자격 확인)에는 구조적으로 안 맞는다. 범위를 문서 하나로 좁혀도 한계는 그대로다.
  2. LLM은 비슷한 걸 만나면 정리하려 든다. 뭉치거나(②) 생략한다(④). “다 보여준다”와 “본 걸 그대로 다 적는다”는 별개의 지시다.
  3. 기존 코드의 방어를 새 코드가 물려받지 않는다(⑤). 같은 모델을 쓰면 같은 함정도 따라온다.