본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 01. 11:25

'FiDi', 'Wall Street area', 'Lower Manhattan'을 하나의 동네로 정규화하기 위한 6단계 레이어

요약

사용자가 입력한 다양한 지명 표현을 하나의 표준화된 동네 이름으로 정규화하기 위한 6단계 레이어 아키텍처를 소개합니다. 공간 역지오코딩과 Gemini를 활용한 별칭 마이닝을 결합하여 데이터의 일관성을 확보하는 방법을 다룹니다.

핵심 포인트

  • 단일 LLM 호출 대신 다층 레이어 구조를 통한 정규화 접근
  • 역지오코딩을 활용한 물리적 위치 기반의 공간 정규화
  • Gemini를 이용한 도시별 비공식 별칭 사전 구축
  • 좌표가 없는 텍스트 데이터 처리를 위한 별칭 마이닝 전략

문제점

한 사용자가 저희의 여행 붙여넣기 검증기 (travel-paste validator)에 자신의 여행 목록을 붙여넣었습니다. 그들은 친구, 블로그, AI 일정, 그리고 Reddit 스레드로부터 추천을 받았습니다. 목록에는 Lower Manhattan가 네 가지 방식으로 언급되어 있습니다:

  • "FiDi"
  • "Wall Street area"
  • "Financial District NYC"
  • "Lower Manhattan financial district"

저희의 '동네 (Neighborhoods)' 탭은 사용자가 한 번에 하루 단위로 계획을 세울 수 있도록 지역별로 그룹화합니다. 정규화 (Canonicalization)가 없다면, 이 네 가지 라벨은 각각 하나의 장소만을 포함하는 네 개의 지역 카드가 됩니다. 사용자는 이들이 모두 도보로 이동 가능한 하나의 구역이라는 것을 알 수 없습니다. 제품이 고장 난 것처럼 보입니다.

이것은 작은 예시일 뿐입니다. 145개 도시에 걸친 전체 범위는 도시당 수천 개의 변형을 포함합니다. 격식 vs 비격식, 영어 vs 현지어, Wikipedia 제목 vs 행정 구역, 폴리곤 (Polygon) 수준의 세밀함 vs 가이드북 어휘 등이 그것입니다. 단 한 번의 API 호출이나 단 한 번의 LLM 호출로 이를 해결하려는 것은 명백히 잘못된 접근입니다.

저희는 이를 6개의 레이어로 해결했습니다. 각 레이어는 다음 레이어가 볼 수 없는 변형 클래스를 처리합니다.

레이어 1: 공간 역지오코딩 (Spatial reverse-geocode)

검증된 항목에 (lat, lng)가 있는 경우, 폴리곤 기반의 이름이 상위 Google Places 매칭에서 반환된 텍스트 라벨 무엇보다 우선합니다.

// lib/providers/places/spatial-reverse.ts
const r = await fetchAddressDescriptors(lat, lng, GOOGLE_GEOCODING_API_KEY);
const areas = r?.addressDescriptors?.areas ?? [];
...

Google 호출이 실패하면, types=neighborhood,locality,place를 사용하는 Mapbox v6 역지오코딩으로 넘어갑니다. 결과는 반올림된 좌표(소수점 4자리, 약 10m 정밀도)별로 캐싱(Cached)되므로, 동일한 동네에 있는 인접한 항목들은 단일 과금 호출을 공유하게 됩니다.

이를 통해 사소한 분리를 통합합니다. 물리적으로 Tribeca 내에 위치한 두 항목이, 하나는 "Holland Tunnel 출구 근처"로 붙여넣어지고 다른 하나는 "Mysterious Bookshop 옆"으로 붙여넣어지더라도 둘 다 "Tribeca"라는 라벨로 반환됩니다.

레이어 2: Gemini를 통한 대량 별칭 마이닝 (Bulk alias mining via Gemini)

공간 패스(Spatial pass)는 좌표가 있는 항목을 처리합니다. 실제 데이터 중 약 2.5%는 제목만으로는 어떤 장소와도 매칭되지 않아 좌표가 없습니다(예: "BoCoCa에 있는 우리 친구의 아파트", "Marais에 있는 Maya가 추천한 레스토랑"). 이러한 경우, 우리는 도시별 별칭 사전(per-city alias dictionary)에 의존합니다.

우리는 도시당 단 한 번의 Gemini 2.5 Flash 호출을 통해 사전을 마이닝했습니다:

