관계로 묻기: ak 그래프

`ak 그래프`로 이름·태그 범위를 정해 두고 묻는 그래프 질의. 요청하는 법, 유형별 예문과 옮겨진 질의, 문법, 응답 읽는 법, 상한.

검색하기의 "관계로 묻기" 절을 한 자리에 모아 넓힌 문서다. 요청하는 법, 질문 유형별 예문과 AI가 옮긴 질의, 문법 전체, 응답을 읽는 법, 상한과 한계를 다룬다.

1. 무엇을 하나, 언제 쓰나

그래프 질의는 기억을 관계와 범위를 두고 묻는 길이다. "이 이름이 나오는 기억 중에서만", "이 태그 안에서만", "이 기억들과 이름을 많이 나눠 가진 기억", "그다음에 무슨 일이" 같은 질문을 한 번의 질의로 쓴다. 읽기만 하고, 아무것도 바꾸지 않는다.

찾는 길은 셋이다. 질문의 모양으로 고른다.

묻고 싶은 것 쓰는 길 까닭
문장으로 찾기: "세이브 버그 어떻게 고쳤지?" 일반 검색 (ak …) 의미와 연결로 찾고, 결과가 어떻게 엮이는지 지도를 같이 준다. 기본 길이다
범위를 정해 두고 뜻으로 찾기: "세이브 포맷이 나오는 기억 중에서 로드 버그를 고친 기록" 그래프 질의 (ak 그래프 …) 그 범위 안에서만 뜻이 가까운 순으로 고른다. 범위 밖의 기억이 자리를 먼저 차지하지 않는다
관계를 따라가기: "이 두 기억과 이름을 가장 많이 나눠 가진 기억", "그다음 기록" 그래프 질의 함께 나오는 이름, 기록의 순서, 뜻의 가까움을 따라 걷는다
범위의 전부, 목록: "balance 태그가 붙은 기억 전부" 그래프 질의의 목록 모양 순위가 아니라 목록이 필요하다. 한 번에 최신 200건까지
글자 그대로의 일치, 정확한 개수: "요약에 '롤백'이라고 적힌 기억이 몇 건?" AI가 SQL 조회 도구로 센다 그래프 질의는 뜻으로 가까운 순이지 글자 일치가 아니다. 이름 부분 일치도 엔티티·태그 이름에만 걸리고 요약 글에는 걸리지 않는다

그래프 질의는 사용자가 ak 그래프로 요청할 때만 쓴다. 문구 없이 물으면 연결에 관한 질문이어도 AI는 일반 검색으로 시작하고, 스스로 그래프 질의로 바꾸지 않는다.

2. 요청하는 법

질문 앞에 ak 그래프를 붙여 한 메시지로 보낸다. 질문은 평소 말투 그대로 쓴다.

ak 그래프 세이브 포맷이 나오는 기억 중에서 로드 버그를 고친 기록
ak 그래프 balance 태그가 붙은 기억 중에서 적 체력을 바꾼 것
ak 그래프 인벤토리와 세이브 포맷이 모두 나오는 기억
  • AI는 질문을 그래프 질의로 옮겨 실행하고, 답과 함께 실행한 질의와 그 뜻을 한 줄로 보여 준다. 무엇을 찾았는지 눈으로 확인할 수 있게 하려는 것이다.
  • 이름과 태그는 저장된 표기 그대로 맞춰 찾는다. 표기가 헷갈리면 AI가 먼저 이름 조회로 확인한다.
  • ak 그래프만 보내면 AI가 따라 쓸 수 있는 예문 셋을 내 기억에 있는 이름으로 바꿔 보여 준다.

3. 질문 유형별 예문과 옮겨진 질의

요청 칸에는 같은 뜻을 두 가지로 말해 두었다. 내 말투로 바꿔 물어도 된다. 질의 안의 $이름은 매개변수 자리이고, 값은 옆의 params로 넘어간다.

