이 상황을 상상해 보세요.
당신이 AI 비서에게 어떤 페이지가 가장 많은 검색 트래픽을 가져오는지 묻습니다. 비서는 자신 있게 답합니다. 그 수치는 3개월 전의 것이며, 당신이 지난 8월에 관리를 중단한 사이트의 데이터입니다.
아무것도 고장 나지 않았습니다. 단지 비서가 더 이상 사실이 아닌 데이터를 기억하고 있었을 뿐입니다.
저는 최신 상태를 유지하며, 무엇을 잊어야 하는지를 아는 구글 검색 트래픽의 '기억'을 원했습니다. 그래서 Mergetober를 위해 Cognee용 Google Search Console 커넥터를 만들었습니다. 코드는 PR #584에 있으며, issue #4793에서 확인할 수 있습니다.
작동 방식
Cognee는 문서를 에이전트가 검색할 수 있는 지식 그래프(knowledge graph)로 변환합니다. 이 커넥터가 Search Console로부터 데이터를 공급합니다. Google로 로그인하고, 원하는 속성(properties)을 선택하면, 해당 속성이 검색에서 어떻게 수행되는지 동기화됩니다.
Search Console은 산문이 아닌 수치를 제공합니다. 날짜, 쿼리, 페이지별로 한 행씩 기록되면 수백만 개의 문서가 되며, LLM(대규모 언어 모델)이 이 모든 것을 읽어야 합니다. 그래서 이 커넥터는 데이터를 달력 월별로 그룹화합니다. 속성(property)의 각 월은 다음과 같이 구성됩니다:
- 개요: 총합계, 기기 유형, 상위 국가, 상위 쿼리 및 상위 페이지;
- 상위 페이지 각각에 대한 문서 하나와 그 페이지로 이어진 쿼리들.
속성(property)의 모든 문서는 하나의 노드 세트(node set)를 공유합니다. 두 달 동안 동일한 쿼리가 발생하면, 그래프 내에서 하나의 개체(entity)가 되므로, 어떻게 변화했는지 질문할 수 있습니다.
source = google_search_console_source(
sites=["sc-domain:example.com"], # 모든 인증된 속성에 대해서는 제외
months=3,
...
로그인은 읽기 전용 범위(read-only scope)를 사용하는 OAuth 2.0 방식입니다. 브라우저에서 login.py를 한 번 실행하면, 토큰이 개인 파일에 캐시됩니다.
지연되고 샘플링된 데이터
Search Console을 일반적인 소스와 다르게 만드는 두 가지 요소가 있습니다.
먼저, 가장 최신 데이터는 늦고 예비적입니다. Google은 최종 수치를 약 이틀 지연하여 게시하며, 심지어 지난 며칠간의 수치도 수정할 수 있습니다. 따라서 커넥터는 최종 데이터만 읽습니다. '오픈(open)' 상태인 월은 몇 일 정도 차이가 날 때까지 열려 있으며, 오픈된 월은 실행될 때마다 다시 읽힙니다. 그런 다음 마지막으로 한 번 더 읽혀지고 닫히며, 한번 닫힌 월은 다시는 읽히지 않습니다. 텍스트가 변경된 문서만 전송되므로, 변화가 없는 실행(run)은 LLM 호출을 수행하지 않습니다.
둘째, 데이터는 샘플링됩니다. Google은 클릭 수에 따른 상위 행만 반환하고 개인 정보 보호를 위해 희귀 쿼리는 제외합니다. 이는 응답에서 사라진 쿼리가 삭제된 쿼리를 의미하는 것이 아니며, 해당 행들이 전체 합계(totals)와 일치하지 않는다는 것을 의미합니다. 모든 문서가 그렇게 명시하고 있습니다.
무엇이 잊히는가
제거하거나 선택 해제하거나 인증을 잃은 속성(property)은 그 문서를 포함하여 통째로 잊힙니다. 잘못된 삭제가 놓치는 것보다 더 나쁠 수 있기 때문에 몇 가지 안전 규칙을 추가했습니다:
- 실패한 요청은 아무것도 삭제하지 않습니다. 실행이 중단되고 다음 실행은 같은 지점부터 시작합니다.
- 갑자기 비어 있는 월이라도 그 문서는 유지됩니다. 모든 클릭이 사라지는 것보다 Google 측에 문제가 생길 가능성이 더 높습니다.
- Google이 동기화(sync) 속도를 제한하면, 깨끗하게 중단되고 이미 읽었던 월은 보존합니다. 동기화는 Cognee의 잠금(lock)을 유지하기 때문에 최대 1분 동안 대기하며, 다음 실행은 중단된 지점부터 다시 시작합니다.
- 계정에서 갑자기 인증된 속성이 없게 되어도, 모든 것을 잊어버리는 대신 실행이 중단됩니다.
실제 테스트
먼저 가짜 데이터, 즉 Search Console API의 메모리 내 복사본을 대상으로 테스트했습니다. 이 가짜 데이터는 실제 사실 행(fact rows)들을 집계하고, 클릭 수로 정렬하며, 최신 날짜를 숨기고, 희귀 쿼리를 보류합니다. Python 3.10 및 3.12에서, 그리고 Cognee 1.6.4 및 1.6.6에서 110개의 테스트가 통과했습니다. 여기에는 모의 LLM을 사용한 전체 Cognee 파이프라인 실행도 포함됩니다. 이 파이프라인 실행을 건너뛰는 106개 테스트 역시 Python 3.14에서 통과합니다. 하지만 가짜 데이터는 당신이 가짜로 만들고자 했던 것만을 증명할 뿐입니다.
저는 실제 Google 계정으로 로그인했습니다. OAuth 클라이언트를 생성하고, 브라우저 동의 절차를 거쳤으며, 제 속성(properties) 목록을 확인했습니다. Google은 siteOwner 권한이 있는 저의 하나의 속성을 반환했습니다. 이후 이 커넥터를 두 번 실행했습니다.
솔직히 말씀드리자면, 제가 가진 유일한 속성은 개인 포트폴리오이며, 지난 80일 동안 검색 트래픽이 전혀 없었습니다. 따라서 실제 동기화(sync)는 데이터를 찾지 못했습니다. 문서를 생성하지도 않았고 오류를 발생시키지도 않았습니다. 두 번째 실행에서는 폐쇄된 두 달을 건너뛰었습니다:
run 1 {'sites': 1, 'periods': 3, 'closed': 0, 'documents': 0, ...}
run 2 {'sites': 1, 'periods': 1, 'closed': 2, 'documents': 0, ...}
실제 Google만이 발견할 수 있는 버그
이 빈 속성은 제가 가짜(fake) 환경에서 잘못 파악했던 점을 보여주었습니다.
데이터가 없을 때 저는 Google이 빈 응답을 할 것이라고 예상했습니다. 하지만 그렇지 않았습니다. 차원(dimensions)이 없는 쿼리(query)의 경우, 이는 하나의 제로 값 행({'clicks': 0, 'impressions': 0, ...})으로 반환됩니다.
제 코드는 이 "하나의 행"을 "데이터가 존재한다"고 해석했습니다. 만약 그렇다면, 모든 빈 달에 대해 "클릭 수 0"이라는 개요(overview)를 작성했을 것입니다. 더 심각한 것은, 제가 설정한 안전 규칙을 무시하고 지나갈 것이라는 점이었습니다. 실제 문서가 있었던 달이 제로 값 행으로 돌아오면, 보존되어야 할 데이터 대신 덮어쓰여져 0으로 처리되었을 것입니다.
이제 임프레션(impressions)이 0인 행은 '데이터 없음'으로 간주됩니다. 저는 가짜 시스템이 Google처럼 작동하도록 수정하여, 이 값이 반환될 경우 테스트가 실패하도록 만들었습니다.
동일한 실행 과정에서 더 작은 질문도 해결했습니다. Google은 제가 전송하는 소문자 type 및 dataState 값을 허용합니다.
제가 테스트하지 않은 부분
저는 실제 트래픽에 대해 이 모든 것을 테스트하지 않았습니다. 즉, 실제 쿼리와 페이지에서 생성된 문서, 실제 데이터에 대한 리콜(recall), 변경된 달, 또는 실제 속성을 잊어버리는 경우 등입니다. 이러한 경로는 라이브 계정으로는 커버할 수 없으며, 가짜 API 테스트와 모의 LLM 파이프라인 테스트로 다루어졌습니다. 또한 Google로부터 실제 429 에러를 본 적도 없습니다.
희귀한 쿼리(Rare queries)는 설계상 목록에서 제외됩니다. 낮은 클릭 수를 가진 페이지들의 긴 꼬리(long tail)는 합계에만 나타납니다. 사이트맵(Sitemaps)과 URL 검사는 수집되지 않습니다. 이 내용은 README에 명시되어 있습니다.
사용해 보기
예제는 packages/connector/google-search-console/examples/에 있습니다. Google Cloud 프로젝트가 필요하며, 해당 프로젝트에는 Search Console API가 활성화되어 있어야 하고, 데스크톱 앱 유형의 OAuth 클라이언트와 인증된 속성(verified property)이 필요합니다. README 파일에 자세한 설명이 나와 있습니다.
만약 실제 트래픽이 있는 속성에 연결해 본다면, 어떤 부분이 문제가 되는지 듣고 싶습니다.