susinlee 님의 블로그

공시메이트 ― 중간 점검 3 본문

일기/TIL

공시메이트 ― 중간 점검 3

susinlee 2026. 10. 3. 14:51

이전 작업 결과 ― 4개 기업 확장

"전체 파이프라인이 이수페타시스에 특화된 건 아니지만, parser의 허용 정책은 꽤 강하게 특화돼 있었다"

 

삼성전자·삼양식품·알테오젠·대상 4개 회사를 골라 총 19건의 공시를 받았는데,

parser 결과는 success 3건, partial 16건, failure 0건이었음.

실제 제품 색인까지 들어간 건 삼성전자 3건뿐이고,

나머지 3개 회사는 다운로드와 element 추출까지는 됐지만 parser contract 때문에 검색 가능한 상태까지 못 갔음.

 

앞단의 parser admission rule이 다른 기업을 너무 많이 탈락시키고 있음.

 

구체적으로 보면 partial 16건의 주된 원인은 `uncertain_named_angle`임. `<식품사업>`, `<이연법인세자산>`, `<감사위원회>`, `<삼양식품>` 같은 "이름처럼 보이는 꺾쇠 표현"이 15건 공시에서 발견됐고, 현재 parser는 이수페타시스에서 본 일부 allowist만 안전한 리터럴로 인정하고 나머지는 partial로 처리함. 그래서 실제 XML parsing은 상당히 진행됐고 element도 수천 개 뽑혔지만, 제품 검색 자격에서는 탈락한 것.

 

다른 회사 XML을 읽었지만, 안전하다고 확신하지 못해 검색용 corpus로 못 올리는 상황.


삼성전자만 보면 오히려 전체 파이프라인은 꽤 잘 일반화됨.

 

2025 사업보고서, 2026 반기, 2026 분기 3건이 success였고, 총 RU 9171개로 색인됨. 별도의 회사 특수 로직 없이 Tool Planning → Retrieval → Evidence → Context → Answer → Citation이 이어졌고, 2025 매출과 주요 사업 질문에는 실제 citation도 붙었음. cross-company leakage도 없었음.

 

적어도 삼성전자에서도 동일 구조로 동작했다.

 

Tool Planning은 꽤 안정적임.

 

4개 회사 x 6개 질문으로 총 24개 probe를 돌렸고, annual / quarter / half / latest 같은 기간 필터는 모두 검증을 통과했음.

삼성전자에서는 "2025년 매출"이 2025.12 사업보고서, "2026년 1분기"가 2026.03 분기보고, "최근 실적"이 2026.06 반기보고서로 갔음. 


CompanResolver 쪽은 하나의 일반화 버그가 발생했고, 수정함

 

`이수페타시스 요즘 실적 어때?`에서 `"요즘"` 이라는 비상장 법인명이 실제 corp cache에 있어서 두 회사로 잡혀 ambiguous가 됐음.

 

원인은 전체 corp cache에서 exact token match를 쓰면서, 이미 색인된 명시 회사가 있는데도 색인되지 않은 다른 이름 후보가 ambiguity를 만들었던 것.

 

이걸 `"요즘"`만 blacklist 하는 식으로 막지 않고

색인된 회사가 하나 있고, 다른 후보는 색인되지 않은 회사라면 색인된 회사를 우선한다.

 

라는 일반 규칙으로 수정했음. 

 

다만 Resolver가 아직 완전히 끝난 건 아니고, `"삼성 전자", "삼양 식품"`처럼 중간 띄어쓰기가 생기면 ambiguous가 되고, 영문명도 잘 붙지 않는 문제가 남아있음. ●


정정공시 문제가 반복됨

 

대상에서 정정 3건을 실제로 받았는데, 모두 `unsupported_tag:CORRECTION` 으로 partial이었음.


알테오젠의 `[첨부추가]반기보고서`도 꽤 흥미로운 케이스

 

`latest`로는 보고기간이 2026.06이라 잘 선택되는데, report_type이 정확히 `"반기보고서"`가 아니라 `[첨부 추가]반기보고서`라서 `semiannual` 필터로는 놓칠 수 있음.

 

이건 앞으로 report type normalization이 필요할 가능성을 보여줌.

예를 들어

[첨부추가]반기보고서
[기재정정]사업보고서
사업보고서

 

처럼 prefix가 붙은 report 이름과 canonical report type을 분리해서 관리하는 게 더 안전할 수 있음.


Forecast 전략은 아직 약간 불안정함

 

삼성전자와 대상의 `"항후 매출 목표"`는 latest 반기만 두 번 검색했고, 삼양식품·알테오젠은 두 번째에 2024-2026 range로 넓혔음.

 

