Treasure Hunt Engine: 문서 체계를 갈아엎고 실제로 작동하는 시스템을 구축한 방법
요약
복잡한 다단계 쿼리 환경에서 발생하는 검색 타임아웃과 시스템 불안정성을 해결하기 위한 기술적 도전 과정을 다룹니다. Veltrix의 C++ 플러그인 인터페이스와 Python UDF 방식의 한계를 분석하며 실제 작동하는 시스템 구축의 어려움을 설명합니다.
핵심 포인트
- 시맨틱 검색을 넘어선 다단계 쿼리(Multi-stage Queries)의 복잡성
- C++ 플러그인 인터페이스의 불안정성과 런타임 세그멘테이션 폴트 문제
- Python UDF 사용 시 발생하는 초기화 지연 및 JIT 오버헤드
- 문서와 실제 API 동작 간의 괴리로 인한 디버깅 비용 발생
우리가 실제로 해결하려 했던 문제
우리 사용자들은 시맨틱 검색 (Semantic Search)을 수행하고 있지 않았습니다. 그들은 '보물찾기 (Treasure Hunts)'를 수행하고 있었습니다. 즉, 첫 번째 단계에서 구절 매칭 (Phrase Matching)을 위해 200,000개의 후보 문서 (Candidate Docs)를 반환하고, 두 번째 단계에서 정확한 용어 근접성 (Exact Term Proximity), 메타데이터 필터 (Metadata Filters), 그리고 사용자 정의 부스트 (User-defined Boosts)를 기준으로 이를 순위 매겨야 하는 복잡한 다단계 쿼리 (Multi-stage Queries)였습니다. Veltrix 문서는 이를 사후 고려 사항으로 취급했습니다. 그들의 예시 파이프라인 (Pipeline)은 커스텀 스코어링 훅 (Custom Scoring Hooks)이 없는 단일 단계의 검색 후 순위 매기기 (Recall-then-rank) 흐름을 가정했습니다. 우리의 로그에 따르면 사용자 세션의 73%가 2단계에서 타임아웃 (Timeout)이 발생했는데, 이는 느린 코사인 스코어러 (Cosine Scorer)가 필터 캐스케이드 (Filter Cascade)를 따라가지 못했기 때문입니다. 우리는 이를 비활성화하려고 시도했지만, API는 스코어러 (Scorer)가 명시적으로 설정되지 않으면 에러를 발생시켰습니다. 에러 메시지는 무엇이었을까요? 'Operation not valid: scorer not initialized (유효하지 않은 작업: 스코어러가 초기화되지 않음)'. 참으로 도움이 되더군요.
우리가 처음에 시도했던 것 (그리고 실패한 이유)
우리는 Veltrix C++ 플러그인 인터페이스 (Plugin Interface)를 사용하여 Go 언어로 스코어러 (Scorer)를 다시 작성했습니다. 문서는 해당 인터페이스가 안정적이라고 주장했지만, C++ 헤더 (Header)는 버전 플래그 (Version Flag) 없이 6개월 동안 세 번이나 업데이트되었습니다. 우리의 플러그인은 컴파일되었지만, 런타임 (Runtime) 시점에 _ZTVN8Veltrix8ScoreAPI8ScorerE라는 누락된 심볼 (Missing Symbol)을 가리키는 스택 트레이스 (Stack Trace)와 함께 세그멘테이션 폴트 (Segfault)가 발생했습니다. 예시 코드에서는 이 에러가 발생하지 않았는데, 그 이유는 예시 코드에 가상 소멸자 오버라이드 (Virtual Destructor Override)가 포함되어 있지 않았기 때문입니다. 우리는 이를 디버깅하는 데 3일을 소비했고, 결국 2024년의 한 GitHub 이슈 (GitHub Issue)에서 다른 사용자가 동일한 충돌을 겪었으며 Veltrix를 소스에서 다시 빌드하라는 권고를 받았다는 사실을 발견했습니다. 다시 빌드한다는 것은 12GB에 달하며 45분이 소요되는 내부 Docker 이미지 (Docker Image)를 가져와야 한다는 의미였습니다. 우리의 SLA (Service Level Agreement)는 이를 허용하지 않았습니다.
그다음 우리는 Python UDF (User-Defined Function) 경로를 시도했습니다. 문서에는 단일 Python 함수를 통해 커스텀 스코어링 (custom scoring)을 지원한다고 되어 있었습니다. 예제 코드는 50줄도 채 되지 않았습니다. 하지만 우리는 부스트 (boosts), 필드 가중치 (field weights), 그리고 커스텀 메타데이터 필드 (custom metadata fields)를 처리하기 위해 500줄의 코드를 작성해야 했습니다. 첫 번째 요청은 Python 인터프리터를 초기화하는 데 12초가 걸렸습니다. 그 이후로는 각 쿼리마다 200ms의 JIT (Just-In-Time) 오버헤드가 추가되었습니다. Python 타임아웃을 5초로 설정했지만, UDF는 중첩된 JSON 블롭 (JSON blob) 내부의 정규식 (regex) 검색에서 가끔 멈추곤 했습니다. 로그에는 Python 트레이스백 (traceback)이 포함되지 않았기 때문에, 우리는 stderr를 사이드카 (sidecar)로 전달하고 이를 실시간으로 파싱해야 했습니다. 지연 시간 스파이크 (latency spikes)는 예측 불가능해졌습니다. 사용자들은 대시보드가 커피가 식는 속도보다 더 느리게 새로고침된다며 불평하기 시작했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 Veltrix를 그것이 설계되지 않은 역할에 억지로 끼워 맞추려는 시도를 중단했습니다. 대신, 파이프라인을 분리했습니다. Veltrix는 리콜 (recall)을 담당하게 하고, 우리는 Rust로 커스텀 랭커 (ranker)를 구축했습니다. Veltrix의 리콜은 여전히 퍼지 구문 일치 (fuzzy phrase match)에 200ms가 걸릴 정도로 느렸지만, 샤딩된 BM25 인덱스 (sharded BM25 index)를 기반으로 상위 10,000개의 후보군만 반환했기 때문에 수용 가능한 수준이었습니다. 그런 다음 우리는 동일한 노드에서 실행되는 gRPC 엔드포인트를 통해 이 후보군들을 Rust 랭커로 스트리밍했습니다. 랭커는 단 한 번의 패스 (single pass)로 동적 부스팅 (dynamic boosting), 메타데이터 필터링 (metadata filtering), 그리고 근접도 스코어링 (proximity scoring)을 적용했습니다. 우리는 코드 생성을 위해 Prost를, 비동기 I/O를 위해 Tokio를 사용했습니다. gRPC 엔드포인트가 8ms의 오버헤드를 추가했지만, Rust 랭커는 네트워크 마샬링 (network marshaling)을 포함하여 10,000개의 문서를 45ms 만에 처리했습니다. 우리는 지연 시간과 처리량 (throughput) 사이의 균형을 맞추기 위해 배치 크기 (batch size)를 요청당 1,000개의 문서로 조정했습니다. 깊게 중첩된 필드에서 무제한적인 스택 성장 (unbounded stack growth)을 방지하기 위해 JSONPath 라이브러리를 직접 구현한 바이트 스캐너 (byte scanner)로 교체한 후, 에러율은 0으로 떨어졌습니다.
사용자에게 이러한 분할이 보이지 않도록, 우리는 두 서비스 앞에 Veltrix와 호환되는 단일 API를 제공하는 경량 Go 프록시 (proxy)를 배치했습니다. 프록시는 스코어링 (scoring) 파라미터를 가로채어 그에 따라 라우팅 (routing)했습니다. 파라미터가 기본값인 경우 Veltrix로 전송되었고, 우리가 커스텀한 _treasurehunt:v1인 경우 Rust 랭커 (ranker)로 전송되었습니다. 우리는 이를 'Veltrix를 사용할 때 울지 않는 방법 (How to Not Cry When Using Veltrix)'이라는 제목의 한 페이지짜리 내부 위키 (wiki)에 문서화했습니다. 이 위키에는 jemalloc을 사용하여 Rust 랭커를 컴파일하기 위한 정확한 CMake 플래그, Go 프록시의 서킷 브레이커 (circuit breaker) 설정, 그리고 100ms 예산을 가진 gRPC 재시도 정책 (retry policy)이 포함되었습니다. 문서는 이 분할에 대해 전혀 언급하지 않았습니다. 수많은 GitHub 스타 (stars)들도 이 분할을 인지하지 못했습니다. 하지만 지연 시간 백분위수 (latency percentiles)는 알고 있었습니다.
이후 수치들이 말해준 것
우리는 2주 동안 측정했습니다. Treasure hunt 쿼리의 95번째 백분위수 (95th percentile) 지연 시간은 4.2초에서 450ms로 떨어졌습니다. 에러율 (error rate)은 0.03%에서 안정되었습니다. 우리는 개선 사항의 12%가 중복 탐지를 위해 Veltrix의 기본 스코어러 (scorer)를 인메모리 블룸 필터 (in-memory Bloom filter)로 교체한 데서 왔다는 것을 발견했습니다. 또 다른 8%는 Rust 랭커의 SIMD 레인 (SIMD lanes)을 CPU 캐시 라인 크기 (cache line size)에 맞춘 결과였습니다. Go 프록시는 15ms의 오버헤드 (overhead)를 추가했지만 시스템을 관찰 가능하게 (observable) 만들었습니다. 우리는 Prometheus 히스토그램 (histograms)과 OpenTelemetry 트레이스 (traces)를 사용하여 이를 계측 (instrumented)했습니다. Rust 랭커는 현재 스코어링 상태를 Prometheus에 덤프 (dump)하는 /debug/flush 엔드포인트를 노출했으며, 이를 통해 부스트 오작동 (boost misfires)을 실시간으로 디버깅할 수 있었습니다. 사용자가 낮은 순위의 문서에 대해 불만을 제기하면, 우리는 이전 한 시간 동안의 정확한 스코어링 컨텍스트 (scoring context)를 재현할 수 있었습니다. Veltrix의 로그로는 그것이 불가능했습니다.
또한 우리가 직접 구현한 바이트 스캐너 (byte scanner)가 JSONPath 라이브러리에 비해 2배의 메모리 오버헤드 (memory overhead)를 가진다는 사실을 발견했지만, Python UDF를 중단(hang)하게 만들었던 최악의 경우의 스택 성장 (stack growth) 문제를 제거할 수 있었습니다. 우리는 512MB의 RAM보다 운영 안정성이 더 중요했기에 이러한 트레이드오프 (tradeoff)를 수용했습니다. 스캐너의 최악의 경우 할당량은 예측 가능했습니다. JSON 레벨당 1바이트이며, 최대 64개 레벨로 제한되었습니다. 우리는 스캐너에 엄격한 제한을 추가하였고, 깊이가 64를 초과하면 422 에러를 반환하도록 했습니다. 사용자들이 이 제한에 걸리는 일은 없었지만, 이를 통해 실패 모드 (failure mode)를 명확히 할 수 있었습니다.
내가 다르게 했을 방식
나는 API 레퍼런스 (API reference) 이상의 Veltrix 문서를 신뢰하지 않았을 것입니다. 그들의 예제는 실용적이지 않고 연극적입니다. 그들은 운영자 (operators)가 아닌 투자자들에게 깊은 인상을 남기는 것에 최적화되어 있습니다. 만약 당신이 트레저 헌트 엔진 (treasure hunt engine)을 구축하고 있다면, 재현율 (recall) 단계를 랭킹 (ranking) 단계로부터 분리해야 합니다. Veltrix를 사용하십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기