요청 (사용자 말투) 옮겨진 질의 응답에서 볼 것
① 엔티티 범위 안 의미 검색. "세이브 포맷이 나오는 기억 중에서 로드 버그를 고친 기록" 또는 "세이브 포맷 얘기 가운데 로드 버그 잡은 거 찾아 줘" START a = events(entity: $name, text: $q, k: 20) RETURN a, a.score ORDER BY a.score DESC, a.id · params {"name": "세이브 포맷", "q": "로드 버그를 고친 기록"} 가까운 순 상위 20건이다. start.has_moretrue면 범위에 더 있다: candidates.count를 보고 "64건 중 가까운 20건"으로 말한다
② 태그 범위 안 의미 검색. "balance 태그가 붙은 기억 중에서 적 체력을 바꾼 것" 또는 "balance 태그 안에서 적 HP 조정한 기록만" START a = events(tag: $tag, text: $q, k: 20) RETURN a, a.score ORDER BY a.score DESC, a.id · params {"tag": "balance", "q": "적 체력을 바꾼 것"} ①과 같다. 태그 이름이 정확히 맞아야 한다
③ 이름 부분 일치. "세이브 포맷과 같은 기억에 나오는 이름 중에 '버그'가 들어간 것 전부" 또는 "세이브 포맷이랑 같이 나온 엔티티 중 이름에 버그 들어간 거" START a = events(entity: $name, k: 200) MATCH (a)-[:PARTICIPATED_IN]-(n) WHERE n.name CONTAINS $part RETURN DISTINCT n.name LIMIT 200 · params {"name": "세이브 포맷", "part": "버그"} 세이브 포맷 기억의 최신 200건에서 찾는다. start.hubtrue면 200건을 넘어 오래된 쪽은 못 봤다
④ 전부·목록. "balance 태그가 붙은 기억 전부" 또는 "balance 태그 목록 쭉 보여 줘" START a = events(tag: $tag, k: 200) RETURN a LIMIT 200 · params {"tag": "balance"} (이름 범위면 entity: $name) 최신순 목록이다. start.hubtrue면 200건을 넘었다: "최신 200건만 보여 준다"고 말한다
⑤ A와 B가 모두 나오는 기억. "인벤토리와 세이브 포맷이 모두 나오는 기억" 또는 "인벤토리 기록 중에 세이브 포맷도 언급된 거" START a = events(entity: $a, k: 200) MATCH (a)-[:PARTICIPATED_IN]-(n {name: $b}) RETURN DISTINCT a LIMIT 200 · params {"a": "인벤토리", "b": "세이브 포맷"} 인벤토리 기억의 최신 200건 안에서 모은다. start.hubtrue면 그 밖은 못 봤다
⑥ 공유 엔티티로 이웃 찾기. "이 두 기억과 이름을 가장 많이 나눠 가진 다른 기억" 또는 "방금 그 기록들이랑 겹치는 게 많은 기억" START a = events(ids: $ids) MATCH (a)-[:PARTICIPATED_IN]-(e)-[:PARTICIPATED_IN]-(b) WHERE NOT b.id IN $ids RETURN b, count(DISTINCT e) AS shared ORDER BY shared DESC LIMIT 20 · params {"ids": ["evt_…a", "evt_…b"]} shared는 함께 나오는 이름의 수다. 같은 일을 SHARES로 쓰면 s.via에 다리가 된 이름이 실린다(4절 예제 4)
⑦ 비슷한 기억·다음 기억. "이 기억과 뜻이 비슷한 것" 또는 "이 기록 다음에 이어진 기록 세 단계까지" 비슷한 것: START a = events(ids: $ids) MATCH (a)-[f:SIMILAR {k: 5}]-(b) RETURN b, f.cos ORDER BY f.cos DESC LIMIT 10 · 다음 것: START a = events(ids: $ids) MATCH (a)-[n:NEXT*1..3]->(b) RETURN b, n.hops ORDER BY n.hops LIMIT 30 f.cos는 뜻의 가까움(클수록 가깝다), n.hops는 몇 걸음 뒤인지다
⑧ 이미 본 것 말고 새것만. "방금 본 결과 말고, 이 기억들과 이름을 둘 이상 나눠 가진 다른 기억" 또는 "아까 나온 거 빼고 연결된 거 더" START a = events(ids: $ids) MATCH (a)-[s:SHARES {min: 2}]-(b) WHERE NOT b.id IN $seen RETURN b, s.count, s.via ORDER BY s.weight DESC LIMIT 20 · params {"ids": [...], "seen": [...]} $seen에 이미 본 id를 넣어 뺀다. 거르기는 걷기가 끝난 뒤에 걸린다
⑨ 순위 근거. "왜 이 순서야? 점수도 같이 보여 줘" 또는 "얼마나 가까운지 숫자로" START a = events(tag: $tag, text: $q, k: 10) RETURN a.id, a.summary, a.score ORDER BY a.score DESC, a.id · params {"tag": "balance", "q": "적 체력을 바꾼 것"} a.score는 시작 문장과의 가까움이다. 1위와 2위의 점수 차이로 순위의 근거를 댄다

