목차
- 문제와 접근
- 데이터 확인
- PDF에서 TXT 추출
- GraphRAG 프로젝트 준비
- 회의록 텍스트 전처리
- input 파일 생성
- settings.yaml 도메인 설정
- GraphRAG indexing
- entity / relationship artifact 분석
- local/global query
- 그래프 구조 진단
- 엔티티 추출 품질 평가
- 평가 질문 자동 생성
- 답변 품질 평가
- 개선 전략
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 진단에서 설정 변경으로 이어지는 기준
1. 문제와 접근
실제 회의록을 GraphRAG로 옮기면 문서 정제와 도메인 표현이 추출 결과에 직접 영향을 준다. PDF 텍스트부터 그래프 산출물과 답변 평가까지 연결해 어디에서 근거가 손실되거나 변형되는지 살펴본다. 그래프의 크기와 연결성은 진단 신호로 보고, 품질 판단은 원문과 질문에 대한 결과로 확인한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| CSV/PDF 원본
↓
pdfplumber TXT 추출
↓
회의록 전처리
↓
GraphRAG input 구성
↓
settings.yaml 도메인 설정
↓
indexing
↓
artifact 분석 + query + 평가
|
2. 데이터 확인
1
2
3
4
5
6
7
8
9
10
11
12
| import os
import re
import json
from pathlib import Path
import pandas as pd
import numpy as np
DATA = Path("..") / "data"
csv_path = DATA / "위원회회의록.csv"
pdf_dir = DATA / "assembly_pdf"
txt_dir = DATA / "assembly_txt"
txt_dir.mkdir(exist_ok=True)
|
1
2
3
4
5
6
7
8
9
| df = pd.read_csv(csv_path, encoding="euc-kr")
confer_nums = sorted(df["회의번호"].unique())
n_pdf = len(list(pdf_dir.glob("*.pdf"))) if pdf_dir.exists() else 0
n_txt = len(list(txt_dir.glob("*.txt")))
print(f"CSV: {len(df)}행, {df['회의번호'].nunique()}개 회의")
print(f"PDF: {n_pdf}개")
print(f"TXT: {n_txt}개")
|
3. PDF에서 TXT 추출
1
2
3
4
5
6
7
8
| import pdfplumber
DEMO_IDS = [56254, 56297, 56351, 56419, 56481]
extract_targets = [
cn for cn in DEMO_IDS
if (pdf_dir / f"{cn}.pdf").exists()
]
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| for cn in extract_targets:
pdf_path = pdf_dir / f"{cn}.pdf"
txt_path = txt_dir / f"{cn}.txt"
with pdfplumber.open(pdf_path) as pdf:
pages = [p.extract_text() or "" for p in pdf.pages]
row = df[df["회의번호"] == cn].iloc[0]
header = (
f"# {row['회의명']}\n"
f"# 날짜: {row['회의날짜']}\n"
f"# 회의번호: {cn}\n\n"
)
txt_path.write_text(header + "\n\n".join(pages), encoding="utf-8")
|
4. GraphRAG 프로젝트 준비
1
2
| PROJECT_ROOT = Path("..").resolve() / "assembly_ragtest_0616"
PROJECT_ROOT.mkdir(exist_ok=True)
|
1
| !graphrag init --root {PROJECT_ROOT} --model gpt-4o-mini --embedding text-embedding-3-small --force
|
5. 회의록 텍스트 전처리
회의록의 반복 표기와 장식 문자를 제거할 때도 발언자·시점·소속처럼 관계 해석에 필요한 정보는 구분해서 보존해야 한다. 전처리 전후 일부 페이지를 대조해 발언의 주체나 인용 경계가 바뀌지 않았는지 확인한다.
페이지 번호, 불필요한 장식, 발언자 표시를 정리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| def preprocess_assembly_text(raw_text):
lines = raw_text.split("\n")
cleaned = []
for line in lines:
line = line.strip()
if not line:
cleaned.append("")
continue
if re.match(r"^제\d+회.+\d+$", line):
continue
if re.match(r"^\d{1,3}$", line):
continue
if re.match(r"^[·...]+$", line):
continue
line = re.sub(r"^◯(\S+)\s+(\S+)", r"[\1 \2]", line)
cleaned.append(line)
text = "\n".join(cleaned)
text = re.sub(r"\n{3,}", "\n\n", text)
return text
|
GraphRAG input 폴더에 전처리된 회의록을 넣는다.
1
2
3
4
5
6
7
8
9
10
11
12
| input_dir = PROJECT_ROOT / "input"
input_dir.mkdir(exist_ok=True)
for cn in DEMO_IDS:
raw = (txt_dir / f"{cn}.txt").read_text(encoding="utf-8")
cleaned = preprocess_assembly_text(raw)
row = df[df["회의번호"] == cn].iloc[0]
date_str = row["회의날짜"].replace("-", "")
fname = f"{date_str}_{cn}.txt"
(input_dir / fname).write_text(cleaned, encoding="utf-8")
|
7. settings.yaml 도메인 설정
회의록 도메인에 맞게 chunk size, entity type, graphml snapshot, report 길이를 조정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import yaml
setting_path = PROJECT_ROOT / "settings.yaml"
with open(setting_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
cfg["chunking"]["size"] = 600
cfg["extract_graph"]["entity_types"] = [
"person",
"organization",
"law",
"committee",
"policy",
"event",
]
cfg.setdefault("snapshots", {})["graphml"] = True
cfg["community_reports"]["max_length"] = 3000
with open(setting_path, "w", encoding="utf-8") as f:
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
1
2
3
4
| env_path = PROJECT_ROOT / ".env"
api_key = os.environ.get("OPENAI_API_KEY", "")
if api_key:
env_path.write_text(f"GRAPHRAG_API_KEY={api_key}\n")
|
8. GraphRAG indexing
1
| !graphrag index --root {PROJECT_ROOT}
|
1
2
3
| OUTPUT = PROJECT_ROOT / "output"
for f in sorted(OUTPUT.glob("*.parquet")):
print(f.name)
|
9. entity / relationship artifact 분석
1
2
3
4
5
6
7
| entities = pd.read_parquet(OUTPUT / "entities.parquet")
type_counts = entities["type"].value_counts()
for t, c in type_counts.items():
print(f"{t}: {c}개")
display(entities.nlargest(20, "degree")[["title", "type", "degree", "description"]])
|
1
2
| rels = pd.read_parquet(OUTPUT / "relationships.parquet")
display(rels.nlargest(15, "weight")[["source", "target", "description", "weight"]])
|
10. local/global query
특정 인물 질문은 local, 전체 정책 이슈는 global에 적합하다.
1
| !graphrag query --root {PROJECT_ROOT} --method local "고광헌 후보자는 어떤 경력을 가지고 있으며, 인사청문회에서 주요 쟁점은 무엇이었나요?"
|
1
| !graphrag query --root {PROJECT_ROOT} --method global "2026년 초 과방위에서 가장 활발하게 논의된 정책 이슈 3가지는 무엇인가요?"
|
11. 그래프 구조 진단
GraphML snapshot을 읽어 노드/엣지 수, 평균 degree, density, isolate를 확인한다.
1
2
3
4
5
6
7
8
9
10
11
| import networkx as nx
graphml_files = list(OUTPUT.rglob("*.graphml"))
g = nx.read_graphml(graphml_files[0])
n_nodes = g.number_of_nodes()
n_edges = g.number_of_edges()
avg_degree = 2 * n_edges / n_nodes
density = nx.density(g)
n_isolates = len(list(nx.isolates(g)))
|
1
2
3
4
5
6
7
8
| 고립 노드(degree=0)가 너무 많다:
관계 추출이 부족할 수 있음
평균 degree가 너무 낮다:
엔티티 간 연결성이 약할 수 있음
커뮤니티 크기가 극단적으로 치우친다:
chunking 또는 entity 정규화 문제 가능
|
12. 엔티티 추출 품질 평가
원본 chunk와 추출된 entity를 LLM-as-a-Judge로 평가한다. 아래 precision_score와 recall_score는 LLM의 추정 점수이며, 정답 엔티티 집합과 대조해 계산한 정밀도·재현율은 아니다. 또한 예제는 텍스트 앞 2,000자만 전달하므로 그 범위의 엔티티를 비교해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| from openai import OpenAI
client = OpenAI()
def evaluate_entity_extraction(chunk_text, extracted_entities, model="gpt-4o-mini"):
entity_list = "\n".join(
f"- {row['title']} ({row['type']}) : {row.get('description', 'N/A')[:100]}"
for _, row in extracted_entities.iterrows()
)
prompt = f"""당신은 한국어 텍스트에서 엔티티 추출 품질을 평가하는 전문가입니다.
### 텍스트
{chunk_text[:2000]}
### 시스템이 추출한 엔티티
{entity_list if entity_list.strip() else "(없음)"}
다음 JSON 형식으로 응답하세요:
{{
"precision_score": 0.0,
"recall_score": 0.0,
"false_positives": [],
"false_negatives": [],
"type_accuracy": 0.0,
"reasoning": "..."
}}"""
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(response.choices[0].message.content)
|
13. 평가 질문 자동 생성
커뮤니티 리포트를 기반으로 local/global 평가 질문을 자동 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| def generate_eval_questions(reports_df, n_local=5, n_global=3, model="gpt-4o-mini"):
report_summaries = "\n\n".join(
f"[커뮤니티: {row['title']}]\n{row['summary']}"
for _, row in reports_df.iterrows()
)
prompt = f"""아래는 국회 과방위 회의록 분석 결과의 커뮤니티 요약입니다.
{report_summaries}
평가용 질문을 생성하세요.
Local Search용 질문 {n_local}개:
특정 인물·조직·법안에 대한 구체적 질문.
Global Search용 질문 {n_global}개:
전체 회의록을 종합해야 답할 수 있는 거시 질문.
JSON 형식:
{{
"local_questions": [],
"global_questions": []
}}"""
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
response_format={"type": "json_object"},
)
return json.loads(response.choices[0].message.content)
|
14. 답변 품질 평가
질문 → GraphRAG 답변과 검색 근거 확보 → LLM-as-a-Judge 평가 흐름이다. 충실도는 평가기에 전달한 근거와 답변을 대조해 판단한다. 질문과 답변만으로는 원본에 근거했는지 평가할 수 없다.
context에는 검색에서 사용한 청크나 커뮤니티 리포트를 출처와 함께 문자열로 전달한다. 답변 텍스트 자체를 근거로 다시 넣지 않는다. 커뮤니티 리포트만 전달하면 그 요약에 대한 충실도를 평가하는 것이므로, 원본 회의록까지 검증하려면 해당 발언의 원문도 포함한다.
| 차원 | 설명 | 점수 |
|---|
| faithfulness | 답변의 주장이 제공된 근거로 뒷받침되는가 | 1~5 |
| relevance | 질문에 직접 답하는가 | 1~5 |
| completeness | 질문과 제공된 근거의 중요 측면을 다루는가 | 1~5 |
| coherence | 구조적이고 읽기 쉬운가 | 1~5 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| def evaluate_answer(question, answer, context, model="gpt-4o-mini"):
if not isinstance(context, str) or not context.strip():
raise ValueError("평가할 검색 근거를 context에 제공해야 합니다.")
prompt = f"""당신은 RAG 시스템의 답변 품질을 평가하는 전문가입니다.
### 질문
{question}
### 검색 근거
{context}
### 시스템 답변
{answer}
제공된 근거만 사용해 각 항목을 1~5 정수로 평가하세요. 2와 4는 중간 수준입니다.
- faithfulness: 1=핵심 주장이 근거에 없거나 모순, 3=일부 주장만 뒷받침됨, 5=모든 검증 가능한 주장이 뒷받침됨
- relevance: 1=질문과 무관, 3=일부만 답함, 5=질문에 직접 답함
- completeness: 1=근거에서 답할 수 있는 핵심 내용 대부분 누락, 3=일부 누락, 5=질문이 요구하고 근거로 확인되는 내용을 충분히 다룸
- coherence: 1=내용 연결이 어렵거나 내부 모순, 3=일부 흐름이 불명확, 5=일관되고 읽기 쉬움
근거에서 확인할 수 없는 주장은 확인 불가로 보고 reasoning에 명시하세요.
reasoning에는 점수 이유와 사용한 근거 구절 또는 출처를 적으세요. 근거 자체가 부족한 경우와 답변이 내용을 누락한 경우를 구분하세요.
JSON 형식:
{{
"faithfulness": 1,
"relevance": 1,
"completeness": 1,
"coherence": 1,
"reasoning": "..."
}}"""
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(response.choices[0].message.content)
|
평가 함수는 evaluate_answer(question, answer, context)로 호출한다. 10절의 CLI 예시는 질의 방법을 보여 주며 이 함수와 자동 연결되지는 않는다. 실제 평가에서는 질의 결과의 답변과 검색 근거를 확보해 각각 전달해야 한다. 점수는 LLM의 판단이므로 정답률로 해석하지 말고, 낮은 점수의 이유와 인용 근거를 원문으로 확인한다.
15. 개선 전략
개선 표의 항목은 원인을 확정한 처방보다 검증할 가설로 사용한다. 고립 노드가 많다면 전처리 누락과 엔티티 표기 불일치도 확인하고, 설정을 하나씩 바꾸어 동일한 질문과 원문 기준에서 결과를 비교한다.
| 문제 | 원인 | 해결책 |
|---|
| 재현율 낮음 | 청크 큼 / 글리닝 부족 | chunking.size 감소, max_gleanings 증가 |
| 정밀도 낮음 | 도메인 안 맞는 유형 | entity_types 조정 |
| 고립 노드 많음 | 관계 추출 부족 | 프롬프트에 관계 강조 |
| Local 답변 불완전 | 참조 chunk 부족 | local_search.text_unit_prop 증가 |
| Global 답변 피상적 | 커뮤니티 리포트 짧음 | community_reports.max_length 증가 |
1
| 인덱싱 -> 평가 -> 진단 -> 설정 조정 -> 재인덱싱 -> 재평가
|
16. 핵심 개념 총정리
| 개념 | 역할 |
|---|
| pdfplumber | PDF 회의록 텍스트 추출 |
| preprocess_assembly_text | 회의록 특유의 잡음 제거 |
| GraphRAG input | 전처리된 txt 파일 위치 |
| entity_types | 도메인 엔티티 유형 지정 |
| graphml snapshot | NetworkX로 구조 진단 가능 |
| entities/relationships parquet | 추출 결과 분석 |
| local/global query | 세부 질문/전체 질문 처리 |
| LLM-as-a-Judge | 추출 품질과 답변 품질 평가 |
| 개선 루프 | 설정 변경 후 재인덱싱/재평가 |
17. 자주 나오는 실수/주의사항
실수 1: CSV 인코딩
1
| pd.read_csv(csv_path, encoding="euc-kr")
|
실수 2: PDF 텍스트 추출 실패 처리 누락
1
| pages = [p.extract_text() or "" for p in pdf.pages]
|
실수 3: GraphRAG CLI 옵션 이름 혼동
1
2
| 사용 중인 graphrag 버전에 따라 --embedding / --embedding_model 옵션이 다를 수 있다.
명령 실행 전 버전별 도움말을 확인한다.
|
실수 4: 전처리 없이 회의록 그대로 인덱싱
1
| 페이지 번호, 장식 문자, 발언자 표기가 섞이면 엔티티/관계 추출 품질이 떨어진다.
|
실수 5: 평가 없이 설정만 변경
1
| chunk size, entity type, max_gleanings 변경은 반드시 평가 점수와 함께 비교해야 한다.
|
18. 진단에서 설정 변경으로 이어지는 기준
그래프 구조가 달라졌다는 사실과 답변 근거가 좋아졌다는 판단은 따로 남긴다. 엔티티 수나 평균 연결 수가 증가했더라도 잘못된 병합이나 관계 추출이 원인일 수 있으므로, 바뀐 결과 중 질문에 사용되는 부분부터 원문과 대조한다.
평가 기록에는 질문, 답변, 전달한 근거, 평가 이유를 함께 보관한다. 같은 질문을 재평가했을 때 설정 변경으로 어떤 근거가 추가되거나 사라졌는지 설명할 수 있어야 다음 개선 방향을 정할 수 있다.