OSM을 위한 로컬 AI 에이전트 구축: 21일간의 반복 과정
요약
자연어 요청을 OSM(OpenStreetMap) 필터 JSON으로 변환하는 로컬 AI 에이전트 구축 과정을 다룹니다. 로컬 LLM과 RAG 파이프라인을 활용하여 개인정보 보호와 비용 문제를 해결하며 시스템을 반복적으로 개선한 기록입니다.
핵심 포인트
- 자연어 질의를 정교한 OSM 필터 JSON으로 변환하는 RAG 파이프라인 구축
- API 비용과 개인정보 보호를 위해 로컬 LLM 및 소비자용 GPU 활용
- 퓨샷(Few-shot) 프롬프팅을 통한 JSON 출력 정확도 향상
- 중괄호 매칭 알고리즘을 이용한 안정적인 JSON 추출 모듈 구현
Photo by Andrew Dawes on Unsplash
모호한 아이디어를 동적 OSM 필터 생성을 위한 작동 가능한 RAG 파이프라인으로 전환한 방법
저는 자연어 요청을 osmfilter JSON으로 변환하는 로컬 AI 에이전트를 구축하는 데 21일을 보냈습니다. 이것은 기술적인 심층 분석, 아키텍처(Architecture), 실패 사례, 해결책, 그리고 배운 교훈들에 대한 기록입니다. 만약 여러분이 로컬 LLM, 임베딩 (Embeddings), 또는 OSM을 다루고 있다면, 여기서 얻어갈 것이 있을 것입니다.
불꽃 (The Spark)
단순한 관찰에서 시작되었습니다: _"제한 구역만 찾기"_라는 문장을 OSM 필터로 번역하는 작업은 자동화가 되어야 하는 작업이지만, 정적인 키워드 맵으로는 불가능합니다. 뉘앙스가 너무 높기 때문입니다. 해상 구역의 경우 seamark:type=restricted_area가 정확하지만, 육지의 경우 access=exclusion_zone이 더 나을 수 있습니다. LLM에는 문맥 (Context)이 필요합니다.
또한 저는 모든 것이 로컬에서 실행되기를 원했습니다. API 호출도, 속도 제한 (Rate limits)도, 개인정보 보호 문제도 없어야 합니다. 오직 소비자용 GPU와 로컬 모델만 있으면 됩니다.
이어지는 내용은 주로 저녁 시간을 활용하여 3주 동안 이 시스템을 구축하며 겪은 가감 없는 이야기입니다.
1주 차: 기초 (1~7일 차)
1~2일 차: LLM 래퍼 (The LLM Wrapper)
저는 가능한 가장 단순한 것부터 시작했습니다: llama-cpp-python을 래핑(Wrap)하여 프롬프트 (Prompt)와 함께 모델을 호출할 수 있는 클래스입니다. 목표는 군더더기 없는 JSON 응답을 받는 것이었습니다.
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
단순합니다. 하지만 stop=["\n\n"] 설정이 나중에 저를 괴롭히게 될 줄은 몰랐습니다.
또한 저는 단순한 중괄호 매칭 알고리즘을 사용하여 임의의 LLM 출력에서 JSON을 추출하는 executor.py 모듈을 구축했습니다. 정규 표현식 (Regex) 없이, 단순히 깊이 (Depth)를 계산하는 방식입니다.
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
이것은 그저 잘 작동하는 코드 중 하나이며, 그 이후로 손대지 않았습니다.
3~4일 차: 프롬프트 템플릿 (The Prompt Templates)
저는 여러 빌더 (Builders)가 포함된 prompts.py를 작성했습니다:
build_prompt(): 기본적인 함수 호출 (basic function-calling)build_mcp_prompt(): MCP 스타일의 도구 호출 (MCP-style tool calls)build_osmfilter_prompt(): 제로샷 (zero-shot) OSM 필터 생성build_osmfilter_prompt_with_examples(): 제공된 예시를 활용한 퓨샷 (few-shot)
마지막 방식이 핵심이 되었습니다. 이 방식은 사용자 질의(user query)와 미리 형식이 지정된 예시 문자열을 입력받아, JSON 필터 구조를 출력합니다:
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
이 부분이 이후 주차에서 실제로 작업이 이루어질 핵심 지점입니다.
5~7일차: 임베딩 모델 (The Embedding Model)
관련 있는 예시를 찾을 방법이 필요했습니다. 이때 33MB 크기에 384차원 임베딩 모델인 bge-small-en-v1.5를 도입했습니다. 저는 임베딩을 처리하고 이를 SQLite에 저장하기 위해 LocalLLMEmbedder를 구축했습니다.
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
그 다음 taginfo-wiki.db 데이터셋을 수집하여, 각 OSM 태그 설명을 JSON 객체로 임베딩했습니다:
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
이 시점에서 저는 문서화된 모든 OSM 태그에 대한 임베딩을 포함한 데이터베이스와, 이를 검색할 수 있는 방법을 갖추게 되었습니다.
2주차: RAG 및 CLI (8~14일차)
8~9일차: 예시 데이터베이스 구축
OSM 태그를 임베딩하는 것과 _필터 예시 (filter examples)_를 임베딩하는 것은 별개의 문제입니다. 저는 자연어 질의 (natural-language query), JSON AST, 추출된 태그, 그리고 자연어 질의의 임베딩을 위한 컬럼을 가진 filter_examples라는 테이블을 생성했습니다.
또한 일반 텍스트 파일에서 예시를 추출하는 파서 (parser)를 작성했습니다:
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
이 파서는 User: 라인, Assistant: 라인, 그리고 JSON 중괄호를 감지하는 상태 머신 (state machine)입니다. 가장 우아한 파서는 아니지만, 제가 가진 200여 개의 예시에는 충분히 작동합니다.
10~11일차: 검색 함수
사용자 질의를 임베딩하고 저장된 모든 예시와 코사인 유사도 (cosine similarity)를 계산하는 search_filter_examples()를 구현했습니다:
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
코사인 유사도 (cosine similarity) 함수는 간단합니다:
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
Day 12–14: CLI 엔트리 포인트 (The CLI Entry Point)
모든 것을 하나로 묶기 위해 func_cli.py를 구축했습니다. 이 파일은 --request와 --model 경로를 입력받아, 예시를 가져오고, 프롬프트 (prompt)를 생성하며, LLM을 호출한 뒤 결과를 출력합니다.
uv run osm-functions --request "Find only restricted areas" --model /path/to/model.gguf
이것은 진실의 순간이었습니다. 그리고 첫 실행 결과는—실패였습니다.
Week 3: 검증 및 다듬기 (Validation & Polish) (Days 15–21)
Day 15–16: 중단 토큰 재앙 (The Stop Token Disaster)
계속해서 빈 응답이 반환되었습니다. LLM이 아무것도 생성하지 않는 것이었습니다. 원시 프롬프트 (raw prompt)와 응답을 확인하기 위해 --verbose 옵션을 추가했고, 모델이 JSON 앞에 빈 줄을 생성하고 있다는 사실을 깨달았습니다. 그 빈 줄이 stop=["\n\n"]을 트리거하여, 출력이 시작되기도 전에 끊어버린 것입니다.
해결책은 간단했습니다: 중단 토큰 (stop token)을 완전히 제거하는 것이었습니다.
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
또한 폴백 (fallback) 로직을 추가했습니다: 응답이 비어 있으면 "{}"를 반환하여 호출자가 이를 처리하도록 했습니다.
Day 17–18: 후보 검증 (Candidate Validation)
관련성에 따라 후보 OSM 태그 (OSM tags)를 필터링하기 위해 build_osmtags_validate_prompt()를 추가했습니다. LLM은 후보 목록(key, value, description)을 전달받고, 관련 있는 ID들의 JSON 배열을 출력합니다.
이는 해상 대 육상과 같은 도메인 구분에서 매우 중요했습니다: seamark:type=restricted_area가 육상 기반 쿼리에는 나타나지 않아야 하기 때문입니다.
Day 19–20: 합성 지침 (Synthesis Instructions)
LLM이 태그의 조합이 더 적절한 경우에도 예시를 맹목적으로 복사하는 경우가 있다는 것을 깨달았습니다. 저는 합성을 강조하도록 프롬프트를 다시 작성했습니다:
"만약 예시들이 사용자 요청에 적용될 수 있는 서로 다른 태그들을 보여준다면, 그것들을 하나의 필터로 결합하십시오. 맹목적으로 복사하지 말고—적응시키십시오."
또한 신뢰도 임계값 (confidence threshold)을 추가했습니다: 만약 top-k 유사도 점수가 0.4 미만이면, LLM은 명확한 설명을 요청하도록 지시받습니다.
Day 21: 에이전트 루프 (The Agent Loop)
마지막 조각은 다음과 같은 단순한 루프였습니다:
- 필터 생성기(filter generator) 호출
- 필터 실행 (
execute_osmfilter()를 통해) - 만약 특징(feature) 개수가 0이면, 요청 범위를 넓혀서 다시 시도
- 모든 결정 사항을 로그(log)에 기록
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
이 루프는 정적인 생성기를 적응형 탐색기(adaptive explorer)로 변모시킵니다.
내가 배운 것들 (고생하며 얻은 교훈)
중단 토큰(Stop tokens)은 당신의 편이 아닙니다. 모델이 유효한 콘텐츠 내부에서 중단 토큰을 절대 출력하지 않을 것이라고 100% 확신할 수 없다면, 이를 피하십시오. 대신 max_tokens를 사용하고 출력을 파싱(parse)하십시오.
임베딩(Embedding) 품질이 중요합니다. 전체 OSM 태그 설명을 JSON 문자열로 임베딩하는 것도 작동하지만, _"key=highway, value=primary, description: A major road"_와 같은 자연어 문장이 더 나은 결과를 보여주었습니다.
예시 데이터베이스가 전부입니다. 저의 초기 데이터 세트는 해양(maritime) 태그에 심하게 편향되어 있었습니다. 육상 기반 예시(주차, 접근, 경계 등)를 추가한 후, LLM의 선택은 더욱 문맥을 잘 파악하게 되었습니다.
상세한 출력(Verbose output)은 최고의 디버깅 도구입니다. --verbose 옵션이 없었다면, 중단 토큰 버그를 잡느라 며칠을 허비했을 것입니다.
에이전트 루프는 단순하지만 강력합니다. 50줄 미만의 코드임에도 불구하고, 단발성 생성기를 동적이고 적응형인 시스템으로 바꿔 놓습니다. 모든 시도는 요청, 검색된 예시, 생성된 필터, 특징 개수와 함께 로그에 기록됩니다. 이러한 감사 추적(audit trail)은 매우 가치 있습니다.
현재 상태
시스템은 작동합니다. 여기서 "작동한다"는 정의는 "여전히 더 많은 예시, 더 나은 프롬프트 엔지니어링(prompt engineering), 그리고 몇 가지 예외 케이스(edge cases)의 수정이 필요하다"는 것을 포함합니다.
CLI는 대부분의 단순한 쿼리에 대해 유효한 JSON 필터를 출력합니다. 에이전트 루프는 요청 범위를 넓힘으로써 빈 결과(empty results)를 처리할 수 있습니다. 그리고 이 모든 과정은 8GB 미만의 VRAM을 가진 단일 GPU에서 실행됩니다.
여전히 개선이 필요한 부분:
- 합성 (Synthesis): 여러 태그를 결합하는 것(예:
maxspeed+surface)은 여전히 완벽하지 않습니다. - 부정 (Negation): LLM은 "고속도로가 아닌" 스타일의 쿼리를 처리하는 데 어려움을 겪습니다.
- 예시 다양성 (Example diversity): 더 많은 예시가 필요하며, 특히 여러 태그가 포함된 복잡한 예시가 필요합니다.
- 신뢰도 처리 (Confidence handling): 0.4 임계값(threshold)은 추측에 불과합니다. 실제 데이터를 통해 이를 미세 조정 (fine-tune)해야 합니다.
실제로 사용할 수 있는 코드 스니펫 (Code Snippets)
RAG 검색 함수
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
JSON 추출 함수
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
CLI 엔트리 포인트 (단순화 버전)
# author: Jan Tschada
# SPDX-License-Identifer: Apache-2.0
...
다음 단계 계획
- 공간 연산자 (Spatial operators): "병원에서 5km 이내"와 같은 쿼리
- Wikidata 통합 (Wikidata integration): 사실 정보를 통해 OSM 피처 (features)를 풍부하게 함
- 더 나은 합성 (Better synthesis): 태그를 더 지능적으로 결합
- 오픈 소스 (Open-source): 예외 케이스 (edge cases)가 해결되면 공개 (현재는 기술적 검증 단계인 technical spike)
여러분께 드리는 질문
- 지정학적 에이전트 워크플로우 (geospatial agentic workflows)에서 태그의 모호성을 어떻게 처리하시나요?
- LLM에게 명확한 설명을 요청할 것인지, 아니면 최선의 추측을 할 것인지 결정하는 임계값 (threshold)은 무엇인가요?
- 지정학적 데이터를 위한 RAG 파이프라인을 구축해 보신 적이 있나요?
여러분의 경험을 진심으로 듣고 싶습니다.
링크:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기