4. 예제로 보기

요청부터 AI의 답까지 처음부터 끝까지 따라가 본다. 데이터는 가상의 게임 프로젝트이고, 응답은 요지만 줄여 적었다.

예제 1. 범위 안 의미 검색: "N건 중 가까운 k건"

요청:   ak 그래프 세이브 포맷이 나오는 기억 중에서 로드 버그를 고친 기록

AI가 보여 주는 한 줄:
  실행한 질의: START a = events(entity: $name, text: $q, k: 20) RETURN a, a.score ORDER BY a.score DESC, a.id
  (뜻: "세이브 포맷"이 나오는 기억 중 "로드 버그를 고친 기록"과 뜻이 가까운 20건)

응답 요지:
  rows: 20개 (evt_…, a.score 0.62에서 0.41까지)
  start: scoped_mode "exact", candidates {count: 64, exact: true}, filled true, has_more true
  partial: false, truncated: []
  notes: "start: top-20 of 64 candidates; 44 more exist in scope (not a budget cut)"

AI의 말: "세이브 포맷이 나오는 기억 64건 가운데 뜻이 가까운 20건입니다. 나머지 44건은 가져오지 않았으니, 64건을 전부 비교한 결과가 아닙니다. 1위(0.62)는 로드 시 버전 필드를 읽는 순서를 고친 기록이고, 2위(0.55)와 차이가 커서 가장 직접적인 답입니다."

exact는 "후보 64건의 거리를 모두 쟀다"는 뜻이지 "64건을 모두 돌려줬다"는 뜻이 아니다. 그래서 AI는 "전부 확인했다"고 말하지 않는다.

예제 2. 전부·목록: 200건을 넘을 때

요청:   ak 그래프 balance 태그가 붙은 기억 전부 보여 줘

AI가 보여 주는 한 줄:
  실행한 질의: START a = events(tag: $tag, k: 200) RETURN a LIMIT 200
  (뜻: balance 태그가 붙은 기억을 최신순으로 200건까지)

응답 요지:
  rows: 200개
  start: hub true, has_more true
  partial: false, truncated: []
  notes: "start: top-200 of 201+ candidates; 1+ more exist in scope (not a budget cut)"

AI의 말: "balance 태그 기억은 200건이 넘습니다. 최신 200건만 보여 드리고, 그보다 오래된 기록은 이번에 보지 못했습니다. 특정 주제만 필요하면 '적 체력' 같은 문장을 붙여 범위 안 의미 검색으로 좁힐 수 있습니다."

숫자 뒤의 +는 하한이다. "적어도 201건"이지 정확한 수가 아니다.

예제 3. 0건: "상위 k 안에는 없음"과 "확인 못 함"

같은 모양의 질의가 두 번 0건을 돌려준 경우다. 뜻이 다르다.

(가) 절단 없는 0건
요청:   ak 그래프 인벤토리 기억 중에 아이템 복제 버그와 가까운 것 가운데 세이브 포맷도 나오는 것
  실행한 질의: START a = events(entity: $a, text: $q, k: 20) MATCH (a)-[:PARTICIPATED_IN]-(n {name: $b}) RETURN DISTINCT a