삼성전자 E2E에서는 latest 반기만 두 번 검색한 뒤 abstain했음. 숫자를 만들진 않았으니 안정성은 지켜졌지만, 만약 오래된 사업보고서에만 장기 목표가 있었다면 놓쳤을 수도 있음. ●

 

이건 false negative 가능성에 가까움. 다만 parser 문제보다는 우선순위가 낮음.


정리하면

CompanyResolver
→ 대체로 일반화 가능
→ 일부 collision/spacing 문제 존재

Tool Planning
→ 회사가 달라도 잘 일반화

Retrieval
→ 삼성전자에서 정상

Evidence / Citation
→ 삼성전자에서 정상

Parser eligibility
→ 심각한 일반화 문제

Correction handling
→ 여러 회사에서 반복되는 limitation

 

그래서 다음 작업은 parser contract 일반화로 가야함.

 

구체적으로는 두 축

  1. uncertain_named_angle
  2. CORRECTION

allowlist를 더 늘리는 방식이 아닌 "이 꺾쇠가 진짜 XML tag인가, 아니면 본문 리터럴인가?"를 구조적으로 판별할 수 있는 규칙을 만들어야 함.

 

예를 들면 surrounding syntax, tag registry, known DART tag set, matching closing tag 여부, XML parser context 같은 걸 이용하는 것.

 

그리고 `CORRECTION`도 "정정공시니까 무조건 위험"이 아니라 실제 XML 구조 안에서 어떤 의미인지 분석해서 처리해야 함.

 

아무튼 한 줄로 요약하면

이번 smoke는 Tool Planning과 retrieval architecture의 일반화 가능성은 확인했고,
실제 제품 화장을 막는 병목이 parser의 보수적인 eligibility policy 라는 게 나타났다.

질의응답 타임

 

LLM이 사용자 질의에서 회사명을 추출하는 것 자체는 충분히 가능하고 자연스러운데

그럼에도 `CompanyResolver` 같은 deterministic 계층이 여전히 필요한 이유는 "회사명이 이해하는 것"과 "검색 시스템에서 사용할 canonical identity를 확정하는 것"이 다른 문제이기 때문.

 

예를 들어 사용자가 "삼성 전자 2025년 매출 알려줘" 라고 하면 LLM은 확실히 "삼성전자"라는 회사를 이해할 것.

그런데 retrieval index는 "삼성전자"라는 문자열이 아닌 `corp_code=00126380` 같은 canonical key로 묶여 있음.

 

그래서 역할을 이렇게 나누는 것이 좋음.

User Query
   ↓
LLM
"회사 mention = 삼성 전자"
   ↓
CompanyResolver
"삼성 전자" → 삼성전자 → corp_code 00126380
   ↓
Search Tool
company_id=00126380

 

현재는 Resolver 구현이 lexical matching에 너무 의존함 → nomalization이 아직 부족.

 

예를 들어 Resolver에는

"삼성전자"
"삼성 전자"
"(주)삼성전자"
"Samsung Electronics"

 

같은 alias/normalization을 다룰 수 있어야 하고,

LLM은 `"삼성 전자"`라는 mention만 넘겨도 됨.

 

장기적으로는 아래 구조가 제일 깔끔.

질문
↓
LLM: company_mention="삼성 전자"
↓
Resolver:
normalize
alias lookup
canonical corp_code 확정
↓
Search

현재 `abstain`은 하나의 단일 규칙으로  발생하는 게 아니라 두 층에서 발생한다고 이해하는 게 가장 정확함.

 

첫 번째는 시스템/runtime 차원의 중단.

 

예를 들어

company_ambiguous
unsupported_company
invalid_arguments
no_matching_filing

 

이건 LLM이 "답하지 말아야겠다"고 판단하는 게 아니라, 시스템이 애초에 유효한 Evidence를 만들 수 없어서 다음 단계로 못 가는 경우임. 

 

직전 Tool Planning 평가에서도 `"2035년 확정 매출"`을 `2035 annual exact`로 검색했을 때 matchin filing이 없어서 `no_matching_filing`으로 처리되는 경로가 있었음.

 

두 번째는 LLM generation 단계의 abstain.

 

검색은 정상적으로 됐고 Evidence도 Context에 들어갔는데, LLM이 그 Evidence만으로는 질문에 답할 수 없다고 판단하는 ㅕㅇ우.

 

이번 삼성전자 E2E가 딱 그 예.

 

`최근 실적`은 2026.06 반기보고서를 정상적으로 검색해서 Evidence 8개, 약 3,951 token을 넘겼는데도 최종 답은 `abstained`, citation 0 이었음. `"항후 매출 목표"`도 반기보고서를 검색했지만 목표를 확인하지 못해서 abstain했음.

 

