이전 포스팅: AST 노드로 RAG 토큰을 다이어트시켜보자
프로젝트 Repository: https://github.com/dong7812/dompruner-mcp

왜 DOM AST에 'BM25'를 결합했는가?
OSSCA에서 ESLint 내부 작동 방식을 공부하며 ESTree(AST) 구조를 접했을 때, "웹 문서도 HTML이라는 트리 구조(AST)인데, 왜 LLM에 전달할 때는 플랫한 텍스트 덩어리로 잘라 넣을까?"라는 의문이 생겼다. -> 이전 포스팅에서 진행했던 고민을 기반으로 실제 구현을 시작하며 파이프라인 구축을 진행해보았다.
기존 Chunk 기반 RAG는 문맥 파괴와 무의미한 토큰 중복이라는 명확한 한계가 있었다. 반면 DOM AST 단위로 파싱하면 Heading, Section, Article 같은 문서의 계층 구조를 보존한 채 노드 단위로 정제할 수 있다.
문제는 "정제된 노드 중 질문과 연관된 섹션을 어떻게 고를 것인가"였다. 처음부터 Vector DB나 임베딩 모델을 붙이는 건 오버헤드가 너무 컸다. 또한 Vector DB와 같은 외부 연결 없이 최소한의 경량화를 진행해 보고 싶었다.
- 임베딩 모델을 호출하는 네트워크 / 계산 비용
- 웹 문서를 실시간으로 임베딩하고 벡터화하는 latency
- 가볍고 빠른 MCP(Model Context Protocol) 도구를 만들겠다는 목적과의 불일치
따라서 경량화와 즉각적인 스코어링을 위해 BM25 알고리즘을 선택했다. AST 노드별 키워드 빈도(TF)와 문서 내 희소성(IDF)을 기반으로, 연관 노드 섹션만 정확히 골라내어 LLM에 전달하는 가벼운 파이프라인을 구상했다.
빠른 구현을 진행하고 실제 실행을 통해서 벤치마크를 집계하고, 다른 사람들에게 보여주면서 문득 방향성에 대한 의구심이 들었다.
1. 비교 대상의 오류: Chunk RAG가 아니라 내장 웹 검색이었다
구현 후 성능 측정을 위해 처음엔 Chunk RAG를 기준점(Baseline)으로 삼으려 했다. 하지만 실무에서 개발자들이 웹 정보를 읽어올 때 Chunk RAG 파이프라인을 직접 구축해 쓰는 경우는 거의 없다. 다들 Claude Desktop, Cursor, Claude Code 등의 LLM 내장 웹 검색을 사용한다.
| 방식 | 실제 사용자 | 비교 대상으로서의 적절성 |
| Chunk RAG | 소수 (직접 인프라 구축 필요) | ✗ 실무 사용 패턴과 거리 있음 |
| LLM 내장 웹 검색 | 대다수 (클릭 한 번으로 사용) | ✓ 실제 해결해야 할 페인 포인트 |
비교 대상을 Anthropic의 내장 툴(web_fetch_20260209)로 변경하고 API 응답의 input_tokens를 측정한 결과, 단일 페이지 평균 15,735 토큰, Wikipedia 기준 56,754 토큰이라는 엄청난 컨텍스트 낭비를 확인했다.
2. 의미 추론 질문과 Fallback: 책임을 명확히 분리하다
DOM AST + BM25 파이프라인을 검증하던 중 난관에 부딪혔다.
"이 기능이 빠른가요?", "어떤 특징이 있나요?"처럼 문서 본문에 키워드가 직접 등장하지 않는 의미 추론형 질문이 들어오면, BM25 검색 점수가 0으로 떨어진다.
초기 고민: 전처리기가 다 하려 했던 오버엔지니어링
처음엔 BM25 점수가 0일 때 소형 임베딩 모델을 Fallback으로 붙이거나, 임계값(Threshold)을 두고 복잡한 분기 로직을 태우려 했다. 하지만 이렇게 하면 파이프라인이 무거워지고 튜닝 포인트만 늘어난다. (이렇게 진행을 하면 소형 모델을 통한 web fetch가 사용자로 하여금 더 편리할 수 있기에 오버엔지니어링으로 판단하였다.)
전환점: BM25 '0점'의 의미와 책임 분리 Architecture
BM25 점수가 0이라는 것은 "쿼리 단어가 본문에 하나도 없다"는 가장 확실한 신호다.
여기서 아키텍처의 책임을 명확히 나누기로 결정했다.
- BM25 0점 발생 시: 필터링을 멈추고, 노이즈(nav, footer, script 등)만 제거된 정제 전체 본문 노드(~2,000~3,000 토큰)를 그대로 LLM에 위임한다.
- 이유: 문맥 이해와 의미 추론은 원래 LLM이 제일 잘하는 일이다. 전처리기가 억지로 의미 추론까지 다 하려고 욕심낼 필요가 없었다.
[사용자 쿼리]
│
▼
[DOM AST 생성 및 노이즈 제거] (nav, script, footer 제거)
│
▼
[BM25 스코어링]
├── 점수 있음 (키워드 매칭) ──► 연관 AST 노드 섹션만 추출 ──┐
│ │
└── 점수 0 (의미 추론 질문) ──► 정제 본문 전체 유지 (~3k tok) ─┴─► [LLM 컨텍스트로 전달]
(의미 추론은 LLM이 담당)
전처리기는 "구조적 노이즈 제거 및 관련 섹션 선별"에만 집중하고, "의미 추론과 최종 답변 생성"은 LLM에 완전 위임함으로써 구조가 눈에 띄게 깔끔해졌다.
3. 정보 손실 우려: 요약이 아닌 '노이즈 제거'
"토큰을 90% 줄였다"고 하면 "필요한 정보가 잘린 것 아니냐"는 의문이 따라온다. LLM의 내장 웹 검색 툴 역시 서버 단에서 소형 모델을 거치며 요약 및 전달을 수행하기 때문에 정보 손실이나 환각 위험이 있다.
하지만 DomPruner는 원문을 요약하거나 재창작하지 않는다. Script, Style, Navigation, Footer, 광고 같은 '구조적 노이즈' 노드만 AST 단에서 잘라내고, 본문 텍스트와 코드 블록 원문은 100% 그대로 유지한다.
결론 및 벤치마크
이 과정을 거치며 내가 만든 것은 RAG 시스템이 아닌 "LLM을 위한 가벼운 웹 Fetch 전처리기"였음이 명확해졌다.
MCP(Model Context Protocol) 서버 형태로 구현한 dompruner-mcp를 실시간 측정한 결과는 다음과 같다.
| 테스트 대상 | Anthropic web_fetch | DomPruner | 토큰 절감율 |
| Python asyncio 문서 | 21,783 tok | 1,328 tok | 93.9% |
| MDN Fetch API | 15,965 tok | 1,368 tok | 91.4% |
| Wikipedia (영어) | 56,754 tok | 679 tok | 98.8% |
| 평균 (10개 주요 사이트) | 15,735 tok | 1,019 tok | 93.5% |
컨텍스트 윈도우 부담이 줄어들면서 LLM의 응답 속도도 평균 45% 향상되었다.
정리하며
AST 기반 RAG를 만들겠다는 거창한 생각에서 출발했지만, 구현 과정에서 BM25의 한계를 마주하고 Fallback 구조를 단순화하면서 "각 모듈의 책임을 명확히 분리하는 것"이 얼마나 중요한지 배울 수 있었다.
모든 것을 시스템 파이프라인 안에서 처리하려 하기보다, LLM이 잘하는 영역(의미 추론)과 전처리기가 잘하는 영역(AST 구조 정제 및 키워드 추출)을 구분할 때 가장 가볍고 강력한 솔루션이 나온다는 점을 깨달은 프로젝트였다.
'짬짬히 기술 블로그✍️ > LLM' 카테고리의 다른 글
| LLM의 토큰 사용량, 응답 시간을 어떻게든 줄여보고 싶다!! (1) | 2026.08.05 |
|---|---|
| LLM을 어떻게 하면 잘 활용할 수 있을까? (Claude Code 스킬 개발기) (1) | 2026.06.02 |
| RAG-token, Rag-sequence의 차이에 대한 이해를 해보자! (0) | 2026.05.20 |