RETURN apoc.version();
CALL gds.version();
:use system
SHOW DATABASES;
CREATE DATABASE auditdb IF NOT EXISTS;
SHOW DATABASE auditdb;
:use auditdb
RETURN "ok" AS status;
:use auditdb
// Document
CREATE CONSTRAINT document_doc_code_unique IF NOT EXISTS
FOR (d:Document) REQUIRE d.doc_code IS UNIQUE;
// Case
CREATE CONSTRAINT case_doc_sub_unique IF NOT EXISTS
FOR (cs:Case) REQUIRE (cs.doc_code, cs.sub_order) IS UNIQUE;
// CaseHeader
CREATE CONSTRAINT caseheader_subcode_unique IF NOT EXISTS
FOR (h:CaseHeader) REQUIRE h.sub_code IS UNIQUE;
// Chunk
CREATE CONSTRAINT chunk_subcode_chunkid_unique IF NOT EXISTS
FOR (c:Chunk) REQUIRE (c.sub_code, c.chunk_id) IS UNIQUE;
# 확인
SHOW CONSTRAINTS;
:use auditdb
CREATE FULLTEXT INDEX chunk_text_ft IF NOT EXISTS
FOR (c:Chunk) ON EACH [c.text, c.sub_title, c.problem, c.action, c.criteria];
# 확인
SHOW INDEXES;
:use auditdb
CREATE VECTOR INDEX caseheader_embedding_idx IF NOT EXISTS
FOR (h:CaseHeader) ON (h.embedding)
OPTIONS {indexConfig: {'vector.dimensions': 1536, 'vector.similarity_function': 'cosine'}};
n8n은 JSON을 만들고 아래 MERGE 쿼리로 넣으면 됩니다(파라미터 방식 권장)
:use auditdb
WITH $doc AS doc
MERGE (d:Document {doc_code: doc.doc_code})
SET d.title = doc.title,
d.purpose = doc.purpose,
d.site = doc.site,
d.category = doc.category,
d.audit_start_date = CASE WHEN doc.audit_start_date IS NULL OR doc.audit_start_date = "" THEN NULL ELSE date(doc.audit_start_date) END,
d.audit_end_date = CASE WHEN doc.audit_end_date IS NULL OR doc.audit_end_date = "" THEN NULL ELSE date(doc.audit_end_date) END,
d.date = CASE WHEN doc.date IS NULL OR doc.date = "" THEN NULL ELSE date(doc.date) END,
d.download_url = doc.download_url,
d.save_place = doc.save_place,
d.file_name = doc.file_name,
d.file_hash = doc.file_hash,
d.created_at = coalesce(d.created_at, datetime()),
d.updated_at = datetime()
WITH d,
// category(list) -> "A|B|C" 문자열로 join (APOC 없이)
reduce(s = "", x IN coalesce(d.category, []) |
CASE WHEN s = "" THEN toString(x) ELSE s + "|" + toString(x) END
) AS category_joined,
$cases AS cases
UNWIND cases AS cs
:use auditdb
MERGE (case:Case {doc_code: d.doc_code, sub_order: cs.sub_order})
SET case.sub_code = coalesce(cs.sub_code, d.doc_code + "-" + toString(cs.sub_order)),
case.sub_title = cs.sub_title,
case.keyword_list = cs.keyword_list,
case.problem = cs.problem,
case.action = cs.action,
case.action_type = cs.action_type,
case.fiscal_amount = cs.fiscal_amount,
case.opinion = cs.opinion,
case.criteria = cs.criteria,
case.related_laws = cs.related_laws,
case.updated_at = datetime(),
case.created_at = coalesce(case.created_at, datetime())
MERGE (d)-[:HAS_CASE]->(case)
WITH d, case, cs, category_joined
:use auditdb
MERGE (h:CaseHeader {sub_code: case.sub_code})
SET h.doc_code = d.doc_code,
h.sub_order = cs.sub_order,
h.title = d.title,
// (교체) 첫 원소만 쓰지 않고, 전체를 "A|B|C"로 저장
h.category = CASE WHEN category_joined = "" THEN NULL ELSE category_joined END,
h.keyword_list = cs.keyword_list,
h.action_type = cs.action_type,
h.sub_title = cs.sub_title,
h.header_text = cs.header.header_text,
h.embedding = cs.header.embedding,
h.embedding_model = cs.header.embedding_model,
h.embedding_dim = cs.header.embedding_dim,
h.embedded_at = CASE WHEN cs.header.embedded_at IS NULL OR cs.header.embedded_at = "" THEN NULL ELSE datetime(cs.header.embedded_at) END,
h.updated_at = datetime(),
h.created_at = coalesce(h.created_at, datetime())
MERGE (case)-[:HAS_HEADER]->(h)
WITH case, cs
:use auditdb
UNWIND coalesce(cs.chunks, []) AS ck
MERGE (c:Chunk {sub_code: case.sub_code, chunk_id: toString(ck.chunk_id)})
SET c.doc_code = case.doc_code,
c.sub_order = case.sub_order,
c.sub_title = cs.sub_title,
c.seq = ck.seq,
c.text = ck.text,
c.token_count = ck.token_count,
c.keyword_list = cs.keyword_list,
c.problem = cs.problem,
c.action = cs.action,
c.action_type = cs.action_type,
c.fiscal_amount = cs.fiscal_amount,
c.opinion = cs.opinion,
c.criteria = cs.criteria,
c.related_laws = cs.related_laws,
c.updated_at = datetime(),
c.created_at = coalesce(c.created_at, datetime())
MERGE (case)-[:HAS_CHUNK]->(c);
:use auditdb
// 1) Document upsert
WITH $doc AS doc
MERGE (d:Document {doc_code: doc.doc_code})
SET d.title = doc.title,
d.purpose = doc.purpose,
d.site = doc.site,
d.category = doc.category,
d.audit_start_date = CASE WHEN doc.audit_start_date IS NULL OR doc.audit_start_date = "" THEN NULL ELSE date(doc.audit_start_date) END,
d.audit_end_date = CASE WHEN doc.audit_end_date IS NULL OR doc.audit_end_date = "" THEN NULL ELSE date(doc.audit_end_date) END,
d.date = CASE WHEN doc.date IS NULL OR doc.date = "" THEN NULL ELSE date(doc.date) END,
d.download_url = doc.download_url,
d.save_place = doc.save_place,
d.file_name = doc.file_name,
d.file_hash = doc.file_hash,
d.created_at = coalesce(d.created_at, datetime()),
d.updated_at = datetime()
WITH d,
// category(list) -> "A|B|C" 문자열로 join (APOC 없이)
reduce(s = "", x IN coalesce(d.category, []) |
CASE WHEN s = "" THEN toString(x) ELSE s + "|" + toString(x) END
) AS category_joined,
$cases AS cases
UNWIND cases AS cs
// 2) Case upsert + Document 연결
MERGE (case:Case {doc_code: d.doc_code, sub_order: cs.sub_order})
SET case.sub_code = coalesce(cs.sub_code, d.doc_code + "-" + toString(cs.sub_order)),
case.sub_title = cs.sub_title,
case.keyword_list = cs.keyword_list,
case.problem = cs.problem,
case.action = cs.action,
case.action_type = cs.action_type,
case.fiscal_amount = cs.fiscal_amount,
case.opinion = cs.opinion,
case.criteria = cs.criteria,
case.related_laws = cs.related_laws,
case.updated_at = datetime(),
case.created_at = coalesce(case.created_at, datetime())
MERGE (d)-[:HAS_CASE]->(case)
WITH d, case, cs, category_joined
// 3) CaseHeader upsert + Case 연결
MERGE (h:CaseHeader {sub_code: case.sub_code})
SET h.doc_code = d.doc_code,
h.sub_order = cs.sub_order,
h.title = d.title,
// (교체) 첫 원소만 쓰지 않고, 전체를 "A|B|C"로 저장
h.category = CASE WHEN category_joined = "" THEN NULL ELSE category_joined END,
h.keyword_list = cs.keyword_list,
h.action_type = cs.action_type,
h.sub_title = cs.sub_title,
h.header_text = cs.header.header_text,
h.embedding = cs.header.embedding,
h.embedding_model = cs.header.embedding_model,
h.embedding_dim = cs.header.embedding_dim,
h.embedded_at = CASE WHEN cs.header.embedded_at IS NULL OR cs.header.embedded_at = "" THEN NULL ELSE datetime(cs.header.embedded_at) END,
h.updated_at = datetime(),
h.created_at = coalesce(h.created_at, datetime())
MERGE (case)-[:HAS_HEADER]->(h)
WITH case, cs
// 4) Chunk upsert + Case 연결 (사례당 N개)
UNWIND coalesce(cs.chunks, []) AS ck
MERGE (c:Chunk {sub_code: case.sub_code, chunk_id: toString(ck.chunk_id)})
SET c.doc_code = case.doc_code,
c.sub_order = case.sub_order,
c.sub_title = cs.sub_title,
c.seq = ck.seq,
c.text = ck.text,
c.token_count = ck.token_count,
c.keyword_list = cs.keyword_list,
c.problem = cs.problem,
c.action = cs.action,
c.action_type = cs.action_type,
c.fiscal_amount = cs.fiscal_amount,
c.opinion = cs.opinion,
c.criteria = cs.criteria,
c.related_laws = cs.related_laws,
c.updated_at = datetime(),
c.created_at = coalesce(c.created_at, datetime())
MERGE (case)-[:HAS_CHUNK]->(c);
:use auditdb
CALL db.index.fulltext.queryNodes('chunk_text_ft', $q) YIELD node, score
WITH node AS c, score
OPTIONAL MATCH (case:Case)-[:HAS_CHUNK]->(c)
OPTIONAL MATCH (d:Document {doc_code: c.doc_code})
RETURN
c.doc_code AS doc_code,
case.sub_code AS sub_code,
c.sub_title AS sub_title,
c.chunk_id AS chunk_id,
c.seq AS seq,
c.text AS text,
score AS score,
d.title AS doc_title,
d.site AS site,
d.download_url AS download_url
ORDER BY score DESC, c.seq ASC
LIMIT $k;
:use auditdb
CALL db.index.vector.queryNodes('caseheader_embedding_idx', $kHeader, $q_embedding)
YIELD node, score
WITH node AS h, score
MATCH (case:Case {doc_code:h.doc_code, sub_order:h.sub_order})-[:HAS_CHUNK]->(c:Chunk)
OPTIONAL MATCH (d:Document {doc_code:h.doc_code})
RETURN
h.doc_code AS doc_code,
h.sub_code AS sub_code,
h.sub_title AS sub_title,
c.chunk_id AS chunk_id,
c.seq AS seq,
c.text AS text,
score AS score,
d.title AS doc_title,
d.site AS site,
d.download_url AS download_url
ORDER BY score DESC, c.seq ASC
LIMIT $kChunk;
v2를 먼저 시도 → 결과가 비었거나 너무 적으면(v2 실패/임베딩 누락) v1으로 폴백
목표: 기존 플로우의 Qdrant “검색 노드” 자리에 넣고, DataFrame을 출력해서 ReRanker로 그대로 넘기기
LangFlow 실행 환경에 neo4j 드라이버가 있어야 합니다.
아래는 “질문(text) + (선택) 질문 임베딩(list)”을 받아서
임베딩이 있으면 v2 먼저, 없거나 결과 부족하면 v1으로 폴백, DataFrame으로 반환
from __future__ import annotations
from typing import List, Optional
import pandas as pd
from neo4j import GraphDatabase
from langflow.custom import Component
from langflow.io import (
Output,
MessageTextInput,
StrInput,
SecretStrInput,
IntInput,
BoolInput,
DataInput,
)
from langflow.schema.dataframe import DataFrame
class Neo4jHybridRetrieverB(Component):
display_name = "Neo4j Hybrid Retriever (B: Case+Chunk)"
description = "Vector-first CaseHeader retrieval + Fulltext fallback Chunk retrieval. Returns DataFrame for reranker."
icon = "database"
name = "Neo4jHybridRetrieverB"
inputs = [
StrInput(name="uri", display_name="Neo4j URI", required=True, value="bolt://127.0.0.1:7687"),
StrInput(name="user", display_name="Neo4j User", required=True, value="neo4j"),
SecretStrInput(name="password", display_name="Neo4j Password", required=True),
StrInput(name="database", display_name="Database", required=True, value="auditdb"),
MessageTextInput(name="query", display_name="User Query", required=True),
DataInput(name="query_embedding", display_name="Query Embedding (optional)", required=False),
StrInput(name="vector_index", display_name="Vector Index Name", value="caseheader_embedding_idx"),
StrInput(name="fulltext_index", display_name="Fulltext Index Name", value="chunk_text_ft"),
IntInput(name="k_header", display_name="Top-K CaseHeaders (vector)", value=8),
IntInput(name="k_chunk", display_name="Max rows returned (vector route)", value=24),
IntInput(name="k_fulltext", display_name="Top-K rows (fulltext)", value=24),
IntInput(name="min_results", display_name="Min results before fallback", value=3),
BoolInput(name="enable_fulltext_fallback", display_name="Enable fulltext fallback", value=True),
]
outputs = [
Output(display_name="Docs (DataFrame)", name="docs", method="run"),
]
def _run(self, driver, database: str, cypher: str, params: dict) -> List[dict]:
with driver.session(database=database) as session:
res = session.run(cypher, params)
return [r.data() for r in res]
def run(self) -> DataFrame:
q = (self.query or "").strip()
if not q:
return DataFrame(pd.DataFrame([]))
# normalize embedding input (LangFlow DataInput can be dict or list)
q_embedding: Optional[list] = None
if self.query_embedding is not None:
if isinstance(self.query_embedding, dict) and "data" in self.query_embedding:
q_embedding = self.query_embedding["data"]
else:
q_embedding = self.query_embedding
if isinstance(q_embedding, list) and len(q_embedding) > 0:
try:
q_embedding = [float(x) for x in q_embedding]
except Exception:
q_embedding = None
else:
q_embedding = None
cypher_vector = """
CALL db.index.vector.queryNodes($vector_index, $k_header, $q_embedding)
YIELD node, score
WITH node AS h, score
MATCH (case:Case {doc_code:h.doc_code, sub_order:h.sub_order})-[:HAS_CHUNK]->(c:Chunk)
OPTIONAL MATCH (d:Document {doc_code:h.doc_code})
RETURN
h.doc_code AS doc_code,
h.sub_code AS sub_code,
h.sub_title AS sub_title,
c.chunk_id AS chunk_id,
c.seq AS seq,
c.text AS text,
score AS score,
d.title AS doc_title,
d.site AS site,
d.download_url AS download_url
ORDER BY score DESC, c.seq ASC
LIMIT $k_chunk;
"""
cypher_fulltext = """
CALL db.index.fulltext.queryNodes($fulltext_index, $q) YIELD node, score
WITH node AS c, score
OPTIONAL MATCH (case:Case {doc_code:c.doc_code, sub_order:c.sub_order})
OPTIONAL MATCH (d:Document {doc_code:c.doc_code})
RETURN
c.doc_code AS doc_code,
case.sub_code AS sub_code,
c.sub_title AS sub_title,
c.chunk_id AS chunk_id,
c.seq AS seq,
c.text AS text,
score AS score,
d.title AS doc_title,
d.site AS site,
d.download_url AS download_url
ORDER BY score DESC
LIMIT $k_fulltext;
"""
driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password))
try:
rows: List[dict] = []
# Vector-first route
if q_embedding is not None:
rows = self._run(
driver,
self.database,
cypher_vector,
{
"vector_index": self.vector_index,
"k_header": int(self.k_header),
"k_chunk": int(self.k_chunk),
"q_embedding": q_embedding,
},
)
# Fallback
if (not rows or len(rows) < int(self.min_results)) and bool(self.enable_fulltext_fallback):
rows = self._run(
driver,
self.database,
cypher_fulltext,
{
"fulltext_index": self.fulltext_index,
"q": q,
"k_fulltext": int(self.k_fulltext),
},
)
df = pd.DataFrame(rows)
if not df.empty:
# downstream compatibility
df["text"] = df["text"].fillna("")
for col in ["doc_code", "sub_code", "sub_title", "chunk_id", "doc_title", "site", "download_url"]:
if col in df.columns:
df[col] = df[col].fillna("")
if "score" in df.columns:
df["score"] = df["score"].fillna(0.0)
return DataFrame(df)
finally:
driver.close()
MultiQuery + ReRanker를 넣어 flow에서 검색 노드만 교체