응답 요지:
  rows: 0개
  start: scoped_mode "exact", candidates {count: 90, exact: true}, filled true, has_more true
  partial: false, truncated: []
  notes: "start: top-20 of 90 candidates; 70 more exist in scope (not a budget cut)"

(나) 범위를 다 못 본 0건
요청:   ak 그래프 전투 기억 중에 세이브 파일이 깨진 사고와 가까운 것 가운데 세이브 포맷도 나오는 것
  실행한 질의: (가)와 같은 모양, $a = "전투"
응답 요지:
  rows: 0개
  start: scoped_mode "filtered_ann", candidates {count: 3001, exact: false}, filled false
  partial: true, truncated: [{stage: "start", reason: "filled_false"}]
  notes: "scoped START did not cover its whole scope (filled false, names truncated, or cut by
          a statement timeout or the deadline) - rows may be missing; 0 rows means
          'not confirmed here', not 'none exist'"

(가)에서 AI의 말: "인벤토리 기억 90건 중 가까운 20건 안에는 세이브 포맷이 함께 나오는 기억이 없습니다. 나머지 70건은 보지 않았으니 '없다'고는 말할 수 없습니다. 전부 확인하려면 두 이름이 모두 나오는 기억을 목록 모양으로 물어볼 수 있습니다."

(나)에서 AI의 말: "전투 기억은 3,000건이 넘어 빠른 근사 검색으로 찾았는데, 20건을 다 채우지 못했습니다. 그래서 0건은 '없음'이 아니라 '이번에 확인하지 못함'입니다. 태그를 같이 걸거나 더 좁은 이름으로 범위를 줄이면 다시 볼 수 있습니다."

"없다"고 말하려면 증거가 있어야 한다. 범위를 끝까지 본 0건이거나, 이름 조회처럼 전체를 보는 다른 길로 확인한 경우다.

예제 4. 관계 걷기: 집계 열 읽기

요청:   ak 그래프 예제 1의 1·2위 기록과 이름을 가장 많이 나눠 가진 다른 기억

AI가 보여 주는 한 줄:
  실행한 질의: START a = events(ids: $ids) MATCH (a)-[:PARTICIPATED_IN]-(e)-[:PARTICIPATED_IN]-(b)
               WHERE NOT b.id IN $ids RETURN b, count(DISTINCT e) AS shared ORDER BY shared DESC LIMIT 20
  (뜻: 두 기록에 나오는 이름을 거쳐 닿는 다른 기억을, 함께 나오는 이름이 많은 순으로 20건)

응답 요지:
  columns: [b, shared]
  rows: 20개. 1행 {b: {id: evt_…, summary: "세이브 슬롯 이전 스크립트 작성"}, shared: 3}
                2행 {b: {id: evt_…, summary: "로드 화면 진행 표시 변경"}, shared: 2} …
  partial: false, truncated: []

AI의 말: "두 기록과 이름을 셋 나눠 가진 기억은 '세이브 슬롯 이전 스크립트 작성'입니다. 어떤 이름이 다리였는지 보려면 SHARES로 다시 물어 s.via를 읽으면 됩니다."

shared는 순위가 아니라 함께 나오는 이름의 개수다. 걸어서 닿은 기억에는 a.score가 없다(비어 있다). a.score는 시작 문장과의 가까움이라 시작점에만 실린다.

5. 문법 한눈에

읽기 전용 Cypher 부분집합이다. 모양은 START … [MATCH … [WHERE …]] RETURN …이다.

시작 (START a = events(…)). 시작점은 늘 기억(이벤트)이다.

시작 k 기본·최대
text: $q 전체에서 문장과 뜻이 가까운 기억 5 · 20
entity: $name 그 이름이 나오는 기억, 최신순 5 · 200
tag: $t 그 태그가 붙은 기억, 최신순 5 · 200
ids: [$id1, $id2] 또는 ids: $ids 지정한 기억 (최대 20개, k는 무시) -
entity: $name, text: $q 그 이름이 나오는 기억 안에서 뜻이 가까운 순 5 · 20
tag: $t, text: $q 그 태그 안에서 뜻이 가까운 순 5 · 20

