요즘 언론인의 역할은 아무리 강조해도 지나치지 않죠. 우리 모두는 인류의 위험을 기록하고 경고하기 위해 비판적이고 정직한 저널리즘에 의존하고 있으니까요.
하지만 뉴스는 단지 첫 페이지에 보이는 기사 그 이상이에요. 많은 세부 정보는 즉시 표시되지 않는 메타데이터 뒤에 숨겨져 있답니다.
만약 여러분이 뉴욕 타임스의 첫 페이지를 본다면, 움직이는 기사와 이미지를 많이 볼 수 있을 거예요.
New York Times – 속보, 미국 뉴스, 세계 뉴스 및 비디오
오늘은 NYTimes API를 사용해서 일부 기사 메타데이터에 액세스하는 비하인드 스토리를 한번 살펴보고자 해요.
우리 동료 윌 리옹이 인기 기사의 API에 대한 데이터 모델 및 가져오기 스크립트와 Neo4j 브라우저 가이드가 포함된 저장소를 준비해뒀어요. 그는 원래 탐사 저널리즘 컨퍼런스(NICAR)를 위한 데이터 탐색을 만들었답니다.
거기로 가서 따라하시면 돼요.
GitHub – johnymontana/news-graph: 지식 그래프로서의 New York Times 기사
먼저 AuraDB 무료 인스턴스를 생성해 볼게요.
'빈 데이터베이스'를 선택하세요.
연결-aura.adoc
개발자 API
먼저, NYTimes 개발자 API를 클릭하고 앱을 추가해서 API 키를 받으세요.
그들은 다양한 API를 제공하는데요, 도서 및 영화 리뷰, 의미 카테고리, 기사 아카이브 메타데이터, 인기 기사 등이 포함되어 있어요.
작업을 단순하게 유지하기 위해 지난 7일 또는 30일 동안 가장 인기 있는 기사를 제공하는 JSON 엔드포인트인 간단한 '인기 기사' API를 살펴볼게요. 기간을 조정하려면 URL의 숫자를 변경하면 된답니다.
;
여기에서 응답의 어떤 부분이 보이는지 확인할 수 있어요.
{
"status": "OK",
"copyright": "Copyright (c) 2022 The New York Times Company. All Rights Reserved.",
"num_results": 20,
"results": [{
"uri": "nyt://article/8428defe-c56e-5177-8798-ea2fbc3ef715",
"url": "https://www.nytimes.com/2022/04/06/us/politics/us-russia-malware-cyberattacks.html",
"id": 100000008282002,
"asset_id": 100000008282002,
"source": "New York Times",
"published_date": "2022-04-06",
"updated": "2022-04-07 10:45:47",
"section": "U.S.",
"subsection": "Politics",
"nytdsection": "u.s.",
"adx_keywords": "Russian Invasion of Ukraine (2022);Cyberwarfare and Defense;United States International Relations;Espionage and Intelligence Services;Biden, Joseph R Jr;Justice Department;Federal Bureau of Investigation;GRU (Russia);Russia;Ukraine",
"column": null,
"byline": "By Kate Conger and David E. Sanger",
"type": "Article",
"title": "U.S. Says It Secretly Removed Malware Worldwide, Pre-empting Russian Cyberattacks",
"abstract": "The operation is the latest effort by the Biden administration to thwart actions by Russia by making them public before Moscow can strike.",
"des_facet": [
"Russian Invasion of Ukraine (2022)",
"Cyberwarfare and Defense",
"United States International Relations",
"Espionage and Intelligence Services"
],
"org_facet": [
"Justice Department",
"Federal Bureau of Investigation",
"GRU (Russia)"
],
"per_facet": [
"Biden, Joseph R Jr"
],
"geo_facet": [
"Russia",
"Ukraine"
],
"media": [{
"type": "image",
"subtype": "photo",
"caption": "Some American officials fear that President Vladimir V. Putin of Russia may be biding his time in launching a major cyberoperation that could strike a blow at the American economy.",
"copyright": "Mikhail Klimentyev/Sputnik",
"approved_for_syndication": 0,
"media-metadata": [{
"url": "https://static01.nyt.com/images/2022/04/06/us/politics/06dc-russia-hacks-1/merlin_204742779_ca6a0b7b-3630-426c-9ee7-77628e11521b-mediumThreeByTwo440.jpg",
"format": "mediumThreeByTwo440",
"height": 293,
"width": 440
}
]
}],
"eta_id": 0
},
이건 우리가 그래프로 가져오거나 변환해서 탐색할 기사 데이터와 메타데이터에요.
데이터 모델
데이터 모델은 API에서 반환되는 데이터를 기반으로 하고 있어요.
주요 Article node는 다음과 같은 properties를 가지고 있죠:
- title
- id
- url
- source
- published_date
- abstract
다른 section들도 있지만, 지금은 무시하도록 할게요.
각 기사에 대한 메타데이터도 얻을 수 있어요.
- 주제 (
des_facet) - 조직 (
org_facet) - 사람들 (
per_facet) - 위치 (
geo_facet) - 사진 (
media)
이러한 메타데이터 항목들은 자체 node로 변환될 수 있고, relationship을 통해 기사에 연결될 수 있어요 (모델 다이어그램을 참고!). 그렇게 하면 해당 node들을 통해 기사를 연관시키고 관련지을 수 있겠죠?
Import
우리는 브라우저에서 JSON API의 직접적인 응답을 확인했어요. Neo4j에 데이터를 로드하기 위해 apoc.load.json을 활용할 수 있는데, 이건 API의 응답을 Cypher 데이터 구조로 제공하는 사용자 정의 procedure에요.
AuraDB에서도 사용할 수 있는 procedure 중 하나랍니다.
데이터를 가져오는 것부터 시작해 볼까요?
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key=<api-key>")
Neo4j Browser에서는 실제로 API key를 :param key⇒'<api-key>'를 사용해서 parameter로 설정할 수 있어요. 그러면 query에 $key를 사용할 수 있어서 key를 노출할 필요가 없죠.
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key="+$key)
이전과 동일하게 보이는 것을 볼 수 있죠? 지금 우리가 해야 할 일은 value 결과를 반복하고 results를 정렬하는 거예요.
그걸로 해보자구요! UNWIND (목록을 행으로 변환)를 사용해서 전체 항목 대신 개별 부분을 반환해볼게요.
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key="+$key)
yield value
unwind value.results as article
return article.id, article.title, article.published_date, article.byline, article.geo_facet, article.des_facet;
이제 이게 바로 기사 `Node`를 만드는 데 사용할 수 있는 데이터랍니다.
╒════════════╤════════════╤════════════╤════════════╤════════════╕
│"title" │"article.pub│"article.byl│"article.geo│"article.des│
│ │lished_date"│ine" │_facet" │_facet" │
╞════════════╪════════════╪════════════╪════════════╪════════════╡
│"Satellite i│"2022-04-04"│"By Malachy │["Ukraine","│["Russian In│
│mage" │ │Browne, Davi│Russia","Buc│vasion of Uk│
│ │ │d Botti and │ha (Ukraine)│raine (2022)│
│ │ │Haley Willis│","Kyiv (Ukr│","Civilian │
│ │ │" │aine)"] │Casualties"]│
├────────────┼────────────┼────────────┼────────────┼────────────┤
│"Grammys 202│"2022-04-03"│"By Shivani │[] │["Grammy Awa│
│2 Wi" │ │Gonzalez" │ │rds","Gospel│
│ │ │ │ │ Music","Fol│
│ │ │ │ │k Music","Cl│
│ │ │ │ │assical Musi│
│ │ │ │ │c","Pop and │
│ │ │ │ │Rock Music",│
│ │ │ │ │"Blues Music│
│ │ │ │ │","Jazz"] │
우리는 MERGE를 사용할 건데요, 이건 가져오기 또는 생성(upsert)이므로 데이터가 이미 그래프에 존재하는 경우 다시 추가되지 않고 문에 제공될 뿐이에요.
기사를 병합하는 핵심은 URL이에요. URL은 전 세계적으로 고유해야 하고, SET은 나머지 속성들이죠.
(더 큰 데이터 볼륨과 일관성을 위해 제약 조건도 만들지만 여기서는 건너뛸게요.)
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key="+$key) yield value
unwind value.results as article
// uniquely create an article
MERGE (a:Article {url: article.url})
SET a.title = article.title,
a.abstract = article.abstract,
a.published = datetime(article.published_date),
a.byline = article.byline,
a.id = article.id
a.source = article.source;
이제 왼쪽 사이드바로 이동해서 Article 라벨을 보면 공간에 떠 있는 수많은 외로운 기사 `Node`를 볼 수 있을 거예요. 속성별로 푸시하고 `Query`할 수 있지만 아직 관계를 추적할 수는 없죠.
// find some articles
MATCH (n:Article) RETURN n LIMIT 25;
// find articles by date
MATCH (n:Article)
WHERE n.published = datetime("2022-04-08")
RETURN n LIMIT 25;
// find articles with matching (case sensitive) title contents
MATCH (n:Article)
WHERE n.title contains 'Bucha'
RETURN n.title, n.byline
LIMIT 25;
다음 단계에서는 먼저 관계를 추가하기 시작할 거예요. Topic에 포함된 `Node`는 des_facet 기사 응답의 배열이에요.
FOREACH는 해당 배열을 반복해서 `Node`를 생성하고 이를 현재 기사에 연결해줘요.
우리는 또한 SET to ON CREATE SET을 사용해서 속성은 `Node`가 처음 생성될 때만 추가되도록 할 거예요.
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key="+$key) yield value
unwind value.results as article
MERGE (a:Article {url: article.url})
ON CREATE SET a.title = article.title,
a.abstract = article.abstract,
a.published = datetime(article.published_date),
a.byline = article.byline,
a.id = article.id,
a.source = article.source
FOREACH (desc IN article.des_facet |
MERGE (d:Topic {name: desc})
MERGE (a)-[:HAS_TOPIC]->(d)
)
주제 중복 및 추천
이 쿼리는 벌써 몇 가지 흥미로운 부분을 보여주는데요. 첫째, 하나 이상의 주제를 공유하지만 다른 기사들과는 전혀 연결되지 않은 기사들의 클러스터가 나타나는 걸 확인할 수 있어요.
HAS_TOPIC 관계를 왼쪽 사이드바에서 선택하고 제한을 없애면 다음과 같은 내용을 볼 수 있습니다. 전체 화면 모드로 들어가서 축소해서 전체적인 그림을 한번 살펴볼까요?
이건 기사들의 유사성을 연결해줄 뿐만 아니라, 추천을 위해 "콘텐츠 기반" 유사성을 사용하는 첫 번째 방법이 되기도 해요.
패턴을 그려보면 (a1:Article)-[:HAS_TOPIC]->(topic)<-[:HAS_TOPIC]-(a2:Article) 이건 기사들 간에 공유되는 주제를 나타내죠.
이걸 사용해서 이전에 봤던 것처럼 시각적으로 클러스터를 찾거나, 한 쌍의 기사가 공유하는 주제의 수를 계산할 수도 있어요.
패턴 앞에 MATCH를 붙인 다음, 기사 쌍당 주제 수를 계산해볼게요.
MATCH (a1:Article)-[:HAS_TOPIC]->(topic)<-[:HAS_TOPIC]-(a2:Article)
// exclude opposite pair
WHERE id(a1)<id(a2)
RETURN a1.title, a2.title, count(topic) as overlap
ORDER BY overlap DESC limit 5;
╒══════════════════════╤══════════════════════╤═════════╕
│"title1" │"title2" │"overlap"│
╞══════════════════════╪══════════════════════╪═════════╡
│"A New Wave of Covid-"│"A New Covid Mystery" │4 │
├──────────────────────┼──────────────────────┼─────────┤
│"Russia Asked China f"│"U.S. Officials Say S"│3 │
├──────────────────────┼──────────────────────┼─────────┤
│"Satellite images sho"│"A makeup artist reco"│2 │
├──────────────────────┼──────────────────────┼─────────┤
│"Satellite images sho"│"Russian soldiers ope"│2 │
├──────────────────────┼──────────────────────┼─────────┤
│"New York Judge Dies "│"Sarah Lawrence Cult "│2 │
└──────────────────────┴──────────────────────┴─────────┘
만약 시작 기사를 고정한다면 (예를 들어, 지금 읽고 있는 기사처럼요!), 해당 주제와 겹치는 (유사한) 기사들을 추천해줄 수 있겠죠.
기사가 어떤 주제와 겹치는지 확인하기 위해 collect 이름(집계 함수)도 목록에 추가해볼게요.
MATCH (a1:Article)-[:HAS_TOPIC]->(topic)<-[:HAS_TOPIC]-(a2:Article)
// exclude opposite pair
WHERE id(a1)<id(a2)
RETURN a1.title, a2.title, count(topic) as score, collect(topic.name) as topics
ORDER BY overlap DESC limit 5;
╒══════════════════╤══════════════════╤═════════╤══════════════════╕
│"title1" │"title2" │"overlap"│"topics" │
╞══════════════════╪══════════════════╪═════════╪══════════════════╡
│"A New Wave of Cov│"A New Covid Myste│4 │["Coronavirus (201│
│id-" │ry" │ │9-nCoV)","Disease │
│ │ │ │Rates","Tests (Med│
│ │ │ │ical)","Coronaviru│
│ │ │ │s Omicron Variant"│
│ │ │ │] │
├──────────────────┼──────────────────┼─────────┼──────────────────┤
│"Russia Asked Chin│"U.S. Officials Sa│3 │["Embargoes and Sa│
│a f" │y S" │ │nctions","Russian │
│ │ │ │Invasion of Ukrain│
│ │ │ │e (2022)","United │
│ │ │ │States Internation│
│ │ │ │al Relations"] │
기타 메타데이터
마찬가지로 다른 메타데이터도 Geo, Person, Organization `Node`에 적절한 `Relationship`을 가질 수 있어요.
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/7.json?api-key="+$key) yield value
unwind value.results as article
MERGE (a:Article {url: article.url})
ON CREATE SET a.title = article.title,
a.abstract = article.abstract,
a.published = datetime(article.published_date),
a.byline = article.byline,
a.id = article.id,
a.source = article.source
FOREACH (desc IN article.des_facet |
MERGE (d:Topic {name: desc})
MERGE (a)-[:HAS_TOPIC]->(d)
)
FOREACH (per IN article.per_facet |
MERGE (p:Person {name: per})
MERGE (a)-[:ABOUT_PERSON]->(p)
)
FOREACH (org IN article.org_facet |
MERGE (o:Organization {name: org})
MERGE (a)-[:ABOUT_ORGANIZATION]->(o)
)
FOREACH (geo IN article.geo_facet |
MERGE (g:Geo {name: geo})
MERGE (a)-[:ABOUT_GEO]->(g)
)
MERGE 구문을 사용하면 멱등성이 보장돼요. 즉, 원하는 만큼 자주 실행해도 새로운 데이터만 추가된다는 거죠.
새로운 상관 관계를 이끌어내는 새로운 메타데이터 `Node`를 통해 그래프가 더욱 풍성해졌어요.
다시 말하지만, 이걸 사용해서 추천 시스템을 개선할 수 있어요.
중복 횟수에 가중치를 줄 수도 있지만, 독자의 선호도나 중요도에 따라 메타데이터 항목별로 계산할 수도 있는 점수로 생각할 수 있겠죠?
예를 들어볼게요.
MATCH (a1:Article)-[:HAS_TOPIC]->(topic)<-[:HAS_TOPIC]-(a2:Article) WHERE id(a1)<id(a2) (a1:article)-[:about_geo]-="" a1,a2,="" as="" count(topic)="" for="" geo="" match="" optional="" overlap="" score="" topic="" topicscore="" with="">(geo)<-[:ABOUT_GEO]-(a2:Article) WITH a1,a2, topicScore, count(geo) as geoScore // score for people overlap OPTIONAL MATCH (a1:Article)-[:ABOUT_PERSON]->(person)<-[:ABOUT_PERSON]-(a2:Article) WITH a1,a2, topicScore, geoScore, count(person) as personScore // compute total score with weights RETURN a1.title, a2.title, topicScore*5+geoScore*2+personScore*1.5 as score ORDER BY score DESC limit 5; ╒══════════════════════╤══════════════════════╤═══════╕ │"title1" │"title2" │"score"│ ╞══════════════════════╪══════════════════════╪═══════╡ │"A New Wave of Covid-"│"A New Covid Mystery" │22.0 │ ├──────────────────────┼──────────────────────┼───────┤ │"Russia Asked China f"│"U.S. Officials Say S"│18.5 │ ├──────────────────────┼──────────────────────┼───────┤ │"Satellite images sho"│"A makeup artist reco"│16.0 │ ├──────────────────────┼──────────────────────┼───────┤ │"Russian Blunders in "│"They Died by a Bridg"│16.0 │ ├──────────────────────┼──────────────────────┼───────┤ │"Satellite images sho"│"They Died by a Bridg"│16.0 │ └──────────────────────┴──────────────────────┴───────┘
Article 1이 다음과 연결되어 있다고 다시 상상해 보세요. article id or URL MATCH (a1:Article {id:$articleId}) 사용자가 현재 읽고 있어요. 반환된 Article 목록은 추천 항목이 되는 거죠.
사진 및 미디어
사진은 약간 중첩된 구조로 되어 있고, 동일한 사진의 해상도도 다르기 때문에 반복을 통해 사진을 가져올 수 있어요. media 목록을 작성하고 캡션과 세 번째 이미지 URL을 가져오는 거죠.
Node에 대한 Neo4j 브라우저의 속성 창에서 사진의 URL을 클릭해서 볼 수 있어요.
call apoc.load.json("https://api.nytimes.com/svc/mostpopular/v2/viewed/30.json?api-key="+$key) yield value
unwind value.results as article
MERGE (a:Article {url: article.url})
ON CREATE SET a.title = article.title,
a.abstract = article.abstract,
a.published = datetime(article.published_date),
a.byline = article.byline,
a.id = article.id,
a.source = article.source
...
FOREACH (media in article.media |
MERGE (p:Photo {url: media.`media-metadata`[2].url})
ON CREATE SET p.caption = media.caption
MERGE (a)-[:HAS_PHOTO]->(p)
)
이제 이미지 인식 API를 사용해서 해당 사진을 추가로 분석해서 더 많은 메타데이터를 추가할 수 있어요.
저자
아쉽게도 저자는 메타데이터로 제공되지 않지만, byline 속성으로 제공되는데요, 이건 이런 형태의 문자열이에요: "By Malachy Browne, David Botti and Haley Willis".
다행히 Cypher에는 다양한 문자열 작업이 있어요. 처음 세 글자를 건너뛰고, and를 쉼표로 교체하고, 문자열을 분할해서 작성자 목록을 가져오는 거죠.
그런 다음 각각에 대한 Node를 만들고 Article을 연결해요. 이렇게 하면 저자 이름이 중복되는 것을 완전히 막을 수는 없지만, 아예 없는 것보다는 낫겠죠?
가져오기의 일부로 수행하거나, 여기에 표시된 후처리 단계로 수행할 수 있어요.
MATCH (a:Article)
// string operation to turn string into array of names
WITH a, split(replace(substring(a.byline, 3), " and ", ","), ",") AS authors
UNWIND authors AS author
// uniquely create author node
MERGE (auth:Author {name: trim(author)})
MERGE (a)-[:BYLINE]->(auth);
더 많은 데이터를 가져오기 위해 기간을 7~30일로 변경해서 지난 달의 인기 Article을 모두 가져올 수도 있어요.
측지거리
위치 메타데이터의 지오코딩(위도, 경도)을 위해 또 다른 APOC 절차 호출을 사용해요. 이는 공개 지오코딩 API를 사용해서 이름을 위치로 확인하는 거죠 (openstreetmap).
MATCH (g:Geo)
CALL apoc.spatial.geocodeOnce(g.name) YIELD location
SET g.location =
point({latitude: location.latitude, longitude: location.longitude})
그런 다음 Article이 서로 얼마나 가까운지, 또는 신고된 Article이 현재 위치에 얼마나 가까운지 확인할 수 있고, 이는 시청자가 순위를 매기는 데에도 사용될 수 있겠죠.
여기서는 베를린의 위치를 킬로미터 단위의 거리를 계산하는 출발점으로 사용하고 있어요.
다음 단계
데이터를 확장할 수 있는 다른 방법으로는 초록에서 추가적인 entities를 추출하거나, 실제 기사 텍스트를 가져와 분석하는 방법이 있어요.
opencorporates, wikidata, gdelt 등 다른 출판물이나 소스의 의견과 데이터를 추가하는 것도 좋겠죠.
윌의 저장소를 팔로우하면 세 가지 다른 관점을 살펴볼 수 있어요.
- 데이터를 기반으로 News GraphQL API를 구축하고 있다는 점
- Cloudflare Edge Workers를 사용해서 위치 인식 데이터를 제공한다는 점
- GitHub 확장인 플랫데이터로 접근해서 플랫그래프 웹사이트와 API에서 소스 데이터를 수집하고, GitHub Actions를 사용해서 Neo4j에 기록한다는 점이에요.
뉴스 메타데이터의 세계를 탐험하는 여정이 즐거웠기를 바라며, AuraDB Free에서 그래프 여정을 시작하는 방법에 대한 아이디어를 얻으셨기를 바라요.
벌써 탐험 24주차네요! 다른 비디오와 데이터 세트를 보려면 라이브 스트림 페이지와 GitHub 저장소를 확인해 보세요.
- 재미있는 데이터세트로 무료 AuraDB 탐색하기
- GitHub – neo4j-examples/discoveraurafree: Discover Neo4j AuraDB 무료 라이브 스트림의 예제가 포함된 저장소
</id(a2)>
에이치시스템즈의 LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.
'Ontology & Knowledge Graph' 카테고리의 다른 글
| DXC Career Navigator: 데이터 기반 직원 커리어 개발 및 몰입도 향상 (1) | 2026.06.20 |
|---|---|
| 엔터프라이즈 지식 그래프로 숨겨진 역량 발견하기 (0) | 2026.06.19 |
| 속성 그래프 데이터 모델, 쉽게 풀어드립니다 (1) | 2026.06.19 |
| 5분 인터뷰: Nulli 창립자 겸 CEO, Derek Small에게 듣는 이야기 (0) | 2026.06.18 |
| 그래프로 풀어보는 ESG 보고의 모든 것 (0) | 2026.06.18 |
