판단 주체가 없는 사전 검색: 규칙 엔진에서 LLM 사전 검색으로, 노드 교체 없이 엔진 전환하기
매 턴 코드가 검색하는 사전 검색 모드가 특정 조항을 못 찾던 원인을 판단 주체의 부재로 정의하고, 규칙 엔진을 거쳐 검색어·폴더 선택만 LLM에 맡기는 구조로 다시 설계했습니다. 엔진은 노드 ID를 바꾸지 않고 관리 설정으로 전환하며, 배포 뒤 드러난 404·스트리밍·마스킹 결함까지 정리했습니다.
Key Results
- 30 s 이내
- 엔진 버전 전환·롤백 반영노드가 관리 설정을 실행 시점에 읽음(30초 캐시), 배포 파드에서 양방향 전환 확인, 저장된 에이전트 재저장·재배포 없음
- 약 10 s
- LLM 사전 검색 구간개발 환경 에이전트 실측, 이후 답변이 25회 점진적으로 스트리밍
XGEN의 에이전트 캔버스에서 문서 검색을 맡는 정보검색 노드는 두 가지 방식으로 문서를 에이전트에 전달합니다. 검색 도구 모드(light, hard)에서는 LLM이 답변 도중 필요하다고 판단할 때 검색 도구를 호출합니다. 사전 검색 모드(always_search)에서는 에이전트 준비 단계에서 코드가 매 턴 무조건 검색하고, 그 결과를 컨텍스트에 미리 주입합니다. 은행 구축 환경에서 "always search로 하면 문서를 못 불러와서 못 읽는다"는 제보가 들어왔고, 현장에서는 검색 도구 모드로 임시 우회하고 있었습니다.
이 글은 그 우회를 걷어내기까지의 두 차례 엔진 개편을 다룹니다. 1차 개편은 LLM이 하던 일을 코드 규칙으로 옮겼고, 2차 개편은 규칙만으로는 특정 조항을 찾지 못한다는 것을 확인한 뒤 검색어와 대상 폴더 선택만 LLM에게 맡기고 검색 실행은 코드가 보장하도록 다시 설계했습니다. 두 개편 모두 이미 캔버스에 배치된 노드를 바꾸지 않고 관리 설정 하나로 전환·롤백되도록 만들었습니다.
배포 뒤에는 모델 404, 지나치게 엄격한 그라운딩, 토큰이 한꺼번에 쏟아지는 스트리밍, 그리고 사전 검색 결과를 마스킹하다 에이전트 노드 전체가 중단되는 결함이 차례로 나왔습니다. 결함마다 왜 그 경로에서만 나타났는지까지 함께 정리합니다.
같은 검색 함수, 다른 결과: 두 모드의 구조
먼저 두 모드가 실제로 어디서 갈리는지 확인했습니다. 두 모드가 부르는 검색 함수는 같은 dense 검색 함수였습니다. 검색 품질의 차이가 검색 엔진에서 오는 것이 아니라면, 차이는 검색 함수를 부르기 전후에 있어야 합니다.
[사전 검색] 준비 단계 → 코드가 무조건 검색 → 결과를 컨텍스트에 주입 → LLM은 문서를 받은 채 답변
[검색 도구] 준비 단계 → 도구함에 검색 도구 장착 → 실행 중 LLM이 판단해 호출| 항목 | 사전 검색(always_search) | 검색 도구(light, hard) |
|---|---|---|
| 검색 시점 | 에이전트 준비 단계, 매 턴 무조건 | 실행 중, LLM이 필요하다고 판단할 때 |
| 검색 주체 | 코드가 직접(도구가 아님) | LLM이 도구 호출 |
| 검색어 | 발화 원문 그대로 1회 | LLM이 다듬어 여러 번 |
| 0건일 때 | 그대로 끝(재시도·로그 없음) | LLM이 다른 표현으로 재시도 |
| 검색 범위 | 전역 단발 | 폴더를 좁혀 검색하고 전역과 병합 |
사전 검색은 LLM 없이 코드가 실행되는 것이 정의이고, 그래서 매 턴 검색이 보장됩니다. 문제는 그 정의 때문에 검색어를 다듬고 결과를 보고 다시 시도하는 주체가 없다는 점이었습니다.
운영 조건도 설계에 영향을 줬습니다. 은행 환경의 컬렉션은 전부 Qwen3-Embedding-8B(4096차원)로 임베딩돼 있었고, 고객 배포 라인과 제품 기본 라인은 에이전트 실행기가 달랐습니다. 고객 배포 라인은 LangChain 기반 실행기, 제품 기본 라인은 별도 하네스 런타임을 씁니다. 이미 고객 캔버스에 정보검색 노드가 다수 배치돼 있어, 검색 로직을 바꾸더라도 노드를 지우고 다시 연결하게 만들 수는 없었습니다.
가설 다섯 개를 실측으로 지운 뒤 남은 것
처음에는 검색 파이프라인 어딘가의 설정 결함을 의심했습니다. 가설마다 코드와 실행 환경을 직접 확인했고, 다섯 개 모두 처음 생각과 다른 결과가 나왔습니다.
| 초기 가설 | 확인한 것 | 실측 결과 |
|---|---|---|
| instruct 프리픽스가 유사도 점수를 깎는다 | 은행 컬렉션의 임베딩 모델 | 반대였습니다. Qwen3-Embedding은 프리픽스를 요구하고, 점수가 14% 깎인다는 측정은 OpenAI 임베딩 기준이었습니다 |
| RRF 융합 후 임계값이 결과를 지운다 | 임베딩 클라이언트 설정, 검색 prefetch 구성 | 현재는 발생하지 않습니다. sparse 클라이언트가 비활성이라 prefetch가 dense 하나뿐이고 융합 분기를 타지 않습니다. 다만 로컬 벡터 DB에서 RRF와 임계값 0.35를 함께 쓰면 5건 중 3건만 통과해, 켜는 순간 드러날 잠복 결함이었습니다 |
| 사전 검색만 dense 단독이다 | 노드 버전별 하이브리드 적용 범위 | 구버전 노드 전체가 dense였습니다. 하이브리드는 신규 버전 노드에만 적용돼 있었고, 모드가 아니라 버전의 차이였습니다 |
| 검색 실패가 조용히 묻힌다 | 로거 네임스페이스와 캔버스 실행 로그 | 절반만 맞았습니다. 예외는 이미 실행 로그에 보였고, 완전히 침묵하는 것은 0건 결과뿐이었습니다 |
| 고객 배포 라인에 출처 게이트가 없다 | 원격 저장소 최신 상태 | 이미 반영돼 있었습니다. 로컬 원격 참조가 낡아 생긴 착시였습니다 |
가설을 지우고 나니 남는 차이는 앞의 표 그대로였습니다. 도구 모드에서는 LLM이 검색어를 직접 만들고, 항목이 여러 개면 나눠서 여러 번 호출합니다. 이전 실행 기록에서도 한 질문에 검색 도구 호출이 4번 나간 적이 있었습니다. 0건이면 다른 표현으로 재시도하고, 폴더를 좁혀 검색한 뒤 전역 결과와 합칩니다. 사전 검색은 "안녕하세요 ... 알려주세요 [File:...]" 같은 대화체 발화를 통째로 한 번 임베딩하고 끝났습니다.
여기에 기본 그라운딩 프롬프트가 겹쳤습니다. 영어 여섯 조항으로 된 기본 프롬프트는 문서에 없으면 정보가 부족하다고 답하라는 지시였고, 매 턴 문서를 주입하는 사전 검색에서는 인사나 일상 질문까지 막았습니다. 정리하면 사전 검색의 개선은 검색 엔진 교체가 아니라 LLM이 하던 판단을 누군가 대신하게 만드는 일이었습니다.
1차 개편: LLM이 하던 일을 코드 규칙으로
첫 번째 엔진(버전 260819)은 판단 주체를 코드로 대체했습니다. 레거시 도구 모드가 LLM에게 맡기던 일을 하나씩 규칙으로 옮겼습니다.
- 질의 정제: 인사말, 문말 상투어, 첨부 마커, 불릿과 번호를 제거합니다. 너무 많이 깎여 의미가 사라지면 원문으로 되돌리고, 상한은 300자입니다. light 도구가 LLM에게 검색어를 채우게 하던 역할입니다.
- 다중 질의: 줄바꿈, 불릿, 번호, 물음표를 기준으로 최대 3갈래로 나눕니다. 첫 갈래는 항상 정제된 전문으로 두어 기존 1회 검색이 그대로 포함되게 했고(회귀 방지), "감사합니다"처럼 상투어만 남은 조각은 갈래로 쓰지 않습니다.
- 병합과 총량 유지: 갈래마다 노드에 설정된 top_k 그대로 검색하고, ID 기준으로 중복을 제거해 합친 뒤 다시 top_k로 자릅니다. 에이전트가 받는 컨텍스트 총량은 기존과 같습니다.
- 0건 완화 재검색: 결과가 0건이면 임계값을 절반으로 낮춰 1회만 다시 검색합니다.
- 진단 로그: 질의, 갈래 수, 임계값, 완화 여부, 건수, 최고 점수를 한 줄로 남기고, 0건은 warning으로 올립니다. 이후 실사용 피드백을 받아 검색된 문서의 파일명, 점수, 페이지를 최대 5건까지 함께 남겼습니다.
- 그라운딩 완화: 저장된 프롬프트가 구 기본값과 공백 정규화 후 완전히 같을 때만 완화판으로 교체합니다. 사용자가 직접 쓴 프롬프트는 건드리지 않습니다.
구현 중에 스스로 만든 실수도 세 가지 고쳤습니다. 검색 폴백 상수를 임의로 5, 0.3, 10으로 넣었다가 구버전 노드의 기본값(4, 0.2, 5)과 다르다는 것을 확인하고 레거시에 맞췄습니다. 다중 질의에서 노드 top_k를 갈래 수로 나눠 배분했더니 노드 설정이 검색에 온전히 전달되지 않아, 갈래마다 top_k를 그대로 쓰도록 바꿨습니다. 임베딩 모델 이름으로 instruct 프리픽스 여부를 판정하던 하드코딩은 제거하고 노드의 기존 프리픽스 설정을 따르게 했습니다.
다중 노드 결함도 이 과정에서 나왔습니다. 사전 검색 노드를 두 개 연결하면 두 번째 노드의 그라운딩 프롬프트가 합쳐진 컨텍스트 중간에 끼었습니다. 출처 파서는 다음 문서 태그나 헤더, 끝을 만날 때까지를 한 문서 본문으로 읽기 때문에, 그 프롬프트가 앞 문서의 본문으로 파싱돼 출처 청크에 섞였습니다. 노드별 프롬프트 부착을 끄고 에이전트 쪽에서 프롬프트를 모아 합친 컨텍스트 맨 앞에 한 번만 넣도록 바꿨고, 같은 컬렉션을 중복 지정하면 로그로 드러나게 했습니다. 단위 테스트 16개에 다중 노드 회귀 테스트 2개를 더한 18개가 컨테이너에서 모두 통과했습니다.
노드를 바꾸지 않고 엔진을 바꾸는 방법
엔진을 새로 만들면 배포 방식이 문제가 됩니다. 기존 엔진(260401)을 쓰던 캔버스와 새 엔진을 시험하려는 캔버스가 함께 있고, 새 엔진에 문제가 있으면 바로 되돌릴 수 있어야 했습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 새 버전 노드를 추가 | 신구 로직이 코드에서 완전히 분리됨 | 이미 배치된 노드를 전부 지우고 다시 연결해야 함 | 기각 |
| 노드 파라미터로 저장 | 워크플로우마다 엔진 지정 가능 | 워크플로우 JSON에 들어가 저장된 에이전트는 캔버스를 열어 다시 저장해야 반영 | 기각 |
| 관리 설정을 실행 시점에 읽기 | 노드 ID 그대로, 재배포 없이 양방향 전환 | 인스턴스 전체 공통 | 채택 |
| 버전 레지스트리(전략 패턴) 선도입 | 버전이 늘어도 분기 사슬이 생기지 않음 | 버전 2개인 지금은 껍데기 클래스만 늘어남 | 전환 기준만 정의 |
노드 ID는 그대로 두고 로직만 갈랐습니다. 노드는 실행 시점에 관리 설정의 엔진 버전을 읽어 검색 파라미터에 실어 보내고, 컨텍스트 빌더가 그 값으로 분기합니다.
[관리 설정] retrieval.engine_version = 260819
↓ 노드가 실행 시점에 읽음 (30초 캐시)
[정보검색 노드] search_params 에 engine_version 을 실어 전달
↓
[분기] 260819 → 정제·다중 질의·완화 재검색 / 그 외 → 이전 1회 검색30초 캐시에는 이유가 있습니다. 설정 서비스가 배포되지 않은 환경에서는 노드가 실행될 때마다 설정 조회 오류를 남기므로, 캐시로 조회 빈도를 줄이면서도 관리자 변경은 30초 안에 반영되게 했습니다. 엔진 버전은 워크플로우 JSON에 저장하지 않으므로 저장된 에이전트도 다음 실행부터 최신 설정을 따릅니다. 캔버스는 로드할 때 노드 명세를 병합해 신규 파라미터를 자동으로 추가하므로, 같은 노드 ID 안에서는 파라미터를 늘려도 재배치가 필요 없다는 것도 확인했습니다. 버전 선택 UI는 별도 설정 페이지가 아니라 관리자 노드 관리의 정보검색 노드 상세 상단에 두었습니다. 관리자가 그 노드를 보는 자리에서 바로 버전을 고르게 하려는 판단이었습니다.
레지스트리는 최종 방향으로는 맞지만 지금 도입하면 다른 부채가 된다고 봤습니다. 대신 두 파일에 흩어져 있던 버전 판정 비교를 판정 함수 하나로 모아, 나중에 바꿀 곳을 한 지점으로 줄였습니다. 버전이 3개를 넘거나 다른 노드도 버전 관리를 시작하면 아래 모양으로 전환한다는 기준을 함께 남겼습니다.
ENGINE_REGISTRY = {
"260401": LegacyEngine, # 버전 = 클래스 하나
"260819": SmartEngine, # 버전 추가 = 딕셔너리 한 줄
}
engine = ENGINE_REGISTRY[version]() # if 없이 조회2차 개편: 검색어와 폴더는 LLM이, 실행은 코드가
1차 엔진을 쓰고도 특정 조항을 찾지 못하는 사례가 남았습니다. 같은 컬렉션과 같은 top_k에서 light 모드는 "제25조"가 들어간 내규 조항을 찾는데 사전 검색은 찾지 못했습니다. 다시 차이를 따라가 보니, 1차 엔진의 다중 질의는 문장을 나눌 뿐 키워드를 추출하거나 질의를 재작성하지 않았습니다. 대화체 발화 전체를 임베딩하니 "제25조"라는 신호가 묻혔습니다. 폴더 필터도 전달되지 않아 전역 검색만 했습니다. light 모드에서는 LLM이 검색어를 "제25조"로 좁히고 대상 폴더를 골라 계층을 따라 검색했습니다. 규칙으로 옮길 수 있는 판단에는 한계가 있었던 셈입니다.
첫 시도는 사전 검색 노드에 light 검색 도구를 연결하고 LangChain 미들웨어로 tool_choice를 강제하는 방식이었습니다. 두 가지 한계가 바로 드러났습니다. 도구는 결국 LLM이 호출해야 실행되므로 "반드시 검색한다"는 보장이 약했습니다. 그리고 그 미들웨어는 LangChain 에이전트 생성 경로에만 연결되는데, 제품 기본 라인의 활성 에이전트는 하네스 런타임이라 같은 코드로 두 라인을 처리할 수 없었습니다.
| 접근 | 검색 실행 보장 | 검색어 품질 | 두 런타임 적용 | 판단 |
|---|---|---|---|---|
| 1차 규칙 엔진 | 코드가 보장 | 문장 분할뿐, 조항 번호 신호가 묻힘 | 공유 진입점 | 특정 조항 누락 |
| 검색 도구 + tool_choice 강제 | LLM 호출에 의존 | LLM이 선택 | LangChain 경로에만 적용 | 폐기 |
| LLM 사전 검색 | 코드가 반드시 1회 | LLM이 선택 | 공유 진입점 | 채택 |
결론은 역할을 나누는 것이었습니다. 사전 검색의 진입점인 컨텍스트 빌더는 두 라인의 에이전트가 공유하므로, 여기서 연결된 에이전트 모델에게 검색어와 대상 폴더만 고르게 하고 검색은 코드가 반드시 한 번 실행합니다. 검색 시점의 확실성은 사전 검색에서, 검색어의 품질은 LLM에서 가져오는 조합입니다. 강제 미들웨어와 관련 플래그, 배선은 모두 제거했습니다.
[정보검색 노드] search_mode=always_search, engine=260828
└─ 컨텍스트 설정만 반환 (도구 없음)
[에이전트 준비 단계] 컨텍스트 빌더 (두 런타임 공유)
└─ engine=260828 → LLM 사전 검색
1) 컬렉션 폴더 트리 조회
2) 연결된 에이전트 모델로 {검색어, 대상 폴더} 생성
3) 로컬(대상 폴더와 하위 계층) + 전역 하이브리드, 로컬 우선 병합
4) 0건이면 임계값 완화 1회 재검색
└─ 결과를 추가 컨텍스트로 주입 (완화 그라운딩 프리픽스)
[에이전트 생성] 문서 주입 완료 + 도구 목록 준비 → 스트리밍 답변다른 도구와의 공존도 따졌습니다. 에이전트 준비 루프는 연결된 노드마다 도구를 만드는 노드(웹 검색, MCP, light, hard)면 도구 목록에 넣고, 사전 검색 노드면 사전 검색을 실행합니다. 변경은 사전 검색 가지에만 있어 도구 경로에는 영향이 없습니다. 정보검색 노드가 여러 개면 각각 사전 검색하고 문서 태그 번호를 이어 붙여 병합합니다. 새 엔진은 기본값을 260819로 둔 채 관리자가 명시적으로 켜야 동작하는 opt-in으로 배포했습니다.
배포 뒤 드러난 결함 네 가지
모델 404. 배포 직후 로그에 요청한 모델이 존재하지 않는다는 404가 찍혔습니다. 검색어 생성에 플랫폼 기본 추론 서버 설정을 썼는데, 그 엔드포인트에는 해당 모델이 없었습니다. 연결된 에이전트 모델은 정상이었는데 엉뚱한 LLM을 부른 것입니다. 검색어 생성은 그 턴에 연결된 에이전트 모델로만 하도록 두 런타임 모두에서 주입했고, 모델이 없으면 오류 없이 원문 검색으로 폴백합니다.
인사에도 "문서에 없음". 완화 그라운딩의 적용 조건이 1차 엔진 판정에만 걸려 있어, 새 엔진은 엄격한 기본 그라운딩으로 떨어졌습니다. 적용 조건을 새 엔진까지 넓혔습니다.
토큰이 한꺼번에 나오는 스트리밍. 기존 검색은 모두 별도 스레드와 이벤트 루프에서 비동기로 실행하는 헬퍼를 거치는데, 새로 넣은 검색어 생성만 동기 호출을 썼습니다. 실행 중인 이벤트 루프와 충돌해 생성 토큰이 버퍼링됐다가 끝에 몰려 나왔습니다. 검색과 같은 비동기 실행 경로로 바꿨습니다. 스트리밍 여부는 50~60 ms 간격으로 답변 길이를 기록해, 증가가 촘촘하면 스트리밍, 큰 단일 점프면 버퍼링으로 판정했습니다.
에이전트 노드 중단. 고객 폐쇄망에서 사전 검색을 실행하면 에이전트 노드 전체가 중단되는 일이 있었습니다.
TypeError: expected string or bytes-like object, got 'tuple'원인은 사전 검색 결과에 적용하는 개인정보 마스킹 함수였습니다. re.findall()은 정규식에 캡처 그룹이 2개 이상이면 전체 매치가 아니라 그룹 튜플을 반환합니다. 그 튜플이 치환 함수로 들어가 TypeError가 났고, 그 지점이 로그용 탐지 샘플을 만드는 코드라 정작 본문 마스킹은 실행되지도 않은 채 노드가 죽었습니다. 확률적으로 보였지만 결정적 결함이었습니다. 활성 규칙이 있고, 마스킹이 강제되고, 정규식에 그룹이 2개 이상인 조건은 항상 참이었고, 이번 텍스트에 매칭되는지만 매번 달랐습니다. 파일명과 본문에 날짜가 든 문서가 검색될 때 터졌습니다. 검색 도구 모드도 도구 결과 마스킹이 같은 함수를 예외 처리 없이 부르므로 안전했던 것이 아니라, LLM이 도구를 부르지 않았거나 매칭이 없었을 뿐이었습니다.
- matches = pattern.findall(masked_text)
+ matches = [_m.group(0) for _m in pattern.finditer(masked_text)]제품 기본 라인은 이미 같은 방식으로 고쳐져 있었고 고객 배포 라인에 이식되지 않은 상태였습니다. 두 서비스의 다섯 곳을 같은 한 줄로 바꿨습니다. 그룹이 없는 정규식에서는 두 방식의 결과가 완전히 같아 정상 규칙의 동작은 바뀌지 않습니다. 배포된 파드에서 실사용 텍스트와 겹치지 않는 접두어로 그룹 3개짜리 시험 규칙을 잠시 넣고 네 시나리오(그룹 매칭, 매칭 없음, 날짜 오탐, 한 문장 여러 건)를 실행한 뒤 규칙을 지웠습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 엔진 버전 전환·롤백 | 새 버전 노드로 교체(기존 노드 삭제·재연결) | 관리 설정 변경 후 30초 이내 반영 | 배포 파드에서 260819 → 260401 → 260819 순으로 노드가 읽는 값 직접 조회 |
| 사전 검색 후 답변 스트리밍 | 토큰이 버퍼링돼 끝에 한꺼번에 출력 | 사전 검색 약 10초 뒤 25회 점진 증가 | 개발 환경 에이전트, 50~60 ms 간격 답변 길이 기록 |
| 웹 검색 도구와 공존 | 확인 전 | 문서 인용 → 도구 호출 → 37회 점진 스트리밍 | 같은 에이전트에 웹 검색 노드 연결 후 실행 |
| 그룹 정규식 마스킹 | 에이전트 노드 TypeError 중단 | 4개 시나리오 실패 0, 시험 규칙 잔존 0 | 배포된 파드에서 마스킹 함수 직접 실행 |
| 검색 진단 | 0건이면 로그 없음 | 0건은 warning 한 줄, 성공 시 검색 문서 최대 5건 | 캔버스 실행 로그 |
웹 검색 공존 실측에서 웹 검색 MCP 서버는 응답하지 않았지만, 에이전트가 문서 검색 뒤 도구를 실제로 호출한 것까지는 확인했습니다. 응답 없음은 MCP 서버 쪽 환경 문제로 따로 분리했습니다.
가장 크게 배운 것은 문제를 "검색이 안 된다"가 아니라 "판단 주체가 없다"로 다시 정의한 순간 설계가 정리됐다는 점입니다. 1차 개편은 판단을 규칙으로 흉내 냈고, 2차 개편은 판단은 LLM에게 넘기되 실행 시점의 보장만 코드에 남겼습니다. 다섯 가설을 실측으로 지운 과정도 중요했습니다. 다른 임베딩 기준의 측정을 믿고 프리픽스부터 껐다면, 프리픽스를 요구하는 은행 환경의 모델에는 반대 방향의 변경이 됐을 것입니다.
남은 과제도 분명합니다. 엔진 버전은 인스턴스 전체 공통이라 워크플로우별 지정은 아직 없습니다. LLM 사전 검색은 첫 토큰 전에 검색 구간만큼 대기가 생깁니다. 고객 환경 배포 후 새 엔진을 켜고 "제25조" 조항의 재현율과 스트리밍을 최종 확인하는 일은 남은 작업으로 기록돼 있고, 1차 규칙 엔진은 새 엔진이 안정될 때까지 기본값이자 롤백 경로로 유지합니다. full-text 병행은 컬렉션에 텍스트 인덱스가 없어 전수 스캔(로컬 1,637청크 기준 31~965 ms, 은행 시험 컬렉션은 27,698청크)이 되므로 인덱스와 한국어 토크나이저 검토가 먼저이고, sparse 하이브리드는 켜는 순간 RRF 임계값 문제가 함께 드러나므로 한 세트로 다뤄야 합니다.