흐름을 그리면 ●

             ┌─ invalid / no filing
             │
Question → Tool → Runtime
                   │
                   └─ valid
                       ↓
                    Retrieval
                       ↓
                    Evidence
                       ↓
                      LLM
                   /       \
              answer       abstain

 

여기서 두 종류를 상태상 분리해서 관리하는 게 좋다고 봄.

 

예를 들면

system_no_evidence
model_abstain

 

혹은 현재 상태 이름을 유지하더라도 run metadata에

abstain_stage = pre_generation | generation
abstain_reason = no_matching_filing | insufficient_evidence | ...

 

왜냐하면 사용자에게는 둘 다 "답을 못 했다"지만, 엔지니어 입장에서는 완전히 다른 실패이기 때문.

 

`no_matching_filing`이면 corpus/기간 문제고,

검색은 됐는데 LLM abstain이면 retrieval/context/generation 쪽을 살펴봐야 하기 떄문.


정정 공시 관련해서는

원본 저장소에서는 제거하지 않는다.
기본 제품 검색에서는 검증된 최신 정정본이 이전 버전을 supersede 하도록 한다.

 

immutable source storage와 active retieval corpus는 목적이 다름.

 

raw storage에서는 둘 다 유지하고, 둘의 관계를 아래처럼 관리해야 함.

A
 └─ superseded_by → A'

 

 

제거해버리면 문제가

 

첫째, 재현성과 audit이 깨짐

"정정 전에는 어떤 내용이 공개돼 있었나?"를 나중에 확인할 수 없어짐.

 

둘째, `as_of` 질의가 불가능해짐.

예를 들어 정정이 3월 21일에 나왔는데, "2025-03-20 기준으로 회사가 뭐라고 공시했어?"라고 물으면 그 시점에는 A가 유효한 문서였기 때문에 A로 답변해야 함.

 

셋째, correction relationship 자체를 검증할 수 없음.

그래서 source layer는 immutable 이어야 함.


위 질문들을 하나의 설계 원칙으로 묶으면

자연어 이해
→ LLM

회사 식별자 확정
→ CompanyResolver

검색 조건
→ LLM Tool Planning

검색 조건 검증
→ Runtime

공시 버전 선택
→ Filing metadata / version relation

검색
→ Retrieval

근거 충분성
→ Runtime + LLM 각각의 단계에서 판단

질의응답 2

 

현재 구조에서는 Question → Tool로 넘어갈지 여부와 Tool 인자 생성은 LLM planner가 판단하는 구조가 맞음.

 

지금 큰 흐름을 이런 식

사용자 질문
→ CompanyResolver
→ LLM Tool Planner
→ search_disclosures(args)
→ Runtime validation
→ Retrieval

 

그래서 `"2035년 확정 매출이 어떻게 돼?"` 라는 질문이 들어오면, 

planner가 질문에 명시된 `2035`를 읽고 그 값을 검색 조건으로 구조화 할 수 있음.

 

여기서 중요한 건 LLM이 `2035`를 "추론해서 만든다"는 게 아니라, 사용자가 직접 말한 연도를 Tool argument로 옮기다는 것.

사용자:
"2035년 확정 매출이 어떻게 돼?"

 

↓

{
  "query": "2035년 확정 매출",
  "period": {
    "year": 2035,
    "mode": "exact"
  },
  "report_types": ["annual"]
}

 

실제 Tool Planning 평가에서는 이 질문이 정확히 그런 식으로 동작했음.

다만 실제 E2E에서는 같은 질문에 대해 planner가 한 번은 `latest`를 선택했다는 것. 그리고 abstain 했다는 것.

즉 LLM planner의 검색 전략은 run마다 달라질 수 있다는 것이 관찰됨.

 

이러한 특성 때문에 Runtime guardrail이 중요함. LLM이 어떤 검색 전략을 선택하든, Runtime은 허용되지 않는 값을 검증하고, 실제 corpus에 매칭되는 filing이 있는지만 확인함.

 

사실 `"2035년 확정 매출"`을 굳이 검색할 이유는 없음. 이건 corpus를 봐야 아는 사실이 아니라 시간적으로 성립할 수 없는 요청이기 때문임. 반대로 `"2035년 매출 목표가 있어?"`는 지금 공시에 이미 장기 목표가 적혀 있을 수 있으니 검색해야 하고.

 

즉 우리가 원하는 건 Runtime에 `feature_realized` 같은 규칙을 다시 만드는 게 아니라,

LLM이 질문 + 현재 시각을 보고 `answer_directly`와 `search_disclosures` 중 하나를 선택하는 것임.

그러면 구조가 훨씬 자연스러워짐.

