지난 18기 카톡봇 스터디를 진행하면서 Dify를 활용해 남경 생활을 위한 사투리 사전을 만들었었습니다.
남경 주재원을 위한 카톡봇 기능 추가 - 외노자 사투리 사전(with Dify)
당시의 글에서 해당 기능을 구현한 배경을 가져오면 아래와 같습니다
중국 주재원의 현실 - "우리만의 사투리" 문제 발견
남경에서 주재원 생활을 하다 보면 한 가지 깨닫게 되는 사실이 있다.
우리는 사실상 '외노자'라는 것. 현지 문화 속에서 살아가면서, 한국인들끼리만 통하는 독특한 "사투리"를 쓰며 소통한다.
주재원들만 아는 "사투리" 사례들
예를 들어, 우리끼리는 회사를 'ESNJ'라고 부른다. 하지만 현지 중국인들에게 "ESNJ 가는 길 아세요?"라고 물어보면 "???"라는 표정을 짓는다. 어느 공장인지, 위치가 어디인지 전혀 알아듣지 못한다. (당연하다 우리 회사에서만 쓰는 진짜 진짜 우리 회사만의 용어니.. 누가 알겠는가?)
또 다른 예시로는 내가 사는 아파트 '동방천군(东方天郡)'이 있다. 이건 중국어를 한국식으로 읽은 것으로, 실제 중국어 발음은 "똥팡티엔쥔" 같은 식이다. 우리끼리는 "동방천군 앞에서 만나자"라고 자연스럽게 말하지만, 택시 앱에 목적지를 입력할 때는 완전히 다른 중국어를 쳐야 한다.
'황소곱창(雅比斯烤肉)' 같은 한국 식당도 마찬가지다. 한글 간판에는 '황소곱창'이라고 되어 있지만, 실제 중국어 상호명은 전혀 다른 의미와 발음을 가지고 있다. 우리끼리는 "황소곱창에서 저녁 먹을까?"라고 말하지만, 실제 중국 배달 앱이나 택시 앱에서 찾으려면 중국어 상호를 알아야 한다.
특히 중국에서는 택시를 부를 때 100이면 100 앱을 통해서 목적지를 설정하는데, 여기서 진짜 문제가 발생한다. 앱에 목적지를 입력하기 위해서는 우리가 가고 싶은 곳을 정확한 중국어로 입력해야 하는데, "동방천군" "황소곱창" 같은 우리만의 이름으로는 절대 찾을 수 없기 때문이다.
원래는 이런 부분 때문에 Notion에 여러 정보를 정리해 두고 있었습니다.
그리고, 이 메모를 여러 사람이 챗봇을 통해 정보를 얻을 수 있게 dify를 활용하여 기능을 구현했었습니다.
위와 같이 Dify에 지식을 생성하고, RAG 구성한 다음 LLM 모델만 연결하면 동작해서 만드는 것은 금방 만들 수 있어서 좋았습니다.
다만, 좀 쓰다 보니 생각보다 검색이 잘 안되는 아쉬움이 있었습니다.
그러던중, 얼마전 부터는 갑자기 동작조차 제대로 되지 않는 문제가 있어서 그동안 잊고 지내고 있었습니다.
n8n으로 다시 만들어 보자!
최근 Make.com에서 n8n으로 여러 자동화를 이식하면서 느낀 게 있습니다.
"코드를 쓸 수 있다는 건 정말 강력하다."
n8n은 노코드 툴이지만, 필요하면 JavaScript 코드를 직접 쓸 수 있습니다.
그리고 이게 AI 시대에는 엄청난 장점이에요. Claude한테 물어보면 바로 코드를 짜주거든요.
막히면 Claude한테 물어보면 됨
세밀한 로직 컨트롤 가능
디버깅도 직접 할 수 있음
"왠만한 건 다 할 수 있을 것" 같은 확신
그렇게 "n8n으로 RAG 다시 만들어보자" 결심했습니다.
Claude에게 물어봤습니다. "Notion 메모를 n8n으로 RAG 구성을 어떻게?"
처음 해보는 작업이니까 일단 Claude한테 물어봤습니다.
이 구조가 마음에 들었습니다. 다만, 임베징과 AI Agent에서 검색 단계는 2단계로 나누어서 구현하기로 했습니다.
1단계: Notion 데이터를 Supabase에 밀어넣기
Supabase 세팅
먼저 Supabase 프로젝트를 만들고 Vector Store를 활성화했습니다.
이 Supabase 구성하는것은 지난 18기 카톡봇때 배웠었는데, 이번엔 한번 더 응용해 보는 것이라 우찌저찌 따라할 수 있었습니다. 필요한 SQL 문은 Claude에서 만들어 줬고요.
Table을 만드는 SQL문과
(몇번 티키타카 한 끝에 아래와 같이 최종 코드는 변경되었습니다.)
-- 기존 테이블 삭제 (데이터 백업 후!)
drop table if exists notion_embeddings;
-- Vector Store 호환 테이블 생성
create table notion_embeddings (
id uuid primary key default gen_random_uuid(),
content text not null, -- Vector Store가 사용하는 컬럼명
embedding vector(1536),
metadata jsonb, -- 모든 추가 정보를 JSON으로 저장
created_at timestamp default now()
);
-- 벡터 인덱스
create index on notion_embeddings
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);
-- metadata 인덱스
create index on notion_embeddings using gin (metadata);
-- 검색 함수 (Vector Store 호환)
create or replace function match_documents (
query_embedding vector(1536),
match_threshold float default 0.7,
match_count int default 5
)
returns table (
id uuid,
content text,
metadata jsonb,
similarity float
)
language sql stable
as $$
select
id,
content,
metadata,
1 - (embedding <=> query_embedding) as similarity
from notion_embeddings
where 1 - (embedding <=> query_embedding) > match_threshold
order by embedding <=> query_embedding
limit match_count;
$$;
-- 특정 필드 검색을 위한 헬퍼 함수
create or replace function match_documents_by_heading (
query_embedding vector(1536),
filter_heading1 text default null,
match_threshold float default 0.7,
match_count int default 5
)
returns table (
id uuid,
content text,
metadata jsonb,
similarity float
)
language sql stable
as $$
select
id,
content,
metadata,
1 - (embedding <=> query_embedding) as similarity
from notion_embeddings
where 1 - (embedding <=> query_embedding) > match_threshold
and (filter_heading1 is null or metadata->>'heading1' = filter_heading1)
order by embedding <=> query_embedding
limit match_count;
$$;SQL 함수식에 대한 쿼리문도 친절하게 잘 만들어 줬습니다.
마찬가지로 함수 부분도 여러번 수정 끝에 아래 코드로 최종 확정 되었습니다.
CREATE OR REPLACE FUNCTION match_documents (
query_embedding vector(1536),
match_count int DEFAULT 5,
filter jsonb DEFAULT '{}',
similarity_threshold float DEFAULT 0.0 -- 새로 추가: 기본값 0.0
)
RETURNS TABLE (
id uuid,
content text,
metadata jsonb,
similarity float
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
notion_embeddings.id,
notion_embeddings.content,
notion_embeddings.metadata,
1 - (notion_embeddings.embedding <=> query_embedding) AS similarity
FROM notion_embeddings
WHERE
notion_embeddings.metadata @> filter
AND (1 - (notion_embeddings.embedding <=> query_embedding)) >= similarity_threshold -- 임계값 적용
ORDER BY notion_embeddings.embedding <=> query_embedding
LIMIT match_count;
END;
$$;어떻게 보면 제일 고생하고 씨름을 많이 했던 어려운 과정이 이 함수 다듬는 과정이었던것 같기도 합니다.
n8n 워크플로우 구성
n8n 워크플로우는 다음과 같이 구성되었습니다.
먼저 Supabase Vector Store에 적재하는 로직입니다.
Flow 자체는 단순해 보이지만... 코드 내부까지 포함한다면... 그리 단순하다고만 하기는 어려울듯 합니다.
Notion 문서를 읽어온 다음
Notion의 문서 내용 중 Content만 추출하고
현재 문서 구조에서 머리글로 구분정리가 잘 되어 있으니, 머리글 단위로 같은 분류의 글들로 자르고 (Chunking)
이 Chunking한 데이터에서 Context만 json 형태로 분리했습니다.
(이 4번 작업이 제일 아쉬웠습니다. Supabase Vector Store 활용법을 잘 몰라서 metadata를 버리고 context만 취했습니다.)이렇게 정제된 데이터를 embedding 한다음 Supabase에 Vector Store로 적재했습니다.
카톡 봇이 동작하는 로직입니다.
많이 보셔서 익숙한 형태일겁니다. Webhook에서 입력 받고, AI Agent를 동해서 답변을 구하고 구한 답을 돌려주는...
다만 Tool에서 Supabase Vector Store를 사용했다. 라는 점이 조금 다릅니다.
중요하게 다뤄진 시스템 프롬프트에는 다음의 정보가 들어 있었습니다.
당신은 남경 생활정보에 대한 문서 검색 도우미입니다.
검색 전략:
1. 사용자 질문에서 핵심 키워드를 추출하세요
- 회사명: NJ, NA, NB
- 정보 유형: 주소, 연락처, 약칭
2. 검색 도구를 사용할 때, 가능한 한 많은 관련 문서를 확인하세요
3. 여러 문서를 비교하여 가장 정확한 답변을 제공하세요
중요한 규칙:
1. 제공된 문서의 정보에만 근거하여 답변하세요
2. 문서에 없는 정보는 "문서에서 해당 정보를 찾을 수 없습니다"라고 답변하세요
3. 추측하거나 외부 지식을 사용하지 마세요
4. 답변할 때는 어느 문서/섹션에서 정보를 찾았는지 언급하세요
5. 한국어로 답변하세요
사용자의 질문에 친절하고 정확하게 답변해주세요.그리고, 지금은 꽤나 나이쓰하게 동작합니다.
전체 구축에 약 6시간 정도 걸렸던것 같습니다.
꽤나 짧은 코드인데, 익숙치가 않다보니 엄청나게 시행착오를 하게 되었네요...
순탄하지만은 않았죠.
주요 시행착오들:
1) Supabase 테이블에 적재가 안됨
데이터 형태가 Supabase Vector Store가 원하는 형태로 잘 정리하지 못해서 고생을 했습니다.
사실 지금 구현한 형태도 내 가 원하는 정보가 다 제대로 잘 들어간 구조는 아닙니다.
Context만 남기고 나머지 정보는 다 지웠거든요. 메타데이터로 넣으면 유용할텐데..
결국 제대로 된 구조를 만드는 것은 나중 과제로 돌리기로 했습니다.
2) 검색이 거의 되지 않음
정말 단순한 검색인데도 답을 찾지 못했습니다. 첨부된 문서에 포함된 키워드로 물었는데도 답을 못찾더라고요.
AI Agent에서 Chat Model을 수정하고 Temp 설정을 바꾸고 프롬프트를 바꾸는 등의 여러 조치를 취했는데.... 문제는 거기 있는게 아니었습니다. Supabase에 넣었던 "match_documents"라는 이름의 쿼리가 문제였습니다.
Supabase에서 기본 검색을 할때 찾아주는 Reference 문서가 없으니 제대로 동작을 못하는 것이었습니다. 그래서 이 부분을 최대한 문서를 많이 찾게 Limit도 15로 올리고, 검색 조건을 찾는 로직도 유사 검색을 많이 찾게 조건을 풀었습니다.
그 결과 원하는 검색을 잘 하게 되었습니다. 아무리 LLM이 똑똑해도 Reference 문서를 아예 뽑아내질 못하면 답을 구할수 없으니 이는 어쩌면 당연한 것이었는지도 모르겠습니다.
조금 많이 찾게 하고, 그다음 LLM이 판단하게 하는게 더 좋은 방법이었던것 같아요.
이 모든 난관도 Claude와 함께라면:
막힐 때마다 Claude한테 물어봤습니다.
"이 에러는 무슨 뜻이야?"
"이 코드 수정해줘"
"더 나은 방법 없어?"
코드 짤 수 있는 환경 + AI = 정말 강력한 조합입니다.
그리고 느낌적인 느낌이지만, 검색도 전보다 훨씬 정확히 해주는 것 같았습니다.
왜 더 잘될까?
라는 부분을 생각해 봤는데, Dify의 경우 확실히 구현이 편하고 쉽게 만들수 있는 반면, 그 내부 검색 로직과 같은 것을 다루는 데 있어서는 자유도가 높지 않았습니다.
또한 내가 설정할 수 있는 파라미터도 한정적이었고요.
반면 '코드'를 할 수 있는 환경이 되니(n8n, Supabase 모두 코드가 되니) 보다 세밀한 로직 개선과 파라미터 설정도 할 수 있어서 보다 나은 최적의 조건을 찾을 수 있는것 같았습니다.
아직 남은 과제들
완벽하진 않습니다. 아직 개선할 점들이 있어요.
1) Metadata 구조 정리
지금은 context만 저장
더 구조화된 metadata를 저장하면 도움이 될텐데
2) 업데이트 자동화
Notion 내용이 바뀌면 자동으로 Supabase도 업데이트되게 만들고 싶은데... (어렵지 않게 만들 수 있을것 같은데)
이 문서는 생각보다 자주 업데이트가 되지 않아서 굳이... 라는 생각도
3) 검색 정보를 보다 풍부하게
결국 Reference 정보가 풍부해야 보다 큰 의미가 있을 텐데...
고3 아들을 둔 상황에서 외식을 거의 하지 못하다 보니 정보가 더 풍부해지지는 않는다는 단점이 있습니다.
마무리: n8n + AI 조합의 가능성
이번 경험을 통해 확실히 느낀 점:
AI 시대의 자동화:
코드 짤 수 있는 환경이 점점 중요해짐
왜? AI가 코드를 짜주니까
노코드 툴이라도 코드를 쓸수 있게 해주는 툴 이 진짜 강력하다!