관계 (MATCH (a)-[:관계]-(b))

관계 잇는 것 뜻과 쓸 수 있는 값
PARTICIPATED_IN 엔티티와 기억 그 기억에 그 이름이 나온다. 기억→이름→기억으로 걸으면 "같은 이름이 나오는 기억"
SHARES {min, min_w} 기억과 기억 이름을 min개 이상 함께 가진다. s.count(개수), s.weight(드문 이름일수록 큰 가중치), s.via(다리가 된 이름)
SIMILAR {k, min} 기억과 기억 뜻이 가까운 이웃. f.cos
FAR {max} 기억과 기억 뜻이 먼 것만 남긴다(cos < max). SHARES와 함께 "이름은 겹치는데 뜻은 먼 것"
NEXT {source} 기억 → 기억 기록의 순서(스레드·대화). 방향이 있다. *1..3으로 여러 걸음, n.hops
MEMBER_OF {kind} 기억 → 태그 태그 소속. 방향이 있다
RESOLVED_BY {relation} 기억 → 기억 계획·기대가 실제로 이뤄진 기록으로 이어진다. 방향이 있다. r.asserted_at
CONNECTED 엔티티와 엔티티 함께 나온 적이 있는 이름끼리. c.event_count
SAME_AS 엔티티와 엔티티 같은 것으로 등록된 별칭. x.confidence

그 밖의 규칙

  • MATCH는 생략할 수 있다. 시작 자체가 답일 때 START … RETURN …으로 끝낸다(①②④⑨).
  • WHERE: =, <>, <, <=, >, >=, IN, CONTAINS, AND·OR·NOT, 그리고 두 기억 변수의 뜻 가까움 cos(a, b). WHEREMATCH 뒤에만 온다.
  • CONTAINS는 엔티티·태그의 이름에만 쓴다. 대소문자와 전각·반각을 가리지 않는다. 조각이 전부 영문·숫자·기호(ASCII)면 2자 이상, 한글·한자가 들어가면 1자도 된다.
  • RETURN [DISTINCT], 집계 count·collect·min·max·sum·avg, AS 별칭, ORDER BY, LIMIT. 집계와 함께 쓰는 ORDER BY는 돌려주는 열(별칭)로 정렬한다.
  • a.score: 시작 문장과의 가까움. 문장 없는 시작과 걸어서 닿은 기억에서는 비어 있고, 빈 값은 맨 뒤로 간다. 순서를 고정하려면 ORDER BY a.score DESC, a.id.
  • $이름은 매개변수다. 사용자의 문장은 질의 안에 직접 쓰지 않고 params로 넘긴다.
  • 방향이 있는 관계에서 화살표를 빠뜨려도, 양 끝으로 방향이 하나로 정해지면 채워 주고 notes에 적는다((a)-[:MEMBER_OF]-(t)->). 기억과 기억을 잇는 NEXTRESOLVED_BY는 화살표를 직접 써야 한다. 틀린 화살표는 뒤집어 주지 않고 거절한다.
  • 돌려받는 행은 작게 줄여 온다. 기억은 {id, summary, timestamp, order_index}, 엔티티는 {id, name, type}, 태그는 {id, name}. 원문은 원문 조회로 따로 연다.

안 되는 것

  • WITH는 없다. 조건은 관계의 값(SHARES {min: 3})으로 건다.
  • 쓰기(CREATE·SET 등)는 없다. 읽기 전용이다.
  • "…로 시작하는"(STARTS WITH)은 없다. CONTAINS로 받은 뒤 AI가 거르고, 그렇게 했다고 말한다.
  • 요약 글에 대한 글자 일치는 없다. 그런 질문은 SQL 조회의 몫이다.
  • 관계 없이 문장만 던지는 질문은 그래프 질의가 아니라 일반 검색이 낫다.

6. 응답 읽는 법

응답은 {columns, rows, row_count, start, partial, truncated, plan, budget, notes}이다. 완결성을 알리는 신호는 두 부류로 나뉜다.

