03. 교정 가능한 오류 — og_explain_error / og_diagnose_empty
이 문서가 답하는 질문
- 실제로 반환되는 오류 코드는 무엇이고, 각각 어떤 메시지에서 나오는가?
- 어떤 실패가 "교정 가능"으로 분류되고, 어떤 실패가 오류로 잡히지도 않는가?
- 에이전트의 재시도 루프를 어떻게 설계해야 하는가?
- 원문
docs/agents.md의 예제와 실제 코드가 어긋나는 지점은?
1. 사실 — og_explain_error(graph, query) 의 실제 동작
정의: engine/src/agent/mod.rs:261-292. STABLE.
두 단계로 나뉜다.
// engine/src/agent/mod.rs:263-291
match crate::cypher::parser::parse(query) {
Err(e) => { ok:false, code:"CYPHER_PARSE_ERROR", message:e, stage:"parse" }
Ok(_) => match std::panic::catch_unwind(|| compile_for_diagnostics(graph, query)) {
Ok(Ok(_)) => { ok:true }
Ok(Err(msg)) => { ok:false, code:classify(msg), message:msg,
stage:"compile", suggestions:suggestions(graph,msg) }
Err(_) => { ok:false, code:"INTERNAL", message:"compilation aborted",
stage:"compile" }
}
}
1.1 오류 코드 전체 목록
code |
stage |
생성 조건 | 근거 |
|---|---|---|---|
CYPHER_PARSE_ERROR |
parse |
파서 실패. 메시지 형식은 "{msg} at offset {pos}, near: …{snippet}…" |
agent/mod.rs:264-269, cypher/parser.rs:97-101 |
UNKNOWN_LABEL |
compile |
메시지에 "unknown label" 포함 |
agent/mod.rs:294-296 |
UNBOUND_VARIABLE |
compile |
메시지에 "not defined" 포함 |
agent/mod.rs:297-298 |
UNKNOWN_FUNCTION |
compile |
메시지에 "unknown function" 포함 |
agent/mod.rs:299-300 |
UNSUPPORTED_SYNTAX |
compile |
메시지에 "not supported" 포함 |
agent/mod.rs:301-302 |
COMPILE_ERROR |
compile |
그 외 전부 | agent/mod.rs:303-304 |
INTERNAL |
compile |
컴파일이 패닉/ereport로 중단됨 (예: 존재하지 않는 graph 이름) | agent/mod.rs:283-288, cypher/compile.rs:155 |
1.2 UNKNOWN_LABEL 은 현재 코드에서 발생하지 않는다
classify() 는 메시지에 "unknown label" 이 있는지 본다 (agent/mod.rs:295). 그러나 저장소 전체에서 그 문자열을 생성하는 코드는 없다. 해당 문자열이 등장하는 위치는 classify/suggestions 두 곳과 주석 한 줄뿐이다 (engine/src/catalog/types.rs:203 — 주석).
실제로 Cypher 컴파일러가 알 수 없는 레이블을 만나면 오류를 내지 않는다:
// engine/src/catalog/types.rs:160-175 (resolve_label_set)
None => {
// A label nothing has ever been written under is not an error —
// in Cypher it simply matches nothing …
let near = nearest_type_names(gid, name);
if !near.is_empty() {
pgrx::notice!("label '{name}' does not exist in graph '{graph}' — matching \
nothing. did you mean: {}", near.join(", "));
}
return Ok(LabelMatch::Nothing);
}
컴파일러는 이를 constrain("false") 로 바꾼다 (engine/src/cypher/compile.rs:709-715). 관계 타입도 같다 — try_type_id (Option 반환)로 조회하고 없으면 매칭 집합이 비는 것으로 끝난다 (engine/src/cypher/compile.rs:835, 918, 941).
결과: SELECT og_explain_error('meeting', 'MATCH (p:Emploee) RETURN p') 는 {"ok": true} 를 반환한다. 교정 정보는 PostgreSQL NOTICE 로만 나가며, 이는 og_explain_error 의 JSON 응답에 들어가지 않는다.
결정(Decision): 오타 레이블을 오류로 만들지 않는 것은 의도된 설계다 — Neo4j 호환을 위해서다(types.rs:161-165 주석). 대가는 오타가 og_explain_error 로 잡히지 않고 og_diagnose_empty 단계까지 밀린다는 것.
1.3 suggestions() 의 실제 동작
// engine/src/agent/mod.rs:308-332
if let Some(rest) = msg.split("did you mean:").nth(1) {
return rest.trim().trim_end_matches('.').split(", ").collect();
}
if msg.contains("unknown label") { /* 이 분기는 도달 불가 — 1.2절 */ }
Value::Null
동작하는 것은 첫 번째 분기뿐이다. "did you mean:" 을 실어 나르는 메시지는 types::type_id 와 TypeQL 경로에서 나온다:
| 메시지 생성 위치 | 예시 |
|---|---|
| engine/src/catalog/types.rs:135 | type 'Emploee' does not exist. did you mean: Employee |
| engine/src/typeql/compile.rs:126 | type 'x' is not defined. did you mean: … |
| engine/src/typeql/write.rs:185 | 동일 |
이 중 types::type_id 는 Cypher 읽기 경로가 아니라 DDL·함수 인자 경로에서 쓰인다 (og_add_property, og_add_embedding, og_vector_search, og_enable_history 등). 그리고 type_id 는 Result 가 아니라 error! 를 던지므로 (types.rs:129-137), og_explain_error 로는 코드 INTERNAL 로만 도달한다.
1.4 후보 생성 규칙 — nearest_type_names
// engine/src/catalog/types.rs:236-261
let target = name.to_ascii_lowercase();
// 그래프의 모든 타입 이름과 Levenshtein 거리를 계산
let cutoff = (target.chars().count() / 2).max(2);
scored.filter(|(d,_)| *d <= cutoff).take(3)
| 입력 이름 길이 | cutoff (허용 편집거리) |
|---|---|
| 1~5 글자 | 2 |
| 6~7 글자 | 3 |
| 8~9 글자 | 4 |
| 20 글자 | 10 |
- 최대 3개 후보.
- 편집거리는 Rust 구현(
edit_distance, 2행 Levenshtein, types.rs:264-284) —fuzzystrmatch확장에 의존하지 않는다. - 비교는 소문자 접기 후 수행하되 ASCII만 접힌다.
- 프로퍼티 이름에 대한 후보 제안은 없다 (spec 008 FR-008이 요구하는 "프로퍼티 후보 목록" 미구현. specs/008-agent-native-interface/spec.md:69-70).
1.5 실제로 COMPILE_ERROR / UNBOUND_VARIABLE / UNKNOWN_FUNCTION 을 내는 메시지
| 메시지 | 결과 코드 | 근거 |
|---|---|---|
variable 'x' is not defined in this query |
UNBOUND_VARIABLE |
compile.rs:1091, 1190 |
unknown function 'foo'. supported: count, sum, … |
UNKNOWN_FUNCTION |
compile.rs:1558-1565 |
property access is only supported on pattern variables |
COMPILE_ERROR |
compile.rs:1178 |
'p' is a path; use length(p) or nodes(p) |
COMPILE_ERROR |
compile.rs:1188 |
id() expects a pattern variable |
COMPILE_ERROR |
compile.rs:1451 |
this clause is only valid in a write query |
COMPILE_ERROR |
compile.rs:364 |
genai.vector.encode(...) needs the text to encode |
COMPILE_ERROR |
compile.rs:1547-1549 |
쓰기 질의 (CREATE/MERGE/SET/…) |
COMPILE_ERROR — 메시지: write queries are not compiled to a single statement |
cypher/mod.rs:53-55 |
중요: 마지막 항목 때문에 og_explain_error 는 쓰기 질의를 검증할 수 없다. CREATE/MERGE/SET/DELETE 를 넘기면 항상 COMPILE_ERROR 가 나온다. 쓰기 질의의 사전 검증은 og_cypher_check (파싱만) 뿐이다 (cypher/mod.rs:699-709).
2. 사실 — og_diagnose_empty(graph, query)
정의: engine/src/agent/mod.rs:339-347 → engine/src/cypher/mod.rs:747-803.
2.1 알고리즘
첫 번째 MATCH 절의 패턴을 왼쪽부터 한 요소씩 늘려가며 count(*) 를 실행하고, 처음으로 0이 되는 지점을 보고한다.
// engine/src/cypher/mod.rs:755-793
for pattern in patterns {
for elem in &pattern.elems {
upto.push(elem.clone());
if matches!(elem, PatElem::Rel(_)) { continue; } // 관계에서 끊지 않음
// (upto)까지의 부분 패턴을 컴파일해 count(*) 실행
if n == 0 { steps.push({ verdict:"this is where the match became empty", … }); return }
}
}
if where_.is_some() {
steps.push({ verdict:"the pattern matched; the WHERE clause removed every row", … })
}
2.2 응답 형태
{ "graph": "meeting",
"steps": [
{ "elements": 1, "description": "(e:Employee)", "rows": 5 },
{ "elements": 3, "description": "(e:Employee)<-[:RESERVED_BY]-(r:Reservation)", "rows": 8 },
{ "verdict": "the pattern matched; the WHERE clause removed every row",
"hint": "relax the predicate or check property names with og_schema()" }
] }
description 은 AST에서 재구성한 문자열이고, 방향 표기는 -[:T]-> / <-[:T]- / -[:T]- 로 복원된다(cypher/mod.rs:805-823).
2.3 결함 — 파라미터가 전달되지 않는다
// engine/src/cypher/mod.rs:776
let n = exec_json(&compiled.sql, &json!({}))
부분 패턴은 빈 파라미터로 실행된다. Cypher 파라미터는 컴파일 시 ($1 ->> 'name') 형태가 되므로(compile.rs:1156-1157), MATCH (m:MeetingRoom {name: $room}) 같은 인라인 프로퍼티 맵을 가진 패턴은 항상 0행이 되어 "this is where the match became empty" 를 오보한다.
또한 WHERE 절은 부분 패턴 컴파일에 포함되지 않는다 (cypher/mod.rs:763-775 — compile_pattern + count(*) 프로젝션만). 따라서 rows 수치는 WHERE 이전 값이다.
2.4 미구현 — 관계 방향 진단
spec 008 FR-009는 "관계 방향 오류는 빈 결과가 아니라 '반대 방향에 N건 존재' 정보와 함께 제공되어야 한다"고 요구한다 (specs/008-agent-native-interface/spec.md:228-229). 구현된 힌트는 고정 문자열이다 (cypher/mod.rs:786-790):
"check the label spelling, the relationship type, and the direction of the arrow at this step"
반대 방향의 실제 행 수를 세는 코드는 없다.
3. 결정(Decision) — 에이전트 재시도 루프 설계 가이드
DB가 제공하는 신호만으로 루프를 짜면 다음이 된다. 각 분기의 근거는 위 절들이다.
질문
└─ og_schema_for(graph, question) [스키마 확보]
└─ (LLM) Cypher 생성
├─ og_cypher_check(query) [구문만, DB 접근 없음, 쓰기도 검증됨]
│ └─ ok=false → CYPHER_PARSE_ERROR 로 재작성 (재시도 1)
├─ og_explain_error(graph, query) [읽기 질의만 의미 있음]
│ ├─ code=UNBOUND_VARIABLE → 변수 바인딩 수정 (재시도 1)
│ ├─ code=UNKNOWN_FUNCTION → message의 supported 목록으로 치환 (재시도 1)
│ ├─ code=COMPILE_ERROR + suggestions≠null → 후보로 치환 (재시도 1)
│ ├─ code=COMPILE_ERROR + suggestions=null → 전면 재작성 (재시도 1)
│ └─ code=INTERNAL → graph 이름/권한 확인. 재시도해도 같은 결과
├─ og_estimate(graph, query) [비용]
│ └─ advice≠[] → 질의 좁히기 (재시도 1)
└─ og_cypher(graph, query, params)
├─ rows>0 → 답변 생성
├─ rows=0 → [새 트랜잭션] og_diagnose_empty
│ └─ verdict 위치의 요소를 재작성 (재시도 1)
└─ ERROR → [새 트랜잭션] og_explain_error 로 되돌아감
필수 설계 규칙
| 규칙 | 이유 |
|---|---|
| 재시도 상한을 에이전트 쪽에서 강제할 것 | 반복 실패 질의 속도 제한(FR-029 / T012)은 미구현 (tasks.md:23) |
| 진단 함수는 새 트랜잭션에서 호출 | og_cypher 실패 시 트랜잭션이 중단됨 (cypher/mod.rs:96-98, 140) |
0행일 때 og_diagnose_empty 를 호출하되, 파라미터를 쓰는 질의라면 결과를 의심할 것 |
2.3절 |
{"ok": true} 를 받았어도 0행이면 레이블 오타를 다시 의심할 것 |
1.2절 |
| 서버 로그/NOTICE 채널을 캡처할 것 | 레이블 오타 후보는 NOTICE로만 나간다 (1.2절) |
쓰기 질의는 og_cypher_check + og_estimate 로 검증할 수 없음을 전제할 것 |
1.5절 마지막 행, agent/mod.rs:352-354 |
Bolt 클라이언트(Neo4j 드라이버, MCP 서버)를 쓰는 경우 NOTICE는 드라이버의 notification 채널로 전달되는지 미확인이다. 저장소에 그 경로를 확인하는 테스트는 없다.
4. 사실 — 원문 문서와 코드의 불일치
docs/agents.md:87-105 는 다음 예제를 싣는다.
SELECT og_explain_error('default', 'MATCH (p:Persn) RETURN p');
{ "ok": false, "code": "UNKNOWN_LABEL", "stage": "compile",
"message": "unknown label 'Persn' in graph 'default'. did you mean: Person",
"suggestions": ["Person"] }
이 출력은 현재 코드에서 나오지 않는다. 근거는 1.2절이다. 실제 반환값은 {"ok": true} 이며, "unknown label 'Persn' in graph 'default'" 라는 문자열을 생성하는 코드는 저장소에 없다.
동일한 취지의 회귀 테스트가 존재하지만, 기대 출력이 저장소에 없어 무엇이 정답으로 간주되는지 확인할 수 없다 (engine/tests/sql/03_vector_agent_rdf.sql:41-42 — og_explain_error('kb', 'MATCH (d:Docs) RETURN d'); engine/tests/expected/ 디렉터리 부재).
이 문서는 코드를 정답으로 삼는다. 원문 수정은 이 카테고리의 범위 밖이며, 개선 항목 10_improvements_llm.md LLM-03 으로 등록한다.
5. 필수(Required) / 금지(Forbidden)
필수
- 재시도 분기는
code로 할 것.message문자열 매칭 금지 (FR-011의 안정성 보장 대상은code). og_explain_error호출 시graph이름을 검증할 것. 없는 그래프는INTERNAL로 나온다.suggestions가null인 경우와[]인 경우를 구분할 것.null은 "제안 없음", 빈 배열은"did you mean:"뒤가 비었던 경우다.
금지
og_explain_error의{"ok": true}→ "질의가 안전하다/결과가 있다"로 해석 금지.og_diagnose_empty를 파라미터 바인딩이 필요한 질의에 쓰고 그 결과를 신뢰하는 것 금지 (2.3절).og_explain_error를 쓰기 질의에 사용하는 것 금지 — 항상 실패한다 (1.5절).std::panic::catch_unwind로 잡힌INTERNAL이후에 같은 세션에서 계속 질의하는 것을 전제하지 말 것. pgrx는 이 목적에PgTryBuilder를 두고 있으며, 원시catch_unwind로 ereport를 삼킨 뒤의 백엔드 상태는 이 저장소에서 검증되지 않았다(미확인). 근거 위치: engine/src/agent/mod.rs:271-273, engine/Cargo.toml:39-43 (panic = "unwind").
6. 참고
- 원문: docs/agents.md:87-124
- 함수 계약: docs/api.md:175-176
- 스펙: FR-007~FR-011 (specs/008-agent-native-interface/spec.md:222-233)
- 개선 제안: 10_improvements_llm.md LLM-03, LLM-04