목차
- 문제와 접근
- 데이터 로드와 RAG 기본 흐름
- 텍스트 chunking
- 임베딩과 cosine similarity
- FAISS vector store
- naive_rag 함수
- multi-hop 질문 실패 진단
- Knowledge Graph 개념
- NetworkX 그래프 만들기
- 그래프 탐색과 경로 찾기
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 그래프가 필요한 문제인지 먼저 확인하기
1. 문제와 접근
여러 문서의 관계를 연결해야 하는 질문에서는 검색한 청크만으로 답변 근거가 완성되는지 먼저 확인해야 한다. 기본 RAG의 실패를 검색 누락과 관계 연결 부족으로 나누고, 그래프가 어떤 부분을 보완할 수 있는지 살펴본다. 그래프 도입 자체보다 필요한 근거 경로를 표현할 수 있는지가 판단 기준이다.
1
2
3
4
5
| Naive RAG:
text -> chunk -> embedding -> top-k -> context -> answer
GraphRAG:
text -> entity/relation -> graph -> community/path -> answer
|
1
2
3
4
5
6
7
8
9
10
11
12
| from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_text_splitters import RecursiveCharacterTextSplitter
llm = ChatOpenAI(model="gpt-4o-mini")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
2. 데이터 로드와 RAG 기본 흐름
1
2
| DATA_PATH = Path("data/council.txt")
text = DATA_PATH.read_text(encoding="utf-8")
|
1
2
3
4
5
6
7
8
| RAG:
text
-> chunking
-> embedding
-> vector store
-> query top-k
-> context + query
-> LLM answer
|
3. 텍스트 chunking
1
2
3
4
5
6
7
| splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(text)
|
chunk가 너무 작으면 문맥이 잘리고, 너무 크면 검색 정밀도가 떨어질 수 있다.
4. 임베딩과 cosine similarity
비슷한 문장끼리 임베딩 유사도가 높게 나온다.
1
2
3
4
| v1 = embeddings.embed_query("노라셋의 공명 하드웨어")
v2 = embeddings.embed_query(
"노라셋 엔지니어링 그룹이 설계하고 제작하는 주력 제품은 공명 기반의 연산 하드웨어이다."
)
|
1
2
3
4
5
6
7
8
| import numpy as np
def cos(x, y):
return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y)))
cos(np.array(v1), np.array(v2))
|
5. FAISS vector store
1
2
3
4
5
| def build_vector_store(chunks: list[str]) -> FAISS:
return FAISS.from_texts(chunks, embeddings)
vs = build_vector_store(chunks)
|
1
2
3
4
| hits = vs.similarity_search_with_score("콰빅스 연구소 설립자", k=3)
for doc, dist in hits:
print(f"{dist} : {doc.page_content}")
|
6. naive_rag 함수
검색된 top-k chunk만 컨텍스트로 넣어 답변한다.
1
2
| def retrieve(query, vs, k):
return [d.page_content for d in vs.similarity_search(query, k=k)]
|
1
2
3
4
5
6
7
8
9
10
| def naive_rag(question, vs, k=3):
chunks = retrieve(question, vs, k=k)
ctx = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(chunks))
sys = SystemMessage(
content="주어진 컨텍스트만으로 한국어로 답하세요. 컨텍스트에 답이 없으면 문서에서 찾을 수 없다고 답하세요"
)
user = HumanMessage(content=f"컨텍스트:\n{ctx}\n\n질문 : {question}")
return llm.invoke([sys, user]).content
|
7. multi-hop 질문 실패 진단
답변 실패를 곧바로 벡터 검색의 한계로 결론 내리지 않는다. 연결에 필요한 청크가 검색됐는지 먼저 확인하고, 청크가 부족한 경우와 모두 있는데 관계를 조합하지 못한 경우를 나누어 본다.
다중 홉 질문은 한 번의 유사도 검색으로 필요한 모든 단서를 못 찾을 수 있다.
1
2
3
4
5
6
| 질문:
모리카이 센의 멘토가 쓴 논문은 무엇인가요?
필요한 추론:
모리카이 센의 멘토 -> 탈라미르 헤쉬
탈라미르 헤쉬가 쓴 논문 -> 공명 프레임
|
1
2
| def question_contains_answer(chunks, key):
return any(key in c for c in chunks)
|
1
2
3
4
5
6
7
8
| def multihop_diagnosis(question, vs, key, k=5):
chunks = retrieve(question, vs, k=k)
answer = naive_rag(question, vs, k=k)
return {
"key_in_chunks": any(key in c for c in chunks),
"key_in_answers": key in answer,
"answer": answer,
}
|
8. Knowledge Graph 개념
텍스트 속 인물, 조직, 제품, 논문 등을 노드로 만들고 관계를 엣지로 만든다.
1
2
3
4
5
| 문장:
모리카이 센은 1987년에 콰빅스 연구소를 설립했다.
그래프:
(모리카이 센) --[founded]--> (콰빅스 연구소)
|
9. NetworkX 그래프 만들기
1
2
3
4
5
6
7
8
9
10
11
12
13
| import networkx as nx
G_demo = nx.DiGraph()
G_demo.add_node("모리카이 센", type="Person")
G_demo.add_node("콰빅스 연구소", type="Organization")
G_demo.add_node("브렌술 아카데미", type="Organization")
G_demo.add_node("탈라미르 헤쉬", type="Person")
G_demo.add_node("벨트란 사고 엔진", type="Product")
G_demo.add_edge("모리카이 센", "콰빅스 연구소", relation="founded", year=1987)
G_demo.add_edge("모리카이 센", "브렌술 아카데미", relation="postdoc_at")
G_demo.add_edge("탈라미르 헤쉬", "모리카이 센", relation="mentor_of")
G_demo.add_edge("콰빅스 연구소", "벨트란 사고 엔진", relation="develops")
|
1
2
| for u, v, d in G_demo.edges(data=True):
print(f"({u}) --{d['relation']}--> ({v})")
|
10. 그래프 탐색과 경로 찾기
그래프에서 경로를 찾았다는 사실과 그 경로가 질문의 답을 뒷받침한다는 판단은 다르다. 연결된 노드뿐 아니라 관계의 종류와 방향, 각 관계의 원문 근거를 함께 확인한다.
1
2
| list(G_demo.successors("모리카이 센"))
list(G_demo.predecessors("모리카이 센"))
|
1
2
3
4
5
6
7
| def neighbors_with_labels(G, node):
out = []
for v in G.successors(node):
out.append({"direction": "out", "relation": G[node][v].get("relation", ""), "neighbor": v})
for u in G.predecessors(node):
out.append({"direction": "in", "relation": G[u][node].get("relation", ""), "neighbor": u})
return out
|
1
2
3
4
5
6
| def multi_hop_paths(G, src, tgt, max_hops=3):
results = []
for path in nx.all_simple_paths(G, src, tgt, cutoff=max_hops):
rels = [G[path[i]][path[i+1]].get("relation", "") for i in range(len(path)-1)]
results.append({"path": path, "relations": rels})
return results
|
11. 핵심 개념 총정리
| 개념 | 역할 |
|---|
| RecursiveCharacterTextSplitter | 텍스트를 chunk로 분할 |
| FAISS | chunk 임베딩 검색 |
| naive_rag | top-k chunk 기반 답변 |
| multi-hop question | 여러 관계를 이어야 하는 질문 |
| Knowledge Graph | 엔티티와 관계를 그래프로 표현 |
| NetworkX DiGraph | 방향 그래프 구현 |
| successors/predecessors | outgoing/incoming 이웃 조회 |
| shortest_path/all_simple_paths | 다중 홉 경로 탐색 |
12. 자주 나오는 실수/주의사항
실수 1: naive RAG로 multi-hop을 해결하려고 함
1
| top-k chunk에 필요한 단서가 모두 들어오지 않으면 답이 틀리거나 빠질 수 있다.
|
실수 2: edge 방향 무시
1
2
| G.successors(node)
G.predecessors(node)
|
방향 그래프에서는 들어오는 관계와 나가는 관계를 구분해야 한다.
실수 3: chunk 크기 고정
1
| chunk_size=300이 항상 정답은 아니다. 문서 구조와 질문 유형에 맞게 조정해야 한다.
|
실수 4: 그래프와 원문 연결 없음
1
2
| 그래프 노드/엣지만 있으면 근거 문장으로 돌아가기 어렵다.
나중에는 source chunk id를 함께 저장해야 한다.
|
13. 그래프가 필요한 문제인지 먼저 확인하기
관계가 여러 단계 이어지는 질문을 기준으로 벡터 검색의 실패를 살펴본다. 검색 범위나 질의 분해로 해결할 수 있는지와 명시적 관계 구조가 필요한지를 비교하면 그래프 구축 비용을 들일 이유가 분명해진다.
수동 그래프 예제는 관계를 표현하고 탐색하는 기준을 확인하는 단계다. 실제 문서에 적용할 때는 잘못 추출한 관계와 누락된 관계가 탐색 결과를 어떻게 바꾸는지도 별도로 평가해야 한다.