예산 절단이 아닌 것 (partial·truncated에 들어가지 않는다)

  • start.has_more: true: 범위에 돌려준 것보다 후보가 더 있다. 범위 안 의미 검색과 tag: 단독에 실린다.
  • start.candidates.count: 범위의 후보 수. exact: false면 하한이다. "224건 중 가까운 20건"은 "224건 전부"가 아니다.
  • start.hub: true: entity:tag: 단독에서 돌려준 최신 k건보다 더 많았다. entity: 단독에서는 이것이 유일한 신호다.
  • 기본 LIMIT: LIMIT을 안 쓰면 행이 20개에서 멈춘다(최대 200).

예산 절단 (partial: true, truncated에 항목)

  • filled: false: 큰 범위를 근사 검색으로 찾았는데 k건을 못 채웠다.
  • 이름 상한: 같은 이름의 엔티티·태그가 20개를 넘어 일부만 썼다 (start.entities_truncated·tags_truncated).
  • 시간 초과·마감: 걷기가 너무 넓어 중간에 잘렸다. 기능 고장이 아니다. 같은 질의를 되풀이하지 말고 k·LIMIT을 줄이거나 SHARES {min}을 올려 좁힌다.

notes에 실리는 안내 (응답에 영어로 그대로 실린다)

  • 후보가 더 있을 때: start: top-20 of 64 candidates; 44 more exist in scope (not a budget cut)
  • 기본 LIMIT에 잘렸을 때: rows cut to the default LIMIT 20; add LIMIT to return more (max 200)
  • 범위를 다 못 봤을 때: scoped START did not cover its whole scope (filled false, names truncated, or cut by a statement timeout or the deadline) - rows may be missing; 0 rows means 'not confirmed here', not 'none exist'
  • 이름 부분 일치 뒤 걷기가 잘렸을 때: CONTAINS filtered after the walk and the walk was truncated - 0 rows means 'not confirmed here', not 'none exist'

범위를 다 못 본 경우에는 첫째 안내 대신 셋째가 붙는다. 예산 절단이 결과를 비운 0건에도 첫째 안내는 붙지 않는다.

0건은 "확인 못 함"이지 "없음"이 아니다. 절단이 있었으면 확인 못 한 것이다. 절단이 없어도 상위 k만 봤으면(has_more, hub) 나머지는 안 본 것이다. "없다"고 말하려면 범위를 끝까지 본 0건이거나, 이름 조회처럼 전체를 보는 다른 길의 증거가 있어야 한다.

거절{error, blocked_by: syntax|grammar|params, hint, allowed_*}로 온다. AI는 hint와 허용 목록을 읽고 한 번 고쳐 다시 부른다.

7. 상한과 한계

항목
k 기본값 5
k 최대 (text:, 범위 + 문장) 20. 넘기면 20으로 줄이고 notes에 적는다
k 최대 (entity:·tag: 단독) 200
ids: 개수 최대 20
한 이름이 가리키는 엔티티·태그 최대 20
LIMIT 기본 20, 최대 200
여러 걸음 (NEXT·SHARES*) 1~3
CONTAINS 조각 길이 ASCII만이면 2자 이상, 한글·한자면 1자 이상
요약 길이 행마다 줄여서 온다. 원문은 원문 조회로
  • 권한 밖 기억은 애초에 보이지 않는다. 행에도, 후보 수에도 잡히지 않는다. 내가 읽을 수 있는 범위가 곧 질의의 범위다.
  • 범위 안 의미 검색은 한 번에 가까운 20건까지다. 그보다 많이 보려면 목록 모양(단독 범위 + k: 200)으로 묻거나 문장·태그로 더 좁힌다.
  • 서버에 따라 이 기능이 없거나 일부가 꺼져 있을 수 있다. 도구 목록에 query_memory_graph가 없으면 그래프 질의를 쓸 수 없다. 새 문법(범위 + 문장, tag:, MATCH 생략, a.score, CONTAINS, 화살표 채우기)만 꺼진 서버는 … is not available on this server (scoped match is off)로 거절한다. 그때 AI는 text:entity: 단독으로 바꾸거나 일반 검색으로 찾고, 꺼져 있다는 사실을 알려 준다.

8. 관련 문서