LangChain 및 Neo4j를 사용하는 고급 그래프 기반 메타데이터 기술로 Vector Search를 최적화합니다.
In Retrieval-Augmented Generation(RAG) 애플리케이션, 텍스트 임베딩 및 Vector Search는 문서의 의미와 서로 얼마나 유사한지를 이해하여 문서를 찾는 데 도움이 돼요. 하지만 날짜나 카테고리와 같은 특정 기준에 따라 정보를 정렬할 때는 텍스트 임베딩이 효과적이지 않죠. 예를 들어, 특정 연도에 생성된 모든 문서를 찾거나 "공상 과학"과 같은 특정 카테고리에 태그가 지정된 문서를 찾아야 하는 경우에요.
메타데이터 필터링, 즉 필터링된 Vector Search가 중요한 역할을 하는 곳이죠! 구조화된 필터를 효과적으로 처리해서 사용자가 특정 속성에 따라 검색 결과를 좁힐 수 있거든요.

제공된 이미지에서 프로세스는 사용자가 2021년에 새로운 정책이 구현되었는지 묻는 것으로 시작돼요. 그런 다음 메타데이터 필터를 사용하여 지정된 연도(이 경우 2021년)를 기준으로 더 큰 인덱스 문서 풀을 정렬하죠. 결과적으로 해당 연도의 문서 하위 집합만 필터링되는 거예요.
가장 관련성이 높은 문서를 더욱 정밀하게 조사하기 위해 이 하위 집합 내에서 Vector Search가 수행됩니다. 이 방법을 사용하면 시스템은 2021년의 문맥상 관련된 문서 풀 내에서 관심 주제와 밀접하게 관련된 문서를 찾을 수 있어요. 이 2단계 프로세스인 메타데이터 필터링과 Vector Search는 검색 결과의 정확성과 관련성을 높여준답니다.
최근에 소개한 메타데이터 필터링을 위한 LangChain 지원은 Neo4j에서 Node 속성을 기반으로 해요. 하지만 같은 Graph Database는 구조화되지 않은 데이터와 함께 매우 복잡하고 연결된 구조화된 데이터를 저장할 수 있다는 점! 다음 예를 한번 살펴볼까요?

데이터 세트에서 구조화되지 않은 부분은 시각화의 오른쪽 상단에 있는 기사와 텍스트 덩어리를 나타내요. 텍스트 청크 `Node`에는 텍스트와 해당 텍스트 `Embedding` 값이 들어있고, 날짜, 감정, 작성자 같은 기사에 대한 추가 정보가 있는 기사 `Node`에 연결되죠.
그런데 기사는 기사에서 언급한 조직과 추가로 연결돼요. 이 예시에서는 기사에 Neo4j가 언급되어 있네요. 게다가 우리 데이터 세트에는 투자자, 이사회 구성원, 공급업체 등 Neo4j에 대한 구조화된 정보가 풍부하게 들어있답니다.
그래서 이 광범위한 구조화된 정보를 활용해서 정교한 메타데이터 필터링을 실행할 수 있고, 다음과 같은 구조화된 기준을 사용해서 문서 선택을 정확하게 세분화할 수 있어요.
- Rod Johnson이 이사회 구성원으로 있는 회사 중에 새로운 재택근무 정책을 시행한 회사가 있나요?
- Neo4j가 투자한 회사에 대한 부정적인 소식이 있나요?
- 현대자동차에 납품하는 기업의 공급망 문제와 관련해서 주목할 만한 소식이 있었나요?
이런 예시 질문들을 보면 구조화된 그래프 기반 메타데이터 필터를 사용해서 관련 문서 하위 집합의 범위를 얼마나 좁힐 수 있는지 알 수 있죠.
이번 블로그 포스팅에서는 OpenAI 함수 호출 에이전트와 함께 LangChain을 사용해서 그래프 기반 메타데이터 필터링을 구현하는 방법을 보여드릴게요. 코드는 에서 확인할 수 있어요.
의제
소위 `Graph Database`를 사용할 건데요, Neo4j가 호스팅하는 공개 데모 서버에서 사용할 수 있어요. 다음 자격 증명을 사용해서 액세스할 수 있습니다.
Neo4j Browser URI: https://demo.neo4jlabs.com:7473/browser/
username: companies
password: companies
database: companies
데이터 세트의 전체 `Schema`는 다음과 같아요.