2035년 확정 매출?
→ LLM: answer_directly

2035년 매출 목표?
→ LLM: search_disclosures

2025년 확정 매출?
→ LLM: search_disclosures

지금 보고된 구조를 보면 `no_matching_filing`, `invalid_arguments`, `unsupported_company`, `corpus_not_fresh_enough` 같은 상태는 Runtime/Tool layer에서 발생함.

 

첫째, Tool layer에는 이런 상태들이 존재한다는 것.

둘째, 적어도 일부 `no_matching_filing` 경로에서는 answer model을 아예 호출하지 않고 종료하는 설계가 있다는 것.

 

그런데 UX 면에서는 아래 방식이 더 좋음.

Tool
→ no_matching_filing

그 상태와 metadata를 LLM에게 다시 전달

LLM
→ 사용자에게 자연스럽게 설명

 

예를 들면 Runtime이 이런 구조화된 결과만 만듦.

{
  "status": "no_matching_filing",
  "company": "이수페타시스",
  "requested_period": {
    "year": 2035,
    "type": "annual"
  },
  "corpus_as_of": "2026-09-25T10:26:24+09:00",
  "available_latest_period": "2026.06"
}

 

그리고 LLM에게

사용자 질문:
"2035년 확정 매출이 어떻게 돼?"

Tool 결과:
해당 조건에 맞는 공시 없음.
현재 corpus 기준 최신 보고기간: 2026.06.

 

정도로 넘기는 것.

2035년 확정 매출은 현재 확인 가능한 공시에는 없습니다. 현재 제가 확인할 수 있는 최신 공시는 2026년 반기보고서까지에요. 

 

물론 앞서 다뤘듯이 여기서는 검색조차 하지 않겠지만.

 

아무튼 이게 고정 문자열보다 당연히 UX가 좋은데 여기서도 역할 경계를 잘 잡아야 함.

 

LLM에게 "no_matching_filing이니까 알아서 아무 말이나 해" 라고 넘기면 안 되고,

Runtime이 사실 상태는 구조화해서 고정해야 함.

 

즉

Runtime이 보장:
- status
- requested filter
- available corpus boundary
- company
- matched filing count
- validation error reason

LLM이 담당:
- 사용자 친화적인 설명 문장

 

으로 나누는 게 좋음. 이렇게 해야 LLM이 "아직 DART에 공시되지 않았습니다" 라고 단정하는 실수를 막을 수 있음.

실제로 시스템이 아는 건 "현재 우리 corpus에는 없다"임.

 

그래서 프롬프트에는 이런 제약이 들어가야함.

Tool status가 no_matching_filing이면
"현재 시스템이 확인 가능한 공시 범위에서 찾지 못했다"고 설명한다.

실제 DART 전체에 존재하지 않는다고 단정하지 않는다.

 

내가 지금 추천하는 응답 흐름을 이런 구조임.

User Question
     ↓
LLM Planner
     ↓
Tool Call
     ↓
Runtime Validation / Search
     ↓
Tool Result
     ├─ success
     │    ↓
     │ Evidence
     │    ↓
     │ Answer LLM
     │
     └─ non-success
          ↓
     Structured System Observation
          ↓
       Answer LLM
          ↓
   Natural-language guidance

 

다만, 기술적 오류나 보안/검증 오류까지 전부 LLM에게 자유롭게 설명시키지는 않는 게 좋음.

 

예를 들어

invalid_arguments
unsupported_company
tool_failure
timeout
internal_error

 

같은 건 사용자에게 내부 구조를 그대로 노출하지 말고,

Runtime이 안전한 구조화된 public-facing state로 변환한 뒤 LLM에게 넘기는 게 좋음.

 

예를 들어 내부를

invalid_arguments:
quarter=1
half=1

 

처럼 그대로 보여주기보다는

{
  "user_facing_reason": "conflicting_period_filter",
  "retryable": true
}

 

처럼 주는 것.

 

그러면 LLM은 내부 구현을 노출하지 않고 설명할 수 있음.

 

정리하면 앞으로는

success
→ LLM gets Evidence

no filing / invalid / stale corpus
→ LLM gets Structured Observation

 

이렇게 모두 마지막에 LLM을 거치게 만들 수 있음.

그러면 UX도 훨씬 일관적이고, Runtime은 계속 deterministic guardrail 역할만 유지할 수 있음.

'일기 > TIL' 카테고리의 다른 글

공시메이트 ― UI  (0) 2026.10.03
공시메이트 ― 중간 점검 4  (0) 2026.10.03
공시메이트 ― smoke test set 확장  (0) 2026.10.01
공시메이트 ― Generation  (0) 2026.10.01
공시 메이트 ― reranker  (0) 2026.10.01