LLM 및 벡터 검색을 사용하여 Neo4j로 GraphAcademy용 교육용 챗봇 구축
개발자 관계 팀의 일원으로서, 저는 Neo4j를 데이터베이스로 사용하여 문제를 해결하면서 정말 흥미로운 시간을 보내고 있어요. 저는 이러한 경험을 다른 사람들과 토론하고 제가 배운 것을 전달하는 것이 항상 활력을 준다고 생각해요.
예를 들어, 저는 최근에 다음과 같은 내용을 심층적으로 다룬 기사를 게재했어요. Neo4j 위에 교육 플랫폼을 구축한 방법. GraphAcademy는 사용자가 Neo4j를 사용하여 성공하기 위해 알아야 할 모든 것을 배울 수 있는 무료 자습형 학습 플랫폼이에요.
Neo4j에서 교육 플랫폼 구축
그런데 재미있는 사실이 있어요. GraphAcademy는 단 두 사람이 관리하고 있다는 거죠! 하루의 시간은 정해져 있고 다루어야 할 콘텐츠도 많기 때문에 우선순위를 명확히 해야 해요.
우리는 어떤 의미에서는 우리 자신의 성공의 희생자였어요. 우리가 받는 콘텐츠에 대한 질문과 피드백의 양이 엄청나거든요. 우리 모두에게 응답하고 싶지만 불가능해요.
밤에 저를 잠 못 들게 하는 한 가지는 우리 사용자의 경험이에요. 새로운 언어나 기술을 배우는 사람이라면 막혀서 어디로 가야 할지 모르면 좌절감을 느낄 수 있어요. 더 나쁜 것은 이로 인해 Neo4j를 완전히 포기하게 될 수도 있다는 것이죠.
그래서 저는 어떻게 하면 사용자를 더 잘 지원할 수 있을지 끊임없이 고민하고 있어요. 아니면 Neo4j를 사용하여 사용자를 어떻게 지원할 수 있을까요?
LLM을 사용할 수 있나요?
이것이 바로 Large Language Model(LLM)이 등장하는 곳이에요. 이는 지원을 확장하고 사용자에게 적시에 피드백을 제공할 수 있는 실질적인 기회를 제공하죠.
Neo4j로 Large Language Model 활용하기
여러분, 이런 콘텐츠가 있다고 상상해 보세요. GraphAcademy와 Neo4j 문서를 활용해서 사용자 지원을 위한 교육용 챗봇을 훈련시키는 거죠. 정말 놓칠 수 없는 기회 아니겠어요?
환각 현상을 피하려면 LLM의 교육 데이터를 사용하고 싶지 않아요. 문서에서 관련 부분을 요약해서 사용자에게 답변해주는 언어 기술을 활용하고 싶은 거죠. 이 기술을 RAG(Retrieval-Augmented Generation)라고도 부른답니다.
Knowledge Graph 및 LLM: Fine-tuning과 Retrieval-Augmented Generation 비교
그래서 우리는 다음과 같은 단계를 거쳐야 해요.
- 유사성 검색을 위해 문서를 Vector Embedding으로 변환해요.
- Neo4j에 저장하고 Index를 생성해요.
- 사용자 질문도 Vector Embedding으로 만들고 가장 관련성이 높은 문서를 찾아요.
- LLM에게 답변을 요청하고 사용자를 위한 정보를 요약하는 거죠.
그래프 및 벡터
요즘 여기저기서 Vector Database가 막 생겨나고 있고, 모든 Database 벤더들이 하나씩 제공하려고 경쟁하는 것 같아요. GenAI에서 이들의 역할은 해당 개념 공간에서 유사성을 기준으로 항목을 찾는 데 사용할 수 있는 고차원 벡터를 저장하는 것이죠.
GraphAcademy 챗봇의 경우, 벡터 표현이 필요해요. 사용자 질문과 비교하기 위한 문서 *덩어리*가 필요한 거죠. 두 벡터가 유사할수록 해당 콘텐츠가 질문에 답할 가능성이 높아져요. 핵심은 문서를 아직 정의되지 않은 질문에 대답하기에 충분한 정보가 포함된 덩어리로 나누는 동시에 LLM의 컨텍스트 제한을 초과하지 않을 만큼 작게 만드는 거예요.
Neo4j 문서 청킹
Asciidoc으로 작성되고 Antora를 사용하여 HTML로 변환된 Neo4j 문서는 하위 제목으로 나뉘어 있어요.
몇 번의 실험 끝에 .sect1 CSS 선택자를 사용하기로 결정했는데, 잘 작동하는 것 같았어요. 문서 사본을 생성한 다음 BeautifulSoup과 Neo4j Python 드라이버의 도움을 받아 다음 코드를 사용하여 Neo4j Graph Database에 페이지와 섹션의 계층 구조를 만들기 위해 각 매뉴얼을 반복했어요.
def extract_docs(url, html):
soup = bs4.BeautifulSoup(html)
text = None
title= soup.find("h1", class_="page").text.strip()
parsed = urlparse(url)
article = soup.find('article', class_='doc')
links = article.find_all('a')
cleaned_links = []
for link in links:
if link.get('href') != None and not link.get('href').startswith('#'):
cleaned_links.append(cleaned_link(url, link))
sections = []
for section in soup.select('#preamble, .sect1'):
header = section.find('h2')
section_links = [ cleaned_link(url, link) for link in section.find_all('a') if link.text != "" ]
anchor = section.find('a', class_='anchor')
section_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, parsed.query, anchor.get('href').replace('#', '') if anchor else 'preamble'))
code_blocks = []
for pre in section.find_all('pre'):
language = pre.find('div', class_="code-inset")
title = pre.find('div', class_='code-title')
code = pre.find('code')
if code:
code_blocks.append({
"language": language.text.strip() if language is not None else None,
"title": title.text.strip() if title is not None else None,
"code": code.text
})
sections.append({
"url": section_url,
"title": header.text.strip() if header is not None else None,
"text": section.text.strip(),
"anchor": anchor.get('href') if anchor else None,
"links": section_links,
"images": [ cleaned_image(url, img) for img in section.find_all('img') ],
"code": code_blocks
})
return {
"url": url,
"title": title,
"links": cleaned_links,
"sections": sections
}
당연히 이 계층 구조는 그래프에 잘 맞죠.
예전의 나라면 텍스트 속성에 Full-text Index를 추가하고 나머지는 운에 맡겼을 거예요. 하지만 OpenAI의 GPT-API 출시로 새로운 가능성이 열렸죠. OpenAI API의 /embeddings 엔드포인트는 텍스트를 Vector Embedding으로 변환하는 방법을 제공하거든요.
// Set the API Key
openai.api_key = os.getenv('OPENAI_API_KEY')
// Get sections without an embedding
records, _, __ = driver.execute_query("""
MATCH (s:Section) WHERE s.embedding IS NULL
and not s.url contains 'upgrade-migration-guide'
RETURN s
""")
for record in tqdm_notebook(records):
url = record['s']['url']
// Convert the text property into an embedding
embeddings = [
el['embedding']
for el in openai.Embedding.create(input=record['s']['text'], model='text-embedding-ada-002')['data']
]
// Add the embedding and a new :Chunk label
driver.execute_query("""
MATCH (s:Section {url: $url})
SET s:Chunk, s.embedding = $embedding
""", url=url, embedding=embeddings[0])
전체 임베딩 프로세스에는 몇 센트 정도의 비용이 들었는데, 기분 좋게 놀랐어요.
chunks = openai.Embedding.create(
input=question,
model='text-embedding-ada-002'
)
동일한 임베딩 방법을 사용해서 사용자 질문에 대한 임베딩을 생성할 수 있어요. 결과는 10.0에서 -10.0 사이의 부동 소수점 배열인데, 두 임베딩 모두 동일한 서비스에서 생성되었기 때문에 유사성 검색을 간단하게 수행할 수 있는 거죠.
Chatbot이나 LLM 관련 글을 읽어보셨다면, 여기서 전용 벡터 데이터베이스를 사용하고 싶을 수도 있을 텐데요. 잠깐만요!
Neo4j에서 이제 벡터 인덱스를 지원한대요! Neo4j Aura에서 벡터 인덱스를 사용할 수 있다는 사실!
Neo4j의 벡터 검색: AI 기반 애플리케이션에 대한 더 깊은 통찰력 확보 - Graph Database 및 분석
여기에서 벡터 인덱스 및 벡터 검색에 대한 기술 문서를 확인해보세요.
벡터 검색 Index – Cypher 매뉴얼
위 코드 샘플에서 업데이트 쿼리는 각 (:Section) Node에 :Chunk Label도 설정하는데요. Label과 Property를 활용해서 :Chunk /.embedding Label/Property 쌍에서 벡터 Index를 생성할 수 있어요.
CALL db.index.vector.createNodeIndex(
'chatbot-embeddings', // The name of the index
'Chunk', // The label
'embedding', // The property
1536, // The dimensions of the embedding, OpenAI uses 1536
'cosine' // Function to calculate similarity
)
이제 다음과 같은 기능만 있으면 돼요.
- 사용자 입력 임베딩 만들기
- chatbot-embeddings 벡터 Index를 쿼리해서 웹사이트에서 유사한 콘텐츠가 있는 Section을 찾기
- 페이지와 같은 관련 정보에 대한 그래프를 추가로 검색하기
- 제공된 콘텐츠 덩어리를 기반으로 사용자 질문에 답변하도록 LLM에 지시하기 위해 전달할 Prompt를 만들기
db.index.Vector.queryNodes() 프로시저를 사용하면 임베딩을 사용해서 Index를 쿼리할 수 있어요. 이 프로시저는 Index 이름, 반환할 결과 수, 임베딩으로서의 질문 이렇게 세 가지 인수를 사용하죠.
def get_similar_chunks(question):
chunks = openai.Embedding.create(
input=question,
model='text-embedding-ada-002'
)
with driver.session() as session:
# Create a Unit of work to run a read statement in a read transaction
def query_index(tx, embedding):
res = tx.run("""
CALL db.index.vector.queryNodes('chatbot-embeddings', $limit, $embedding)
YIELD node, score
MATCH (node)<-[:HAS_SECTION]-(p)
RETURN
p.title AS pageTitle,
p.url AS pageUrl,
node.title AS sectionTitle,
node.url AS sectionUrl,
node.text AS sectionText,
score
ORDER BY score DESC
""", embedding = chunks.data[0]["embedding"], limit=10)
# Get the results as a dict
return [dict(record) for record in res]
res = session.execute_read(query_index, chunks=chunks)
이 결과가 왜 유용할까요?
컨텍스트의 일부로 힌트를 제공하면 LLM이 할 수 있는 **환각(Hallucination)**의 양을 줄일 수 있어요. 사용 사례에 따라 LLM이 사전 훈련된 지식을 활용하길 원할 수도 있지만, 여기에는 어려움이 따르죠.
이 경우, 원본 LLM 교육 데이터에 의존하는 것은 신뢰할 수 있는 방법이 아니라는 것을 발견해서 Prompt에 설명을 요청하는 지침을 추가했어요.
Prompt Engineering
**Prompt Engineering**은 그 자체로 하나의 예술과 같아서, 제대로 구현하려면 몇 번의 반복이 필요해요. Prompt를 좀 더 자세히 살펴볼까요?
You are a chatbot teaching users how to use Neo4j GraphAcademy.
Attempt to answer the user's question with the context provided.
Do not attempt to ask the question if it is not related to Neo4j.
Respond in a short, but friendly way.
Use your knowledge to fill in any gaps.
If you cannot answer the question, ask for more clarification.
Provide a code sample if possible.
Also include a link to the sectionUrl.
- You are a chatbot teaching users to how use Neo4j GraphAcademy. 제공된 컨텍스트를 사용하여 사용자의 질문에 답변해 보세요.
LLM이 수행해야 하는 역할을 정의하는 거죠. - Neo4j와 관련이 없는 질문은 시도하지 마세요.
가끔 "하늘은 무슨 색인가요?" 또는 "인생의 의미는 무엇입니까?" 같은 질문을 하는 분들이 있어요. 이런 질문들이 Neo4j와 관련이 없다면 답변을 시도하지 않는 게 좋아요. - 짧지만 친절하게 답변해 주세요.
이건 제 개인적인 생각인데요, LLM에게 간결하게 말하지 않으면 영업사원 모드로 돌변해서 "시장 선도적", "강력함" 같은 과장된 표현을 남발하더라고요. - 지식을 활용해서 부족한 부분을 채워주세요.
컨텍스트에 답변이 없는 경우, 미리 학습된 지식을 활용하는 거죠. - 질문에 바로 답할 수 없다면, 더 자세한 설명을 요청하세요.
컨텍스트에 답변이 없더라도 엉뚱한 답변을 만들어내지 말고, 좀 더 명확하게 질문해달라고 요청하는 게 중요해요. - 가능하다면 코드 샘플을 제공해주세요.
주요 독자는 개발자와 데이터 과학자니까, 가능하다면 제공된 정보를 뒷받침할 수 있는 코드 예제를 보여주는 게 좋겠죠? - `sectionUrl`에 대한 링크도 잊지 마세요.
컨텍스트로 제공된 각 섹션에는 연결된 URL이 있거든요. 사용자가 더 자세한 정보를 원할 때 해당 섹션으로 바로 이동할 수 있도록 링크를 제공해주세요.
페이지를 가져오면 Cypher `MATCH` 절 페이지가 될 수 있고, 위에서 언급한 것처럼 제공된 컨텍스트는 다음과 같을 수 있어요.
---
Page URL: https://neo4j.com/docs/cypher-manual/current/clauses/match/
Page Title: MATCH - Cypher Manual
Section URL: https://neo4j.com/docs/cypher-manual/current/clauses/match/#match-introduction
Section Title: Introduction
The MATCH clause allows you to specify the patterns Neo4j will search for in the database....
---
---
Page URL: https://neo4j.com/docs/cypher-manual/current/clauses/match/
Page Title: MATCH - Cypher Manual
Section URL: https://neo4j.com/docs/cypher-manual/current/clauses/match/#example-graph
Section Title: Example graph
The following graph is used for the examples below...
---
제가 경험해본 결과, 대부분의 경우 상위 3개의 결과를 LLM에 제공하면 99%의 질문에 답변하기에 충분하더라고요.
나머지 1%의 질문에 대해서는, 현재 Vector Index에서 가장 유사한 상위 10개 페이지를 선택하고 Cohere의 Rerank API 엔드포인트를 사용해서 관련성을 다시 평가해요.
LLM 테스트
테스트를 위해 모든 것을 `anask_question` 함수에 묶었어요.
def ask_question(question):
with driver.session() as session:
# Use the get_similar_chunks method to
results = session.execute_read(get_similar_chunks, question=question)
# Define the LLM's role with system messages
messages = [
{
"role": "system",
"content": """
You are a chatbot teaching users to how use Neo4j GraphAcademy.
Attempt to answer the users question with the context provided.
Respond in a short, but friendly way.
Use your knowledge to fill in any gaps.
If you cannot answer the question, ask for more clarification.
Provide a code sample if possible.
Also include a link to the sectionUrl.
"""
},
{
"role": "assistant",
"content": """
Your Context:
{}
""".format(json.dumps(reranked))
},
]
# Append a message detailing what the user has asked.
messages.append(
{
"role": "user",
"content": """
Answer the users question, wrapped in three backticks:
```
{}
```
""".format(question)
}
)
# Send the message to OpenAI
chat_completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", messages=messages)
return chat_completion.choices[0].message.content
에 대한 중간 질문에 답할 수 있나요? gds.similarity.cosine() 함수가 GDS 라이브러리에 있는지 말이죠.
ask_question(
"What are the arguments for gds.similarity.cosine?"
)
LLM은 마크다운으로 답변을 줬어요.
Neo4j Graph Data Science의 'gds.similarity.cosine' 함수는 코사인 유사성 측정항목을 사용하여 두 숫자 배열 간의 유사성을 계산해요. 비교하려는 두 숫자 배열을 나타내는 `ps`와 `pt`라는 두 개의 인수를 사용하죠. 이 함수는 [-1, 1] 범위 내의 수치적 유사성 점수를 반환해요. 자세한 내용은 에서 확인할 수 있어요.
성공! 이 기능은 질문을 Vector Embedding으로 변환하고, 문서에서 가장 관련성이 높은 페이지를 찾고, 문서의 내용을 기반으로 간결하고 유용한 답변을 제공했어요. GDS 설명서 유사성 함수 페이지의 정의 섹션에 있는 함수 표를 참고하세요.
MATCH clause에 대한 추상적인 질문은 어떨까요?
ask_question("I don't understand the MATCH clause")
Cypher의 MATCH clause를 사용하면 데이터베이스에서 검색할 패턴을 지정할 수 있어요. SQL의 FROM clause와 유사하죠. 이를 사용해서 Graph Database에서 데이터를 검색할 수 있어요. 다음은 MATCH clause를 사용하여 그래프에서 모든 Person Node를 찾는 방법에 대한 예시에요.
MATCH(p:사람)
반환 p
이 쿼리는 그래프에서 Person 레이블이 있는 모든 Node를 검색해서 반환해요. Neo4j 문서에서 MATCH clause에 대해 자세히 알아볼 수 있어요.
Neo4j와 관련 없는 질문은 어떻게 될까요?
ask_question("What is the meaning of life?")
미안하지만 저는 삶의 의미를 판단할 수 없어요. 제가 더 도와드릴 일이 있나요?
😎👌
그래프로 유사성 검색 개선
물론 이는 유사성을 제공하기 위해 Index 자체에 크게 의존해요. 그래프의 구조는 추천을 개선하는 데에도 사용될 수 있죠. 위의 extract_docs() 함수에서 각 섹션에는 이와 관련된 링크 배열이 있다는 것을 알 수 있어요. 이는 해당 페이지 및 섹션과의 Relationship으로 그래프에 저장돼요.
두 페이지가 서로 연결되어 있다는 사실은 두 페이지가 공통 콘텐츠를 공유하거나 한 페이지가 다른 페이지에서 제기한 질문에 답할 수 있다는 것을 의미할 수 있어요.
사용자의 현재 페이지를 고려해서 사용자가 현재 있는 페이지를 제외하거나 사용자의 여정을 조사해서 이미 방문한 페이지 섹션을 강조 표시하거나 페이지 전체를 할인할 수도 있어요.
요약하면
그래프는 LLM의 외부 메모리 또는 참조 소스 역할을 하는 체계적이고 검증 가능한 Knowledge Graph를 제공해서 LLM의 환각을 예방하는 데 도움이 될 수 있어요. 프롬프트의 일부로 이 정보를 미리 제공하면 LLM이 창의적인 답변을 제공하는 경향을 억제할 수 있죠.
데이터가 이미 Neo4j에 저장되어 있으면 새로운 Vector Index는 외부 서비스를 사용하지 않고도 Vector Embedding을 사용하여 유사한 문서를 찾는 편리한 방법을 제공해요.
챗봇을 확인하실 수 있습니다.GraphAcademy 방문 및 강좌 등록. 챗봇은 모든 수업에서 사용할 수 있습니다.
무료, 자기 주도형 실습 온라인 교육
아래에서 실제로 작동하는 모습을 볼 수 있어요.

- GenAI Use Cases
- GraphAcademy
- Vector Database
에이치시스템즈의 LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.
'Agent AI' 카테고리의 다른 글
| 에이전트 기반 AI 시스템을 위한 최적의 Context Engineering 도구 (0) | 2026.04.06 |
|---|---|
| GPT-4와 Neo4j로 만드는 맥락 인지 지식 그래프 챗봇 (0) | 2026.04.05 |
| 바운더리 그래프: 그래프 렌즈로 IPL 탐험하기 (1) | 2026.04.05 |
| crewAI와 Neo4j로 자동 보고서 생성 에이전트 구현하기 (0) | 2026.04.04 |
| RAG에서 Reqs로: 그래프와 챗봇으로 ASVS에 쉽게 접근하기 (0) | 2026.04.04 |