그래프 스키마는 Organization 노드를 중심으로 구성돼요. 여기에는 공급업체, 경쟁업체, 위치, 이사회 구성원 등에 대한 방대한 정보가 담겨 있죠. 앞서 말씀드렸듯이, 특정 조직을 텍스트 덩어리와 함께 언급하는 기사도 있고요.
사용자 입력을 기반으로 Cypher 쿼리를 동적으로 생성하고 Graph Database에서 관련 텍스트 chunk를 검색할 수 있는 도구를 사용해서 OpenAI 에이전트를 구현할 수 있어요. 이 예제에서 도구는 4개의 선택적 입력 매개변수를 가지고 있죠.
- 주제: 조직, 국가, 감정 외에 사용자가 관심을 갖는 특정 정보나 주제를 말해요.
- 조직: 사용자가 정보를 찾고자 하는 조직이에요.
- 국가: 사용자가 관심을 갖는 조직의 국가고요. 미국, 프랑스처럼 전체 이름을 사용해야 해요.
- 감정: 기사의 감정을 나타내요.
4개의 입력 매개변수를 기반으로 동적으로, 그렇지만 확실하게 해당 Cypher 쿼리를 구성해서 그래프에서 관련 정보를 검색하고, 이걸 컨텍스트로 사용해서 LLM을 통해 최종 답변을 생성하는 거죠.
코드를 따라 하려면 OpenAI API key가 필요해요.
기능 구현
Neo4j에 대한 사용자 인증 정보와 관련 연결을 정의하는 것부터 시작해 볼게요.
import os
os.environ["OPENAI_API_KEY"] = "sk-"
os.environ["NEO4J_URI"] = "neo4j+s://demo.neo4jlabs.com"
os.environ["NEO4J_USERNAME"] = "companies"
os.environ["NEO4J_PASSWORD"] = "companies"
os.environ["NEO4J_DATABASE"] = "companies"
embeddings = OpenAIEmbeddings()
graph = Neo4jGraph()
vector_index = Neo4jVector.from_existing_index(
embeddings,
index_name="news"
)
이 글을 쓰는 시점에는 Vector Index를 사전 필터링 접근 방식과 함께 사용할 수 없어요. Vector Index와 함께 사후 필터링만 적용할 수 있죠. 하지만 사후 필터링에 대한 논의는 이 글의 범위를 벗어나요. 철저한 벡터 유사성 검색과 결합된 사전 필터링 접근 방식에 집중할 거니까요.
전체 블로그 게시물은 Cypher 쿼리를 동적으로 생성하고 관련 정보를 검색하는 다음 `get_organization_news` 함수로 요약돼요. 명확하게 보여드리기 위해 코드를 여러 부분으로 나눠서 설명할게요.
def get_organization_news(
topic: Optional[str] = None,
organization: Optional[str] = None,
country: Optional[str] = None,
sentiment: Optional[str] = None,
) -> str:
# If there is no prefiltering, we can use vector index
if topic and not organization and not country and not sentiment:
return vector_index.similarity_search(topic)
# Uses parallel runtime where available
base_query = (
"CYPHER runtime = parallel parallelRuntimeSupport=all "
"MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE "
)
where_queries = []
params = {"k": 5} # Define the number of text chunks to retrieve
입력 매개변수를 정의하는 것부터 시작해 볼게요. 보시다시피 모두 선택적 문자열이에요. `topic` 매개변수는 문서 내에서 특정 정보를 찾는 데 사용되죠. 실제로는 `topic` 매개변수의 값을 임베딩해서 벡터 유사성 검색을 위한 입력으로 사용하는 거예요. 다른 세 가지 매개변수는 사전 필터링 접근 방식을 보여주는 데 사용되고요.
사전 필터링 매개변수가 모두 비어 있으면 기존 Vector Index를 사용해서 해당 문서를 찾을 수 있어요. 그렇지 않다면 사전 필터링된 메타데이터 접근 방식에 사용될 기본 Cypher 쿼리 준비를 시작하는 거죠. `CYPHER Runtime = Parallel ParallelRuntimeSupport=all` 절은 Neo4j Database에 병렬 런타임을 사용하도록 지시하는 부분이에요. 다음으로 `Chunk` node와 해당 `Article` node를 선택하는 `match` 구문을 준비하고요.
이제 Cypher 쿼리에 메타데이터 필터를 동적으로 추가할 준비가 됐어요. 먼저 `Organization` 필터부터 시작해 볼게요.
if organization:
# Map to database
candidates = get_candidates(organization)
if len(candidates) > 1: # Ask for follow up if too many options
return (
"Ask a follow up question which of the available organizations "
f"did the user mean. Available options: {candidates}"
)
where_queries.append(
"EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})}"
)
params["organization"] = candidates[0]
LLM이 사용자가 관심을 갖는 특정 조직을 식별하는 경우, 먼저 `get_candidates` 함수를 사용하여 값을 데이터베이스에 매핑해야 해요. 내부적으로 `get_candidates` 함수는 전체 텍스트 인덱스를 활용한 키워드 검색으로 후보 `Node`를 찾죠. 여러 후보자가 발견되면 LLM에게 사용자에게 후속 질문을 해서 정확히 어떤 조직을 의미하는지 명확히 하도록 지시합니다.
그렇지 않으면 우리는 실존 하위 쿼리를 사용해서 특정 조직을 언급하는 기사를 필터 목록으로 필터링해요. Cypher 삽입을 방지하기 위해 쿼리를 연결하는 대신 쿼리 매개변수를 사용하고요.
다음으로, 사용자가 언급된 조직의 국가를 기반으로 텍스트 청크를 사전 필터링하려는 상황을 처리해볼게요.
if country:
# No need to disambiguate
where_queries.append(
"EXISTS {(a)-[:MENTIONS]->(:Organization)-[:IN_CITY]->()-[:IN_COUNTRY]->(:Country {name: $country})}"
)
params["country"] = country
국가는 표준 명명 표준을 따르므로 LLM은 대부분의 국가 명명 표준에 익숙해서 값을 데이터베이스에 매핑할 필요가 없어요.
마찬가지로 감정 메타데이터 필터링도 처리해볼까요?
if sentiment:
if sentiment == "positive":
where_queries.append("a.sentiment > $sentiment")
params["sentiment"] = 0.5
else:
where_queries.append("a.sentiment < $sentiment")
params["sentiment"] = -0.5
감정 입력 값으로 긍정적이거나 부정적인 두 가지 값만 사용하도록 LLM에 지시합니다. 그런 다음 이 두 값을 적절한 필터 값에 매핑하는 거죠.
`topic` 매개변수는 사전 필터링에 사용되지 않고 벡터 유사성 검색에 사용되므로 약간 다르게 처리해요.
if topic: # Do vector comparison
vector_snippet = (
" WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score "
"ORDER BY score DESC LIMIT toInteger($k) "
)
params["embedding"] = embeddings.embed_query(topic)
else: # Just return the latest data
vector_snippet = " WITH c, a ORDER BY a.date DESC LIMIT toInteger($k) "
LLM이 사용자가 뉴스의 특정 주제에 관심이 있음을 식별하면, `topic` 입력의 텍스트 임베딩을 사용하여 가장 관련성이 높은 문서를 찾아요. 반면에 특정 주제가 식별되지 않으면 최신 기사 몇 개만 반환하고 벡터 유사성 검색을 완전히 피합니다.
이제 Cypher 문을 함께 배치하고 이를 사용하여 데이터베이스에서 정보를 검색해야 해요.
return_snippet = "RETURN '#title ' + a.title + 'n#date ' + toString(a.date) + 'n#text ' + c.text AS output"
complete_query = (
base_query + " AND ".join(where_queries) + vector_snippet + return_snippet
)
# Retrieve information from the database
data = graph.query(complete_query, params)
print(f"Cypher: {complete_query}n")
# Safely remove embedding before printing
params.pop('embedding', None)
print(f"Parameters: {params}")
return "###Article: ".join([el["output"] for el in data])
모든 쿼리 조각을 결합하여 최종 `complete_query`를 구성합니다. 그런 다음 동적으로 생성된 Cypher 문을 사용하여 데이터베이스에서 정보를 검색하고 이를 LLM에 반환해요. 입력 예를 위해 생성된 Cypher 문을 한번 살펴볼까요?
get_organization_news(
organization='neo4j',
sentiment='positive',
topic='remote work'
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} AND
# a.sentiment > $sentiment
# WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score
# ORDER BY score DESC LIMIT toInteger($k)
# RETURN '#title ' + a.title + 'ndate ' + toString(a.date) + 'ntext ' + c.text AS output
# Parameters: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
동적 쿼리 생성은 예상대로 작동하며 데이터베이스에서 관련 정보를 검색할 수 있다는 것을 알 수 있어요.
OpenAI 에이전트 정의
다음으로 함수를 에이전트 도구로 래핑해야 해요. 먼저 입력 매개변수 설명을 추가해볼게요.
fewshot_examples = """{Input:What are the health benefits for Google employees in the news? Query: Health benefits}
{Input: What is the latest positive news about Google? Query: None}
{Input: Are there any news about VertexAI regarding Google? Query: VertexAI}
{Input: Are there any news about new products regarding Google? Query: new products}
"""
class NewsInput(BaseModel):
topic: Optional[str] = Field(
description="Any specific information or topic besides organization, country, and sentiment that the user is interested in. Here are some examples: "
+ fewshot_examples
)
organization: Optional[str] = Field(
description="Organization that the user wants to find information about"
)
country: Optional[str] = Field(
description="Country of organizations that the user is interested in. Use full names like United States of America and France."
)
sentiment: Optional[str] = Field(
description="Sentiment of articles", enum=["positive", "negative"]
)
사전 필터링 매개변수는 설명하기 꽤 간단했지만, `topic` 매개변수가 예상대로 작동하게 만드는 데 약간의 어려움이 있었어요. 결국, LLM이 더 잘 이해할 수 있도록 몇 가지 예시를 추가하기로 결정했죠. 또한 국가 명명 형식에 대한 LLM 정보를 제공하고, 감정에 대한 열거형도 제공하는 것을 볼 수 있어요.
이제 LLM 사용 시기에 대한 지침이 포함된 설명과 이름을 지정해서 커스텀 툴을 정의할 수 있어요.
class NewsTool(BaseTool):
name = "NewsInformation"
description = (
"useful for when you need to find relevant information in the news"
)
args_schema: Type[BaseModel] = NewsInput
def _run(
self,
topic: Optional[str] = None,
organization: Optional[str] = None,
country: Optional[str] = None,
sentiment: Optional[str] = None,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> str:
"""Use the tool."""
return get_organization_news(topic, organization, country, sentiment)
마지막으로 `agent_executor`를 정의하는 거예요. 얼마 전에 구현했던 OpenAI 에이전트의 LCEL 구현을 그대로 재사용했어요.
llm = ChatOpenAI(temperature=0, model="gpt-4-turbo", streaming=True)
tools = [NewsTool()]
llm_with_tools = llm.bind(functions=[format_tool_to_openai_function(t) for t in tools])
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful assistant that finds information about movies "
" and recommends them. If tools require follow up questions, "
"make sure to ask the user for clarification. Make sure to include any "
"available options that need to be clarified in the follow up questions "
"Do only the things the user specifically requested. ",
),
MessagesPlaceholder(variable_name="chat_history"),
("user", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
]
)
agent = (
{
"input": lambda x: x["input"],
"chat_history": lambda x: _format_chat_history(x["chat_history"])
if x.get("chat_history")
else [],
"agent_scratchpad": lambda x: format_to_openai_function_messages(
x["intermediate_steps"]
),
}
| prompt
| llm_with_tools
| OpenAIFunctionsAgentOutputParser()
)
agent_executor = AgentExecutor(agent=agent, tools=tools)
이 에이전트는 뉴스 정보를 검색하는 데 사용할 수 있는 툴을 하나 가지고 있어요. 또한 `chat_history` 메시지 플레이스홀더를 추가해서 에이전트가 대화할 수 있도록 하고, 후속 질문과 답변이 가능하도록 만들었답니다.
구현 테스트
몇 가지 입력을 실행하고 생성된 Cypher 문과 파라미터를 살펴볼게요.
agent_executor.invoke(
{"input": "What are some positive news regarding neo4j?"}
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization {name: $organization})} AND
# a.sentiment > $sentiment WITH c, a
# ORDER BY a.date DESC LIMIT toInteger($k)
# RETURN '#title ' + a.title + 'date ' + toString(a.date) + 'text ' + c.text AS output
# Parameters: {'k': 5, 'organization': 'Neo4j', 'sentiment': 0.5}
생성된 Cypher 문이 유효하네요. 특정 주제를 지정하지 않았으니 Neo4j를 언급하는 긍정적인 기사의 마지막 5개 텍스트 청크를 반환하는군요. 좀 더 복잡한 작업을 해볼까요?
agent_executor.invoke(
{"input": "What are some of the latest negative news about employee happiness for companies from France?"}
)
# Cypher: CYPHER runtime = parallel parallelRuntimeSupport=all
# MATCH (c:Chunk)<-[:HAS_CHUNK]-(a:Article) WHERE
# EXISTS {(a)-[:MENTIONS]->(:Organization)-[:IN_CITY]->()-[:IN_COUNTRY]->(:Country {name: $country})} AND
# a.sentiment < $sentiment
# WITH c, a, vector.similarity.cosine(c.embedding,$embedding) AS score
# ORDER BY score DESC LIMIT toInteger($k)
# RETURN '#title ' + a.title + 'date ' + toString(a.date) + 'text ' + c.text AS output
# Parameters: {'k': 5, 'country': 'France', 'sentiment': -0.5, 'topic': 'employee happiness'}
LLM 에이전트는 사전 필터링 파라미터를 올바르게 생성했지만 특정 항목도 식별했어요. 주제 말이죠. 이 주제는 Vector Embedding 유사성 검색에 대한 입력으로 사용되므로 검색 프로세스를 더욱 세분화할 수 있어요.
요약
이번 블로그 포스팅에서는 예제 그래프 기반 메타데이터 필터를 구현해서 Vector Embedding 검색 정확도를 향상시켰어요. 하지만 데이터 세트에는 훨씬 더 정교한 사전 필터링 쿼리를 허용하는 광범위하고 상호 연결된 옵션이 있답니다. Graph Database 표현을 통해 구조화된 필터의 가능성은 LLM 함수 호출 기능과 결합되어 Cypher 문을 동적으로 생성할 때 정말 무궁무진하죠.
또한 에이전트에는 이 블로그 포스팅에 표시된 것처럼 구조화되지 않은 텍스트를 검색하는 도구와 다음을 수행할 수 있는 기타 도구가 있을 수 있어요. 구조화된 정보 검색, Knowledge Graph를 많은 RAG 애플리케이션에 대한 탁월한 솔루션으로 만들어주죠.
코드는 다음에서 사용할 수 있습니다. .
- rag
- 구조화되지 않은 데이터
- 벡터 유사성 검색
에이치시스템즈의 LogTree는 Neo4j 기반 GraphRAG 플랫폼으로, 데이터를 자동으로 지식그래프화하고 자연어 질의로 즉시 답을 제공합니다.
'GraphRAG' 카테고리의 다른 글
| 엔터프라이즈 MDM을 위한 그래프 기술: Neo4j와 GraphRAG의 활용 (1) | 2026.05.08 |
|---|---|
| 생명을 구하는 그래프: 연구 논문과 임상 시험을 연결하여 인류를 구원하는 Neo4j GraphRAG 활용기 (1) | 2026.05.08 |
| 그래프와 빅데이터의 만남: 시각화의 장애물과 Neo4j GraphRAG 기회 (1) | 2026.05.07 |
| PDF 문서에서 그래프와 LLM 기반 GraphRAG 애플리케이션 구축하기 (1) | 2026.05.06 |
| 고객 골든 프로필 및 사기 탐지를 위한 그래프: 알리안츠 베네룩스와의 5분 인터뷰 (Neo4j, GraphRAG 활용) (1) | 2026.05.06 |