// scripts/places/mine-aliases-gemini.mjs
const prompt = `For each canonical neighborhood in ${city}, list the
informal abbreviations, prefix-stripped forms, article variations,
...

우리는 이를 먼저 Wikidata Action API를 대상으로 시도했습니다. 익명의 wbsearchentities + wbgetentities 흐름은 부하가 걸릴 경우 약 1초당 1회의 요청으로 속도 제한(rate-limit)이 걸리며, 11초의 Retry-After 헤더가 반환됩니다. 약 75,000개의 정규 항목(canonical entries)에 대해 각각 약 4번의 호출을 수행할 경우, 실제 소요 시간(wall-clock cost)은 며칠이 걸립니다.

Gemini 호출은 환각 방지(hallucination guards) 장치를 갖춘 상태에서 한 번의 호출로 도시 전체의 별칭을 반환합니다. 반환된 별칭이 동일한 도시 내의 다른 정규 항목과 일치하는 경우(이들은 서로 다른 동네임), 해당 별칭은 제외됩니다. 또한, 정규 항목에 "X and Y"가 포함되어 있지 않은데 별칭에 "X and Y"가 포함된 경우(예: "Camden Town and Regent's Park"와 같은 유사 조합)에도 제외됩니다.

결과: FiDi → Financial District. DUMBO → Down Under the Manhattan Bridge Overpass. Le Marais → Marais. La Roma → Roma. 渋谷 → Shibuya. NYC FiDi의 4가지 철자 예시는 이 레이어에서 하나로 통합됩니다.

레이어 3: Overture Maps 폴리곤 점-내-폴리곤 (polygon point-in-polygon)

공간 역지오코딩(Spatial reverse-geocode)은 좌표를 처리합니다. 별칭 사전은 라벨을 처리합니다. 하지만 Google Places 결과가 올바른 폴리곤(polygon)을 반환하되 행정 세분화(administrative granularity) 수준이 잘못된 경우는 둘 다 처리하지 못합니다.

NYC(뉴욕시)는 전형적인 사례입니다. Overture의 NYC 동네 폴리곤(polygon)은 커뮤니티 보드(Community Board) 세분화 수준으로 되어 있습니다. CB 5는 미드타운 이스트(Midtown East), 미드타운 웨스트(Midtown West), 그리고 머레이 힐(Murray Hill)을 모두 포함하는데, 이는 가이드북에 나오는 세 개의 동네가 하나의 행정 구역으로 합쳐진 것입니다. 만약 우리가 폴리곤의 원문 이름을 아이템에 그대로 썼다면, 사용자는 자신이 관심 있는 세 개의 뚜렷한 동네 대신 "Manhattan Community Board 5"라는 지역 카드를 보게 되었을 것입니다.

우리는 릴리스(release)마다 Overture의 S3를 대상으로 DuckDB를 사용하여 폴리곤을 한 번 미리 구워둡니다(pre-bake):

duckdb -c "
  COPY (
    SELECT id, names.primary AS name, subtype, bbox, geometry
...

검증(validate) 시점에, 우리는 해당 도시의 .geo.json을 지연 로딩(lazy-load)하고, 미리 계산된 bbox(bounding box)로부터 flatbush R-tree를 구축하며, @turf/boolean-point-in-polygon을 실행하여 모든 포함 폴리곤을 찾은 뒤, 서브타입 순위(subtype rank)에 따라 가장 구체적인 것을 선택합니다:

const SUBTYPE_RANK: Record<string, number> = {
  microhood: 0,
  neighborhood: 1,
...

도시별 "너무 거친(too-coarse)" 건너뛰기 패턴(skip patterns)은 세분화 수준이 저하되는 것을 방지합니다. NYC는 커뮤니티 보드 이름을 건너뛰어 LLM 리졸버(resolver, 레이어 5)가 더 구체적인 추측을 유지할 수 있게 합니다. 런던은 "City of Westminster"와 같은 자치구(borough) 이름을 건너뛰는데, 이 단일 자치구가 Mayfair / Soho / Marylebone / Covent Garden / Westminster / St James's라는 6개의 가이드북 동네를 포함하기 때문입니다. 멕시코시티는 13개의 알칼디아(alcaldía, 구) 이름을 건너뜁니다.

건너뛰기 목록(skip list)은 서브타입 기준이 아닌 이름 기준으로 작성되는데, 이는 Overture가 가끔 행정 구역을 neighborhood 서브타입으로 라벨링하기 때문입니다.

레이어 4: POI 가제티어 (Wikidata 기반)

어떤 명칭들은 그 자체로 동네를 알려줄 만큼 유명합니다. "Whitney Museum of American Art"는 미트패킹 디스트릭트(Meatpacking District)를 암시하며, "Tate Modern"은 사우스 뱅크(South Bank)를 암시합니다. 우리는 Wikidata SPARQL로부터 도시별 (title → neighborhood) 가제티어(gazetteer)를 추출했습니다:

SELECT ?poi ?poiLabel ?neighborhood ?neighborhoodLabel WHERE {
  VALUES ?landmarkType { wd:Q33506 wd:Q1244442 wd:Q12876 wd:Q35112 wd:Q860861 }
  ?poi wdt:P31/wdt:P279* ?landmarkType .
...

이를 통해 15개 도시에 걸쳐 약 8,600개의 항목을 확보했습니다. 조회(Lookup)는 foldKey-exact 방식으로만 수행됩니다 (부분 문자열 매칭(substring matching)을 한 차례 시도해 보았으나, 실제 파리(Paris) 감사 데이터에서 23건 중 14건이 거짓 양성(false positives)으로 나타나, 정확히 일치하는 방식(exact-only)을 보수적인 선택지로 채택했습니다). 지명 사전(gazetteer) 파일은 최초 로드 후 모듈 캐싱(module-cached)됩니다.

이는 레이어 3(Layer 3)의 폴리곤(polygons)이 잡아낼 수 없는 롱테일(long tail) 사례를 포착합니다. 예를 들어, 좌표나 동네 이름 없이

다섯 개의 레이어를 모두 거친 후에도, 폴리곤(polygon)의 실제 이름이 가이드북의 표준 명칭(canonical)과 일치하지 않는 경우가 때때로 발생합니다. Overture는 Polanco를 "Polanco 3ª Sección", "Polanco 4ª Sección", "Polanco 5ª Sección"로 세분화합니다. 파리의 구(arrondissements)는 50개 이상의 다양한 형태("14th arrondissement of Paris", "Paris 14e Arrondissement", "14th Arrondissement")로 나타납니다.

도시별로 작성된 작은 맵(map)은 foldKey 조회를 통해 각 폴리곤-실제 명칭 세분화 항목을 가이드북 표준 명칭으로 통합합니다:

export const CANONICAL_CONSOLIDATIONS: Record<string, Record<string, string>> = {
  'mexico city|MX': {
    'polanco 3a seccion': 'Polanco',
...

기존의 표준 명칭으로 통합되어야 하는 새로운 폴리곤 실제 명칭이 verify-pass-d에서 발견될 경우, 해결 방법은 LLM이 이를 확률적으로 처리하도록 가르치는 것이 아니라 이곳에 해당 항목을 추가하는 것입니다.

왜 하나의 LLM 호출이 아니라 6개의 레이어인가

단일 LLM 호출은 개별적인 사례에 대해서는 작동할 것입니다. 하지만 검증당 비용이 더 많이 들고, 시간이 더 오래 걸리며, 때때로 환각(hallucination)을 일으키고, 모델이 릴리스 사이에 변할 때(drift) 조용히 성능이 저하될 수 있습니다.

각 결정론적(deterministic) 레이어는 변형 사례들을 더 저렴하고 안정적인 방식으로 처리합니다:

레이어강점비용
1. 공간 역지오코딩 (Spatial reverse-geocode)좌표가 존재할 때 정확한 답변 제공고유 좌표당 약 1회의 Geocoding API 호출
...

LLM은 1단계가 아니라 5단계입니다. 왜냐하면 LLM 단계에 도달할 때쯤이면 레이블의 95%가 이미 결정론적으로 해결되었기 때문입니다. 나머지 5%가 바로 LLM의 강점인 철자 간 패턴 매칭(pattern-matching)이 가장 중요하게 작용하는 사례들입니다.

동일한 논리가 여행 소프트웨어의 대부분의 "데이터가 지저분한(messy)" 문제에도 적용됩니다. 실제 사용자가 다양하기 때문에 실제 붙여넣기 데이터도 이질적입니다. 런던 여행 데이터에는 지하철역 이름, 위키피디아 제목, 블로그의 재구성된 표현, Reddit의 약어 등이 동일한 붙여넣기 데이터 안에 모두 섞여 있습니다. 깨끗한 동네(Neighborhoods) 탭을 만드는 가장 저렴한 경로는 레이어를 순서대로 벗겨내는 것입니다: 기하학(geometry)을 먼저, 사전(dictionaries)을 그다음, 구조화된 카탈로그(structured catalogs)를 세 번째, 그리고 LLM을 마지막으로 배치하는 것입니다.

결과를 확인하고 싶다면, 새로운 여행 (a new trip)에 무엇이든 엉망인 내용을 붙여넣고 Neighborhoods 탭을 열어보세요. 정규화 (canonicalization)는 모든 검증 (validate) 단계에서 실행됩니다. 우리가 무엇과 매칭하고 있는지 확인하고 싶다면 전체 정규화 가이드북(canonical guidebook)이 있는 우리의 목적지 페이지 (our destinations page)를 참조하거나, LLM이 생성한 여행 계획에 동일한 파이프라인 (pipeline)이 적용된 사례를 보고 싶다면 ChatGPT 일정 (a ChatGPT itinerary)을 확인해 보세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0