좋은 답사에는 좋은 문답이 따른다. 유물 앞에서 물을 줄 알아야 유물이 답하는 법이다. 앞 장의 NER은 자연어와 지식 그래프가 사귀는 여러 길 가운데 하나였을 뿐 — 이 장에서는 문서에서 사실을 캐어 그래프를 채우고, 사람의 말을 Cypher로 통역해 그래프에 묻고, 그래프가 제 스스로 문장을 지어 답하게 하는 법을 배운다. 그리고 답사의 막바지, 영어의 옥편이라 할 WordNet을 통째로 그래프에 들여 말과 말 사이의 촌수 — 의미 유사도 — 를 재는 법까지 나아간다.
앞 장에서 본 NLP는 시맨틱 검색을 받치는 지식 그래프의 구축에 쓰였고, 그 기둥은 엔터티 추출(NER)이었다. 그러나 NER은 NLP가 지식 그래프와 사귀는 여러 방식 중 하나일 뿐이니, 크게 보아 길은 셋이다(그림 13-1).
자연어 텍스트에 NLP를 적용해 얻은 엔터티·사실·지식으로 그래프를 채운다. 12장의 내용에 더해, 질문 응답용 지식 그래프를 세우기 위한 사실 추출(fact extraction)이 여기 든다.
지식 그래프에서 자연어를 생성한다. 질의에 대한 대화체 답변이나 자동 보고서 생성이 그 본보기다 — 이 장의 한복판에서 다룬다.
그 중간 어디쯤 — 지식 그래프가 NLP 작업에 구조화된 맥락을 대는 도구가 된다. 어휘 수준에서든 개념 수준에서든, 혹은 둘 다에서든.
이 장의 답사는 세 마당으로 짜였다. 첫째 마당에서는 NER을 사실 추출로 보완해 문서 검색·추천이 아닌 질문 응답을 위한 그래프를 세운다. 둘째 마당에서는 그래프와의 대화형 인터페이스 — 자연어 질문을 구조화 질의로 옮기는 일과, 그래프에서 텍스트를 지어 답을 내놓는 일 — 의 기법을 탐사한다. 마지막 마당은 어휘 데이터베이스를 위한 지식 그래프다 — WordNet 같은 공개 어휘 데이터베이스를 들여와 특화된 그래프의 쓰임을 상세히 분석하고 의미 유사도 지표를 구현한다.
앞 장의 기법들은 문서 중심이었다 — 컬렉션의 아이템에 NER과 비중의성 해소를 적용해 주석을 달고, 방대한 문서 집합 위의 시맨틱 검색이나 문서 간 유사도 계산을 해냈다. 이 절에서 문서는 사실의 원천일 뿐이다 — 사실들이 그래프 안에서 서로 이어져, 문서에 대한 정교한 작업이 아니라 도메인 질문에 답하는 것이 목적이 된다. 이런 그래프에 질의할 때 기대하는 것은 답이지, 답이 들어 있을 문서의 목록이 아니다. 물론 출처 문서의 참조를 보존해 프로버넌스와 신뢰를 챙기는 일과 어긋나지 않는다 — 다만 궁극의 목표가 "어떤 문서가 Tesla를 말하는가?"가 아니라 "Tesla의 CEO는 누구인가?" "Tesla의 본사는 어디인가?"에 답하는 그래프인 것이다.
쓰이는 NLP의 성격과 복잡도가 달라지면 비정형 데이터에서 빚어지는 그래프의 풍부함도 달라진다. NLP의 소임이 엔터티 추출에서 사실 추출로 옮겨 간다 — 자연어 텍스트에서 진술(statement)을 뽑아, 그래프에서 노드 속성 또는 노드를 잇는 관계로 모델링하는 것이다. 시연에는 또 다른 NLP 패키지 Diffbot을 쓴다 — Diffbot의 Natural Language API는 엔터티 추출에 더해 사실 추출을 제공한다.
import json import requests payload = { "content": "Pichette is a partner at Inovia Capital and chair of commerce platform Lightspeed", "lang": "en" } res = requests.post( "https://{}/v1/?fields={}&token={}".format (HOST, "entities,facts", TOKEN), json=payload) for ent in res.json()["entities"]: print(ent) # write to graph for fact in res.json()["facts"]: print(fact) # write to graph
{"sentiment": -0.32881057,
"entities": [{"name": "Patrick Pichette",
"confidence": 0.81, "salience": 0.64345313, …}],
"facts": [{
"humanReadable": "[Patrick Pichette] employee
or member of [Innovia Capital]",
"entity": { "name": "Patrick Pichette",
"diffbotUri": "…/EYwbLa__MNVGtPMryqqCCEA",
"confidence": 0.9999268,
"allUris": ["…wikidata.org/entity/Q3369779"], …},
"property": {"name": "employee or member of",
"diffbotUri": "…ontology#Person.memberOf"},
"value": {"name": "Innovia Capital",
"confidence": 0.9989188, …},
"confidence": 0.9498123,
"evidence": [{"passage": "Pichette is a
partner at Inovia Capital", …}]}, …]}
# 엔터티–속성–값 삼중항. Wikidata 교차 참조,
# salience·confidence·감성·증거 구절까지 실려 온다
// entity_list, fact_list를 매개변수로 쓰기 트랜잭션 호출 UNWIND $facts AS fact MERGE (source:Entity { id: fact.entity.diffbotUri }) MERGE (target:Entity { id: fact.value.diffbotUri }) WITH source, target, fact CALL apoc.create.relationship(source, fact.property.name, {}, target) // salience 등도 함께 싣는다
MATCH (:Organization { name : "Inovia Capital"}) -[rel]-(p:Person) RETURN p.name AS person, type(rel) AS rel_type
이 접근의 어려움은 관계의 조화다. 엔터티 사이에 관계가 달갑잖게 증식하는 것을 막으려면, 사실 추출 도구에 어떤 관계 유형이 유의미한지, 그것들을 어떻게 식별하고 비중의성 해소할지에 관한 추가 입력을 주어야 한다.
지식 그래프의 정보가 얼마나 풍부한지, 그리고 Bloom 같은 시각 탐사와 Cypher 프로그래밍으로 값을 얻는 법은 이미 보았다. 그런데 Bloom이 도메인의 기초 이해만 있으면 되는 보편의 길이라면, Cypher는 기술 전문가에게만 열린 길이다. 그래프에서 값을 얻는 사용자의 스펙트럼을 넓히는 한 방도가 자연어 기반 인터페이스다. Siri나 Alexa에 목소리로 묻고 고객 지원 챗봇과 대화하는 일이 이미 일상이듯, 지식 베이스를 탐사한다는 자각이 있더라도 질문을 자연어로 던질 수 있다는 것 자체가 콘텐츠 접근의 문턱을 낮춘다. 지식 그래프의 세계에서 대화형 상호작용이란 곧 자연어를 그래프에의 구조화 질의로 옮기는 통역이다.
길잡이 예제는 Neo4j 영화 데이터베이스의 기초 자연어 인터페이스다. NLP 라이브러리는 spaCy,
그중 규칙 기반 매칭이다 — 정규 표현식에 익숙하다면, spaCy의 규칙 기반 Matcher 엔진은 그 접근을 한 차원
끌어올린 것이라 보면 된다. 단어·구를 찾는 데 그치지 않고 문서 안 토큰들과 그 관계에 접근하게
해 주니, 주변 토큰을 분석·결합·처리해 질문의 뜻을 새기고 끝내 Cypher 구조화 질의로 옮길 수 있는 것이다.
그래프는 Neo4j Browser에서 :play movies 한 줄이면 선다 — Person과 Movie 두
엔터티가 ACTED_IN · DIRECTED · PRODUCED · WROTE · REVIEWED 다섯 관계로 이어진 정경이다(그림 13-3).
import spacy from spacy.matcher import Matcher nlp = spacy.load('en_core_web_sm') matcher = Matcher(vocab=nlp.vocab) q1_pattern = [{"LOWER":"who"}, {"LEMMA": {"IN": ["direct", "produce", "write", "review"]}, "POS": "VERB"}, {'IS_ASCII': True, 'OP': '+'}, {'IS_PUNCT': True, 'OP': '?'}] matcher.add("q_1", patterns=[q1_pattern]) # (1) 패턴 등록 doc = nlp("do you know who wrote a few good men?") # (2) 문장 투입 result = matcher(doc, as_spans=True) # (3) 매치 — Cypher 변환에 # 필요한 모든 정보가 담긴다 print(result) # 네 토큰의 뜻 — # ① who의 소문자형: Who든 WHO든 어떤 케이스 변형에도 통한다 # ② 동사(POS VERB)이되 lemma가 direct·produce·write·review 중 하나: # lemma는 단어 무리의 정준형 — directs·directed의 lemma가 direct다 # ③ 임의의 영숫자 단어(IS_ASCII), 수식자 OP '+' 로 한 번 이상 반복 허용 # ④ 선택적 문장부호(OP '?'): "Tell me who wrote X"처럼 평서로 청할 때를 위해 # # 잡히는 문형: "Who directed The Da Vinci Code?" / # "Do you know who wrote A Few Good Men?"
# 토큰 → 그래프 스키마 요소의 대응표 q1_verb_to_rel = {"direct" : "DIRECTED", "produce" : "PRODUCED", "write" : "WROTE", "review" : "REVIEWED"} max_match = result[-1] verb = max_match[1].lemma_ # (1) 동사 추출 title = ' '.join([tk.text for tk in max_match[2:] if tk.pos_ != 'PUNCT']) # (2) 제목 어절 추출 query_as_cypher = "MATCH (p:Person)-[:{rel_type}]-> (m:Movie) WHERE toLower(m.title) CONTAINS '{movie_title}' …".format( rel_type=q1_verb_to_rel[verb], movie_title=title.lower()) # (3) 합성
MATCH (p:Person)-[:WROTE]->(m:Movie) WHERE toLower(m.title) CONTAINS 'a few good men' WITH collect(p.name) AS answer_as_list RETURN CASE WHEN size(answer_as_list) > 0 THEN substring(reduce(result='', x in answer_as_list | result + ', ' + x),2) ELSE "I cannot answer your question about 'a few good men' " end AS answer // 그래프를 눈으로 살폈다면 손수 썼을 바로 // 그 질의다 — 뜻이 통역된 것이다
Matcher가 알아야 할 패턴을 일일이 명시해야 하니 꽤 각본적인 접근이 아니냐 — 어느 정도 옳은 지적이다.
그러나 그래프는 자기 기술적(self-describing) 데이터 구조임을 기억하라.
db.schema.* 프로시저를 부르는 것만으로 그래프가 아는 엔터티·속성·관계의 유형을
그래프 자신에게 물을 수 있다. 스키마 요소들의 흔한 자연어 표현을 조금 주석해 두면 풍부한 자연어
인터페이스를 동적으로 만들고, 그래프가 진화하는 대로 함께 진화시키는 일이 가능해진다 — 다음 절의
온톨로지 주석이 바로 그 길이다.
Example 13-9의 완결판 대화 인터페이스는 패턴 셋을 부린다 — q_1(who + 동사), q_2("when was … released / premiere / out" — 단어를 released 속성으로 잇는 q2_word_to_prop 사전), q_3("who acts/performs/ appears/was in …" — ACTED_IN 고정). 드라이버·세션을 열고(1), Cypher를 받아 답 문자열을 돌려주는 query_db를 정의하고(2), 패턴마다 고유 식별자로 등록한 뒤(3·4·5), process_question 함수가(6) 어떤 패턴이 잡혔는지 식별해(7) if 분기로 알맞은 Cypher를 합성한다. 질문 아홉을 시험하니(8·9) 문답이 이러하다.
각 답에는 합성된 Cypher가 Explain으로 함께 찍힌다 — 질문, 답, 그리고 통역문. 문답에 해설까지 곁들이니, 가히 격식을 갖춘 문답이라 하겠다.
지식 그래프에서 자연어를 지어내야 할 때가 종종 있다 — 그래프 위의 수사적 탐사(파나마 페이퍼스 조사 같은)에서 자연어 보고서를 뽑거나, 대화형 인터페이스의 출력으로 질의 결과를 문장으로 내놓는 경우다. 이 대목에서 그래프 데이터의 자기 기술성이 실질적인 이점을 준다. 명사는 노드로, 동사는 관계로 바꾸는 기초적(그러나 여전히 대체로 유효한) 모델링을 떠올려 보라 — 이미 유명한 문장 "Dan loves Ann"은 그래프가 되면 두 명사의 노드 둘과 사랑을 나타내는 관계 하나다. 이 논리를 뒤집어 일반화하면, 관계로 이어진 임의의 노드 쌍의 이름과 관계의 이름을 이어 붙이는 것만으로 주어–술어–목적어의 원형 문장이 나온다.
MERGE (x:Entity { name: "Euroyacht Limited" , jurisdiction: "BM" }) MERGE (y:Entity { name: "TUC LIMITED", jurisdiction: "MLT", incorporation_date: date("2013-10-07")}) MERGE (z:Entity {name: "GLOBAL TUITION & EDUCATION INSURANCE CORPORATION", jurisdiction: "BRB", incorporation_date: date("1998-04-03") }) MERGE (x)-[:OFFICER_OF]->(y) MERGE (x)-[:OFFICER_OF]->(z)
MATCH (x)-[r]->(y) RETURN x.name + " " + toLower(replace(type(r),"_"," ")) + " " + y.name AS sentence // 결과 — 놀랍도록 올바른 영어에 가깝다: // "Euroyacht Limited officer of GLOBAL TUITION // & EDUCATION INSURANCE CORPORATION" // "Euroyacht Limited officer of TUC LIMITED" // // 스키마 특정 요소가 없는 질의라 어떤 그래프에도 // 유효하다 — :play movies에 돌리면 // "Keanu Reeves acted in Johnny Mnemonic". // 두 줄 코드치고는 꽤 훌륭한 가성비다
MATCH (n:Entity) UNWIND keys(n) AS property RETURN n.name + "'s " + replace(property,"_"," ") + " is " + n[property] AS sentence // "Euroyacht Limited's jurisdiction is BM" // "TUC LIMITED's jurisdiction is MLT" // "GLOBAL TUITION & … 's incorporation date // is 1998-04-03" // 이번에도 제법 반듯한 영어 문장들이다
첫째, 앞의 질의들은 사실 완전히 제네릭하지 않았다 — name이라는 속성명 하나를 가정했는데, 이름 속성이 늘 있으리란 법은 없으니 노드를 지칭할 일반적 방도가 필요하다. 둘째, 문장의 가독성이 스키마 명명에 좌우된다 — sub_109라는 관계에서 나올 문장을 상상해 보라. 간결한 Cypher를 위한 이름과 자연어 지향 주석은 서로 독립이어야 한다. 셋째, 역방향 탐색의 언어 문제 — Person–ACTED_IN–Movie는 한쪽으로만 옳게 읽히니, MOVIE–FEATURED–Actor 같은 역방향 자연어 주석이 필요해진다. 이 셋의 일반해가 다음 절, 온톨로지 주석이다.
이 출발점이 실증하는 바는 뜻깊다 — 지식 그래프가 포착한 구조는 곧바로 읽어 자연어로 옮길 수 있으며, 이는 그래프가 인간과 닮은 방식으로 도메인의 이해를 담는다는 경험적 확인인 것이다.
이 절은 속성 그래프에서 자연어를 자동으로 생성하는 접근을 편다 — 온톨로지를 만들고 주석하여, 범용 자연어 생성 엔진이 그 정보로 양질의 자연어를 짓게 하는 것이다. 온톨로지는 인스턴스 데이터와 함께 저장되어 생성 엔진이 둘 다에 접근한다고 가정하지만, 대안 아키텍처로의 일반화도 얼마든지 가능하다 — 외부 저장소에서 직접 읽거나, 패브릭 구성의 별도 그래프에 두거나. 출발점은 지식 그래프의 온톨로지를 만들어 담는 일이다 — W3C 표준(RDF Schema, OWL)으로 형식화해 neosemantics 플러그인으로 들이거나, Neo4j에 노드·관계로 직접 만들면 된다. 이 선언적 스키마 기술이 곧 범용 생성 엔진의 설정 파일인 셈이다.
첫 소요는 노드 유형마다 지칭에 쓸 속성을 정하는 일이다 — Person은 name으로, Movie는
title로 부른다(Example 13-16의 talk:name 주석). 이를 온톨로지 모델링 도구나
손으로 만들어 CALL n10s.onto.import.fetch("file","Turtle") 한 줄로 들이면
되고(Example 13-17), W3C 준수가 필요 없다면 플레인 Cypher로 CREATE (:Class {
"name":"title","label":"Movie" })라 써도 같다(Example 13-18).
@prefix talk: <http://www.neo4j.org/2022/07/talkable#> . @prefix mv: <http://www.neo4j.org/sch/movies#> . mv:ACTED_IN rdf:type owl:ObjectProperty ; rdfs:domain mv:Person ; rdfs:range mv:Movie ; talk:direct "acted in"@default , "is in the cast of"@long , "worked in"@short ; talk:inverse "has $o in it"@default , "includes"@short , "includes $o in its cast"@long ; rdfs:label "ACTED_IN" . mv:WROTE rdf:type owl:ObjectProperty ; rdfs:domain mv:Person ; rdfs:range mv:Movie ; talk:direct "is the author of"@default , "wrote"@short , "wrote the script of"@long ; talk:inverse "is authored by"@default , "is written by"@long , "is by"@short ; rdfs:label "WROTE" .
mv:born rdf:type owl:DatatypeProperty ; rdfs:domain mv:Person ; talk:direct "The birth year of $s was"@long , "spawned in"@short , "was born in"@default ; rdfs:label "born" . mv:released rdf:type owl:DatatypeProperty ; rdfs:domain mv:Movie ; talk:direct "premiered in"@short , "The release year of $s was"@long , "was released in"@default ; rdfs:label "released" . mv:tagline rdf:type owl:DatatypeProperty ; rdfs:domain mv:Movie ; talk:direct "'s tagline goes: '$o'"@default , "'s tagline is"@short , "the tagline for $s was"@long ; rdfs:label "tagline" .
자연어로 표현하고 싶은 관계마다 domain과 range로 출처·목적지를 대고, 순방향과 역방향의 자연어를 함께 주석한다 — ACTED_IN의 자연 방향(Person→Movie)에서는 직접 표현("person X is in the cast of movie Y")을, 역방향(Movie→Person)에서는 역표현("movie Y includes person X")을 쓰는 것이다. 생성할 자연어의 버전도 여럿 둔다 — 예제에는 long과 short가 있으나 맥락 따라 격식체와 구어체를 골라 쓸 수도 있다. 끝으로 $ 매개변수가 유연함을 보탠다 — 주어–술어–목적어의 기본 틀이 옹색할 때, Keanu Reeves와 The Matrix의 ACTED_IN을 "The Matrix has Keanu Reeves in it"처럼 목적어를 문장 한가운데 심어 표현하는 것이다.
MATCH (n)-[r]-(o) WHERE id(n) = $entity_id MATCH (cn:Class)<-[:domain|range]-(op:ObjectProperty)-[:domain|range]->(co:Class) WHERE type(r) IN op.label AND (op.direct IS NOT NULL OR op.inverse IS NOT NULL) AND [x in labels(n) where x <> "Resource"][0] IN cn.label AND [x in labels(o) where x <> "Resource"][0] IN co.label WITH n[cn.name[0]] AS subj , n10s.rdf.getLangValue("default" , op[case when startNode(r) = n THEN "direct" else "inverse" end]) AS pred , substring(reduce(res="", x IN collect(o[co.name[0]]) | res +","+ x),1) AS obj WITH CASE WHEN pred CONTAINS '$s' THEN '' ELSE subj end AS subj , replace(replace(pred,'$o',obj),'$s',subj) AS pred, CASE WHEN pred CONTAINS '$o' THEN '' ELSE obj end AS obj RETURN subj + " " + pred + " " + obj AS sentence // 온톨로지에서 클래스·관계 주석을 찾아, 방향에 따라 direct/inverse를 고르고, // $s·$o 매개변수를 치환해 문장을 짓는다. 결과(Example 13-22)는 제법 의젓하다 — // "Lilly Wachowski wrote V for Vendetta,Speed Racer" // "Laurence Fishburne worked in The Matrix Revolutions,The Matrix Reloaded,The Matrix" // "Hugo Weaving worked in Cloud Atlas,V for Vendetta,The Matrix Revolutions,…" // "Lana Wachowski wrote V for Vendetta,Speed Racer" // "Emil Eifrem worked in The Matrix"
이는 온톨로지를 설정 아티팩트 삼아 자연어 생성을 순수 Cypher만으로 해내는 해법이다 — 임의의
X에 대해 "그래프가 X에 관해 아는 모든 것"의 자연어 서술을 돌려주니 문서화 등에 요긴하되, 아직은 꽤 제한적이다.
더 정교한 접근은 WordNet 같은 어휘 데이터베이스나, 흔한 표현을 학습해 수동 문형 정의 없이 스스로 재구성하는
ML 요소를 품는다. 실전의 창구로는 오픈 소스 봇 프레임워크 Rasa가 있다 — "knowledge base
actions"로 외부 데이터베이스의 정보를 부릴 수 있어 Neo4j 위의 지식 그래프와 상호작용한다(Example 13-23:
KnowledgeBase("ref to neo4j")를 감싼 ActionQueryKnowledgeBase
서브클래스).
WordNet은 영어의 어휘 데이터베이스다. 명사·동사·형용사·부사가 인지적 동의어 집합(synset)으로 묶이고, 각 synset은 하나의 뚜렷한 개념을 표현하며, synset들은 개념-의미 관계와 어휘 관계로 서로 이어진다. 그 결과는 그래프에 자연스레 담기는 요소들의 그물 — 말하자면 영어라는 언어의 족보요 옥편(玉篇)인 것이다. Global WordNet Association이 공개한 판본(최신 릴리스는 WordNet GitHub 저장소)을 쓰고, 제공 포맷 중 RDF를 고르면 들이는 일이 크게 단순해진다.
CREATE CONSTRAINT n10s_unique_uri ON (r:Resource) ASSERT r.uri IS UNIQUE CALL n10s.graphconfig.init( {handleVocabUris : "IGNORE"}); CALL n10s.rdf.import.fetch(".../english-wordnet-2021.ttl.gz","Turtle"); // 그래프의 뼈대 패턴(그림 13-4): Form — LexicalEntry — LexicalSense — LexicalConcept. // 명시적이고 상세한 그래프 표현은 내용에 대한 통제권을 주고, // 특정 용도로의 적응과 확장을 가능케 한다
MATCH (lemma:Form)<-[:canonicalForm]-(le:LexicalEntry) -[:sense]->()-[:isLexicalizedSenseOf]->(concept) WHERE lemma.writtenRep = "clear" RETURN le.partOfSpeech AS PoS, concept.definition AS definition // clear는 명사·동사·형용사·부사 노릇을 다 하니 // 결과가 수두룩하다 — WordNet 공개 웹 검색과 // 정확히 일치한다(그림 13-5). RETURN path로 // 바꾸면 그림 13-6의 시각화가 된다
MATCH (lemma:Form) RETURN lemma.writtenRep AS lemma, size((lemma)<-[:canonicalForm]- (:LexicalEntry)-[:sense]->()) AS senseCount ORDER BY senseCount DESC LIMIT 5
| lemma | senseCount |
|---|---|
| break | 75 |
| cut | 70 |
| run | 57 |
| play | 52 |
| make | 51 |
말은 하나요 뜻은 일흔다섯 — break가 그러하다(표 13-1, WordNet 2021 기준). 더 흥미로운 것은
역조회다. 같은 패턴을 반대로 순회하면 개념에서 출발해 그것을 표현할 수 있는 영어 단어들을
모두 찾는다 — "성공하지 못함"이라는 개념을 나타내는 세 가지 길이 그림 13-7의 정경이다. 어느 개념이 가장
많은 표현을 거느리는지도 집계 하나면 된다(Example 13-28 — 개념별 lemma를 세고 collect해 내림차순).
여기서 한 걸음 더 — 이 WordNet 그래프는 어휘 개념 사이의 의미 관계로 풍부화되어 있다.
하이퍼님(hypernymy, 더 일반적임), 하이포님(hyponymy, 더 특수함), 메로님(meronymy, 부분임) 등 목록이 꽤
길며 CALL db.relationshipTypes()로 다 살필 수 있다. 앞의 둘(hyper/hypo)이
그래프에 택소노미를 이룬다 — 개념을 더 특수하게, 또는 더 일반적으로 넓혀 가며 분석할 수 있고,
lemma 수준에서는 더 일반적·특수한 단어 찾기가 된다. 그림 13-8의 길이 보여 주듯, "지도가 그려진 구(球)"
라는 특정한 뜻의 globe에게 sphere는 더 일반적인 말인 것이다. 실용의 층위에서, 이 택소노미 탐사가 바로
어휘 개념 간 의미 유사도 지표의 정의에 쓰여 왔다.
인기 있는 NLP 라이브러리들은 표준 의미 유사도 지표 여럿을 구현해 두었다. 그런데 그 지표들의 속을 들여다보면 결국 어휘 지식 그래프의 경로 탐사에 기초해 있음이 드러난다 — 족보에서 촌수를 세는 일과 진배없는 것이다. 가장 흔히 쓰이는 셋을 골라 Cypher로 재현하고, 도메인 특화 용어·개념을 그래프에 보태는 커스터마이즈가 이 지표들을 여러 시나리오에 쓰이게 함을 본다.
경로 유사도(path similarity)는 택소노미에서 두 어휘 개념을 잇는 최단 경로에 기초한다. dog와 lion이 나타내는 개념으로 시험해 보자 — 하이퍼님 택소노미에서 두 개념 사이의 경로 길이는 5다(그림 13-10).
MATCH (a:LexicalConcept {uri: $a_id}) MATCH (b:LexicalConcept {uri: $b_id }) MATCH p = shortestPath((a)-[:hypernym*0..]-(b)) WITH a, b, length(p) AS pathLen RETURN a.definition AS a_def, b.definition AS b_def, pathLen, 1.0/(1+pathLen) AS pathSim // :params // a_id: "https://en-word.net/id/oewn-02086723-n" (dog) // b_id: "https://en-word.net/id/oewn-02131817-n" (lion)
from nltk.corpus import wordnet as wn dog = wn.synset('dog.n.01') lion = wn.synset('lion.n.01') print(dog.definition()) print(lion.definition()) print(dog.path_similarity(lion)) # a member of the genus Canis (probably descended # from the common wolf) that has been domesticated # by man since prehistoric times; occurs in many breeds # large gregarious predatory feline of Africa and India # having a tawny coat with a shaggy mane in the male # 0.16666666666666666 ← Cypher와 정확히 같다 (표 13-2)
유사도 알고리즘의 구현을 제 손에 쥐는 데에는 뚜렷한 이점이 있다. 첫째, NLTK가 지원하는 WordNet 버전에의 의존이 없다 — 집필 시점 NLTK는 WordNet 3.0만 지원하며 이는 현행 버전에 수년 뒤처져, 예제에서는 결과가 같았으되 다른 사례를 찾기는 어렵지 않다. 둘째, WordNet이 다루지 않는 영역의 도메인 특화 용어와 개념을 그래프에 보탤 수 있다 — 노드와 관계를 더하면 그만이다. 셋째, 그 극한은 WordNet에 기초하지 않은 어떤 택소노미 조직에도 이 지표를 적용할 수 있다는 것. 넷째, 알고리즘 자체를 고치고 맞출 수 있다.
Leacock-Chodorow 유사도는 계산에 요소 하나를 보탠다 — 택소노미의 깊이다. 깊이는 임의 요소와 루트 사이 경로의 최대 길이로, Cypher의 가변 길이 경로식으로 쉽게 잰다. 이 지표는 더 깊은 계층에서 일어나는 유사성을 증폭한다 — 얕은 족보의 5촌과 깊은 족보의 5촌은 무게가 다르다는 이치다.
MATCH path = (leaf:LexicalConcept) -[:hypernym*0..]->(root) WHERE NOT EXISTS (()-[:hypernym]->(leaf)) AND NOT EXISTS ((root)-[:hypernym]->()) RETURN max(length(path)) AS maxTaxonomyDepth // 이 그래프의 전역 최대치는 19. // 주의 — 모든 범주가 단일 루트에 이어져 있으란 법은 // 없다. WordNet도 다중 루트, 즉 서로 끊어진 여러 // 택소노미를 가진 그래프다. 그럴 때는 비교 대상 // 요소들을 담은 택소노미의 깊이를 계산해야 한다
MATCH (a:LexicalConcept {uri: $a_id}) MATCH (b:LexicalConcept {uri: $b_id }) MATCH p = shortestPath((a)-[:hypernym*0..]-(b)) WITH a, b, length(p) AS pathLen RETURN a.definition, b.definition, pathLen, -log10(pathLen/(2.0*$depth)) AS LCSim # NLTK 쪽은 함수 이름만 다르다: # print(wn.lch_similarity(dog, lion))
Wu-Palmer 유사도의 핵심 개념은 최소 공통 포섭자(LCS, least common subsumer) — 비교되는 두 요소의 가장 특수한 공통 조상 노드다. 족보로 치면 두 사람이 처음 만나는 공동 선조인 셈이다. 지표는 두 요소의 깊이와 LCS의 깊이를 결합한다. Cypher로 LCS를 얻으려면 두 가변 길이 패턴이 LCS로 수렴하게 그리면 된다.
MATCH (a:LexicalConcept {uri: $a_id}) MATCH (b:LexicalConcept {uri: $b_id }) MATCH p = (a)-[:hypernym*0..]->(lcs) <-[:hypernym*0..]-(b) WITH a, b, lcs, length(p) AS pathLen MATCH p = (lcs)-[:hypernym*0..]->(root) WHERE NOT (root)-[:hypernym]->() RETURN lcs.definition AS lcs_def, lcs.uri AS lcs_id, length(p) AS lcs_depth ORDER BY pathLen LIMIT 1 // 표 13-3의 결과 — // lcs_def: "a terrestrial or aquatic // flesh-eating mammal" // lcs_id: …/id/oewn-02077948-n // lcs_depth: 11
MATCH (a:LexicalConcept {uri: $a_id}) MATCH (b:LexicalConcept {uri: $b_id }) MATCH (lcs:LexicalConcept {uri: $lcs_id }) MATCH a_to_lcs = (a)-[:hypernym*0..]->(lcs) MATCH b_to_lcs = (b)-[:hypernym*0..]->(lcs) WITH a, b, lcs, length(a_to_lcs) AS depth_a, length(b_to_lcs) AS depth_b RETURN (2.0 * $lcs_depth) / (2.0 * $lcs_depth + depth_a + depth_b) AS wp_sim # NLTK — 함수 이름만 다르다: # print(wn.wup_similarity(dog, lion))
어휘 데이터베이스를 지식 그래프에 담는 이점 하나는 블랙박스이던 해법에 투명성을 들이는 것이다 — 그래프는 새 요소의 추가를 허락하는 동적 실체이기 때문이다. WordNet 그래프에 새 엔터티를 올리는 절차는 이러하다. 먼저 Form–LexicalEntry–LexicalSense–LexicalConcept 패턴을 지어야 한다 — proprietary software와 open source software를 software의 하이포님으로 들이는 예다.
CREATE (lc:LexicalConcept:Noun { subject:"noun.communication", partOfSpeech:"Noun", definition:"computer software released under a license restricting use, study or redistribution", uri:"https://custom.extension/id/15349-n", example:["the use of proprietary software is not allowed in our organization"] }) CREATE (ls1:LexicalSense {uri: "https://custom.extension/lemma/prop-soft#15349"}) CREATE (lc)<-[:isLexicalizedSenseOf]-(ls1) CREATE (le1:LexicalEntry:Noun { canonicalForm: "proprietary software", partOfSpeech: "Noun", uri: "https://custom.extension/lemma/prop-soft#prop-soft-n" }) CREATE (ls1)<-[:sense]-(le1) CREATE (f1:Form { writtenRep: "proprietary software", uri: "https://custom.extension/lemma/prop-soft" }) CREATE (le1)-[:canonicalForm]->(f1) // 같은 뜻(LexicalConcept)에 "closed-source software"의 LexicalSense/Entry/Form을 // 하나 더 단다 (ls2·le2·f2 — 위와 동형). OSS 쪽도 닮은꼴 스크립트로 — // "OSS"와 "open source software" 두 어형이 정의 // "software whose source code is available under an open source license" // (id/15350-n)를 나눠 갖는다
MATCH (prop:LexicalConcept { uri:"https://custom.extension/id/15349-n"}) MATCH (oss:LexicalConcept { uri:"https://custom.extension/id/15350-n"}) MATCH (sw:LexicalConcept { uri:"https://en-word.net/id/oewn-06578068-n"}) MERGE (prop)-[:hypernym]->(sw)-[:hyponym]->(prop) MERGE (oss)-[:hypernym]->(sw)-[:hyponym]->(oss) // software(oewn-06578068-n)에 hypernym과 // 대칭 hyponym으로 갈고리를 건다. // Example 13-40의 탐색 질의로 새 부분 그래프를 // 시각 확인할 수 있다 (그림 13-14)
| 지표 | OSS vs proprietary |
|---|---|
| Path similarity | 0.4 |
| Leacock-Chodorow | 0.846 |
| Wu-Palmer | 0.973 |
확장된 그래프는 그 즉시 유사도 계산에 응한다 — 정확히 같은 질의가 갓 태어난 엔터티들 위에서 매끄럽게 돌아 표 13-4의 결과를 내놓는다. 공동 선조 software가 깊은 곳에 있으니 Wu-Palmer가 특히 높다 — 오픈 소스와 사유 소프트웨어는, 촌수로 치면 지척의 형제인 것이다.
그래프를 고쳐 쓰는 이 발상은 일반화된다 — 계층 조직을 가진 어떤 지식 그래프에든 이 유사도
지표를 적용할 수 있는 것이다. Wikidata(wd:P279)와
DBpedia(rdf:SubClassOf)처럼 엔터티가 택소노미로 분류된 대형 지식 그래프가 바로
그 경우다. 텍스트 정보를 기존 의미 관계와 함께 통째로 들이고, 다른 도구의 모든 연산을 Cypher로 해내며,
도메인 특화 용어를 필요한 만큼 보태는 것 — 현실에서 자주 보이는 패턴이다. 독일 당뇨병연구센터(DZD)는
임상시험 데이터와 자체 연구 데이터, 그리고 발표된 의학 연구의 PubMed 데이터베이스 전체를 제
지식 그래프에 들여놓았다.
이 장에서 지식 그래프로 대화형 시스템을 움직이는 강력한 기법과 패턴의 무리를 보았다. 특화된 서드파티 도구와 상호운용 포맷으로 매력적인 시스템을 짓는 법을 보았고, 무엇보다 범용 그래프 데이터베이스 기술만으로 시스템 짓기가 얼마나 수월한지를 보았다. 원서의 표현을 빌리면 — 독자는 이제 인상적인 이해 수준에 도달한 것이다. 사실을 캐어 채우고, 말로 묻고, 문장으로 답하고, 옥편을 들여 촌수를 재는 데까지 — 그래프와의 문답은 과연 아는 만큼 깊어진다.
세 갈래 길. 자연어는 그래프의 입력(NER + 사실 추출)이 되고, 출력(대화 답변·자동 보고서)이 되며, 그래프는 NLP의 구조화된 맥락(어휘·개념 수준)이 된다. QA 그래프의 요체는 "Tesla를 말하는 문서"가 아니라 "Tesla의 CEO"에 답하는 것 — Diffbot의 엔터티–속성–값 삼중항(humanReadable, Wikidata 교차 참조, confidence, evidence)을 apoc.create.relationship으로 들이되, 관계의 조화가 숙제로 남는다.
spaCy 통역관. Bloom(보편)과 Cypher(전문가) 사이를 자연어 인터페이스가 잇는다. 네 토큰의 Matcher 패턴(who / lemma 동사 / IS_ASCII+ / 선택적 문장부호)과 verb→관계 사전 하나로 "who wrote a few good men?"이 WROTE 질의가 되어 Nora Ephron을 답한다 — 그래프는 자기 기술적이니(db.schema.*) 이 각본은 자동화로 진화할 수 있다.
두 줄 Cypher의 작문. "Dan loves Ann"의 역전 — (x)-[r]->(y) 연결만으로 "Keanu Reeves acted in Johnny Mnemonic"이 나오고 UNWIND keys(n)이 속성 문장을 짓는다. 남는 세 흠(name 가정, sub_109식 명명, 역방향 어색함)은 talk:name·talk:direct/inverse(@default/@long/@short)·$s/$o 매개변수의 온톨로지 주석이 고치고, 순수 Cypher 엔진이 "그래프가 X에 관해 아는 모든 것"을 서술한다. 더 멀리는 WordNet·ML 재구성·Rasa의 창구가 있다.
옥편을 그래프에. WordNet의 synset 그물이 Form–LexicalEntry–LexicalSense–LexicalConcept 패턴으로 들어온다(n10s.rdf.import.fetch). clear의 뭇 뜻과 break의 75가지 뜻, 개념→어형의 역조회, 그리고 hypernym/hyponym이 이루는 택소노미(globe는 sphere의 일종) — 명시적 그래프 표현이 통제권과 확장성을 준다.
촌수 재는 세 저울. Path 1/(1+pathLen)=0.1667(dog–lion 5걸음), Leacock-Chodorow −log₁₀(pathLen/2·depth)(깊이 19, 다중 루트 주의), Wu-Palmer 2·lcs/(2·lcs+dₐ+d_b)(공동 선조 "육식 포유류", 깊이 11). NLTK와 결과가 같되 Cypher는 네 이점 — 버전 독립·도메인 확장·임의 택소노미·알고리즘 수정 — 을 얹고, proprietary/OSS를 접붙이면 같은 질의가 곧장 0.4/0.846/0.973을 내놓는다. Wikidata·DBpedia로 일반화되며, DZD는 PubMed 전체를 이 길로 들였다.
그렇다면 자연히 이런 물음이 남는다 — "지식 그래프의 다음은 무엇인가?" 마지막 답사지인 14장은 이 책에서 배운 모든 것에 기대어 지식 그래프의 미래를 점친다 — 기업 스택의 아래로 내려가 기반의 소임을 맡는 지식 그래프, 그리고 지식 레이크(knowledge lake)라는 흥미로운 패턴이다 (From Knowledge Graphs to Knowledge Lakes).