무엇을 먹일 것인가 — 찾은 것을 추리고 차려 내기
조건에 맞는 레시피 50개를 SQL로 거르는 건 통과/탈락일 뿐 순위가 아니다. 가까운 것을 빠르게 찾은 다음, 그중 무엇을 어떤 순서로 모델에게 먹이느냐가 답을 가른다. 랭킹·rerank·주입·환각의 기록.
지난 편까지 우리는 의미를 좌표로 바꾸고, 두 좌표의 거리를 재고, 수백만 개 중 가까운 것을 전부 뒤지지 않고 빠르게 찾는 데까지 왔다(가까운 것을 빠르게 찾다). 그런데 가까운 후보를 한 줌 손에 쥐고 나면, 곧장 더 까다로운 질문이 따라온다. 이 중 무엇을, 어떤 순서로, 얼마나 모델에게 먹일 것인가. 찾는 것과 먹이는 것은 다른 일이다.
밥비서를 만들며 우리는 이 구분을 머리가 아니라 손으로 먼저 익혔다. 가장 흔한 착각부터 짚자. 우리는 매일 Postgres에서 조건에 맞는 레시피를 골라냈다. 단백질이 닭이고, 난이도가 쉽고, 30분 안에 되는 것. 이걸 ‘검색’이라 불렀지만, 그건 검색이 아니라 거르기(필터링)였다.
SELECT * FROM recipe
WHERE primary_protein = 'CHICKEN'
AND difficulty = 'EASY'
AND total_time_min <= 30;
이 질의가 하는 일은 이진 판정이다. 각 레시피는 조건을 만족하거나(통과) 못하거나(탈락), 둘 중 하나다. 30분짜리 닭 요리 50개가 나오면, 그 50개는 SQL의 눈에 전부 동등하다. ORDER BY를 붙여도 만든 날짜 같은 외부 기준일 뿐, ‘질의와 얼마나 잘 맞는가’의 순위는 아니다. 이 글은, 그 50개에서 진짜 먹일 몇 개를 가려내고 식탁에 차리는 과정의 기록이다.
필터가 아니라 랭킹이다
랭킹(ranking)은 거르기와 다르다. 모든 후보에 ‘적합도 점수’를 매겨 줄을 세운다. 통과/탈락이 아니라 1등, 2등, 3등이다. 의미 검색이 바로 랭킹이었다 — 모든 재료에 유사도 점수를 매겨 가까운 순으로 세웠으니까. 필터는 “맞나 틀리나”, 랭킹은 “얼마나 맞나”. 프롬프트에 넣을 자리는 한정돼 있으니, 모델에게 먹이려면 가장 관련 높은 것부터 줄을 세워야 한다. 필터로는 그게 안 된다.
그럼 글자를 맞춰 보는 keyword 검색은 필터일까 랭킹일까? 의외로 랭킹이다. 그 대표가 BM25다. 질의 단어가 문서에 얼마나 자주 나오는지(많을수록 관련), 그 단어가 전체 코퍼스에서 얼마나 희귀한지(희귀할수록 변별력), 문서가 얼마나 긴지(길면 약간 할인)를 버무려 점수를 낸다. “고추장 제육”으로 찾으면 두 단어가 다 든 레시피가 위로 온다. 글자를 보지만 통과/탈락이 아니라 점수로 줄을 세운다.
여기서 의미 검색과의 결정적 차이가 되살아난다. BM25는 글자가 겹쳐야 점수가 난다 — 돈전지로 찾으면 앞다리살 레시피는 0점이다. 반대로 의미 검색은 글자가 한 자도 안 겹쳐도 잡지만, 정확히 그 단어를 집는 데는 약하다. ‘고추장’을 콕 집어야 할 때 의미 공간에서 이웃한 ‘된장’·‘쌈장’까지 함께 끌어올 수 있다. 한쪽은 정확한 단어에, 다른 쪽은 의미에 강하다. 그래서 둘을 합친다. 이게 hybrid search다. BM25로 한 줄, 의미 검색으로 한 줄, 두 랭킹을 만든 뒤 섞어 최종 순위를 낸다.
문제는 점수 체계가 다르다는 것이다. BM25의 12.7과 코사인의 0.83을 무슨 수로 더하나. 단위가 아예 다르다. 가장 흔한 답이 RRF(Reciprocal Rank Fusion, 역순위 융합)다. 점수 자체는 버리고 ‘순위’만 쓴다. 각 리스트에서 k등이면 1/(k+상수)를 주고, 두 리스트의 그 값을 더한다. 양쪽에서 고루 상위면 합이 커진다. 점수 단위가 달라도 순위는 공평하게 섞이는, 단순하지만 튼튼한 방법이다.
밥비서를 이 렌즈로 다시 보면 흥미롭다. 우리는 두 종류의 검색을 따로 갖고 있었다 — 레시피를 고르는 SQL 조건, 그리고 흩어진 재료 표기를 하나로 묶는 의미 매칭. 하나는 정확한 속성(단백질·시간)을, 하나는 의미(표기 변형)를 잡았다. 정식 hybrid 엔진으로 합치진 않았지만, “정확한 조건 매칭”과 “의미 매칭”이라는 hybrid의 두 조각을, 우리는 다른 층위에서 이미 쥐고 있던 셈이다.
거칠게 줍고, 정밀하게 고른다
랭킹으로 후보 50개를 추렸다고 하자. 그런데 왜 하필 50개일까. 진짜 먹일 건 서너 개인데. 여기에 검색 시스템의 핵심 설계가 숨어 있다 — 2단계로 나누기.
이유는 의미 검색의 성질에 있다. 의미 유사도는 빠르다. 질의도 문서도 각각 따로 벡터 하나로 미리 바꿔 두고 코사인만 재면 되니까, 수백만 개를 훑어도 견딘다. 그런데 그 ‘따로 인코딩’이 약점이기도 하다. 질의와 문서가 서로를 못 본 채 각자 벡터가 되니, 둘 사이의 미묘한 상호작용 — 어순, 부정어, 조건 충돌 — 을 놓친다. ‘닭가슴살 말고 소고기’라는 질의에서 ‘말고’라는 부정을, 질의는 통째로 한 벡터에 뭉개고 문서 ‘닭가슴살 스테이크’는 이미 따로 벡터가 돼 있어, 둘을 떼어 비교하는 한 그 부정의 무게가 흐려진다. 이렇게 질의와 문서를 따로따로 벡터로 만드는 방식을 bi-encoder라 한다.
그래서 2단계를 쓴다. 1차로 bi-encoder가 빠르게 그물을 넓게 던져 50개를 건진다(놓치지 않기 위주, 넓게). 2차로 더 비싸지만 정밀한 모델이 그 50개만 한 쌍씩 다시 채점해 줄을 새로 세운다. 이 2차 정밀 재정렬이 rerank다. 2차의 주인공은 cross-encoder — 이름 그대로 질의와 문서를 함께 한 모델에 넣는다. “질의: 고단백 저녁 / 문서: 닭가슴살 스테이크”를 한 입력으로 같이 읽으니 둘 사이의 상호작용까지 본다. 대신 느리다. 쌍마다 모델을 새로 돌려야 하니 100만 개엔 못 쓰고, 딱 50개에만 쓴다. 빠른 그물로 좁히고 정밀한 잣대로 고르는 분업이다.
| bi-encoder | cross-encoder | |
|---|---|---|
| 입력 | 질의·문서 따로 인코딩 | 질의·문서 함께 인코딩 |
| 속도 | 빠름(벡터 미리 저장) | 느림(쌍마다 계산) |
| 쓰는 곳 | 1차 광범위 검색 | 2차 소수 정밀 재정렬 |
그리고 밥비서 런타임에 이 그림이 거의 그대로 들어 있다. 다만 자리에 앉은 배우가 다르다. 우리 식단의 코어는 LLM이 아니라 결정론 알고리즘이다 — 휴리스틱 최적화기가 하드 제약과 소프트 선호를 가중합으로 풀어, 그럴듯한 후보 식단을 한 번에 여러 벌 만든다(1차, 넓게). 그다음, 런타임의 검토 단계에서 LLM이 그 후보들을 함께 읽고 그 주에 가장 자연스러운 한 벌을 고른다(2차, 정밀). 여기서 LLM은 식단을 짓지 않는다. 이미 만들어진 후보들 가운데 고르는 판단자다. 구조만 떼어 보면 이건 정확히 rerank다. cross-encoder 자리에 LLM을 앉힌 ‘LLM 리랭커’인 셈이다. 우리는 reranking이라는 말을 쓰지 않고도, 2단계 검색을 런타임에 구현해 두고 있었다.
프롬프트라는 식탁
가장 어울리는 후보 한 벌을 골랐다. 이제 RAG의 ‘G(생성)’ 직전, 가장 과소평가된 단계가 남는다. 그 골라낸 것을 프롬프트에 어떻게 넣는가. 이걸 context injection(컨텍스트 주입)이라 한다. 주입은 별도 후속 작업이 아니라 retrieve와 generate 사이를 잇는 다리이고, 이 ‘넣는 법’이 답의 품질을 크게 좌우한다. 핵심은 셋이다 — 어디에, 어떤 순서로, 얼마나.
어디에. 지시는 system에, 찾아온 근거는 그 아래 명확히 구분된 블록에, 사용자 질문은 끝에 둔다. 근거와 지시가 뒤섞이면 모델이 ‘무엇이 사실이고 무엇이 명령인지’ 헷갈린다. 그래서 근거에는 울타리를 친다 — <context> ... </context> 같은 펜스로 “이건 참고 자료다”라고 분명히 표시한다.
어떤 순서로. 여기 반직관적인 함정이 있다. lost in the middle(가운데서 길을 잃다) 현상이다. 긴 컨텍스트를 줄 때 모델은 맨 앞과 맨 뒤는 잘 쓰지만 한가운데 묻힌 정보는 흘리는 경향이 있다. 모델이 입력의 첫머리(지시)와 끝(질문)에 더 강한 주의를 두고 가운데를 약한 신호로 처리하는 탓으로 본다. 원인이 무엇이든 결과는 분명하다 — 가장 중요한 근거는 맨 앞이나 맨 뒤에 둔다. 10개를 순위대로 죽 늘어놓으면, 정작 가운데 놓인 결정적 한 줄이 증발할 수 있다.
얼마나. 많이 넣을수록 좋을 것 같지만 반대다. 관련 없는 근거를 욱여넣으면 신호가 노이즈에 묻히고, 토큰 비용이 오르고, 가운데가 길어져 lost in the middle이 심해진다. 그래서 rerank로 추린 상위 소수만 넣는 게 낫다 — 앞 절의 2단계가 여기서 보상받는다. 적게, 그러나 가장 관련 높은 것을.
마지막으로 환각을 줄이는 값싼 장치 하나. 출처를 함께 주입하고 인용을 요구하기. 각 근거에 [recipe_id: 1023] 같은 꼬리표를 달고 “답에 쓴 근거의 id를 밝혀라”라고 지시하면 모델이 두 겹으로 묶인다 — 생성이 프롬프트의 근거에 더 강하게 앵커되고, ‘존재하는 id만 인용하라’는 제약이 지어낸 주장을 자제하게 만든다. 완벽한 자물쇠는 아니지만(작정하면 id마저 지어낼 수 있다), 근거 충실성을 끌어올리는 가장 싼 방법이다. 밥비서 런타임의 해설 생성 단계가 하는 일이 정확히 이것이다. 알고리즘이 고른 식단을 구조화해 근거로 주입하고, “이 식단을 사용자에게 설명하라”는 지시를 얹는다. 해설이 식단과 어긋나지 않는 건, 근거를 식탁 위에 분명히 차려 놨기 때문이다.
근거를 코앞에 두고도 거짓말하는 이유
근거를 정성껏 차렸는데도 모델이 천연덕스럽게 거짓을 말할 때가 있다. 주어진 식단에 없는 메뉴를 해설에 끼워 넣고, 적혀 있지 않은 재료를 더한다. 이게 환각(hallucination)이고, 우리는 밥비서 시범 때 이것과 정면으로 싸웠다.
왜 근거를 코앞에 두고도 그럴까. 핵심은 LLM의 본성에 있다. LLM은 ‘주어진 근거를 조회하는 기계’가 아니라 ‘다음에 올 가장 그럴듯한 토큰을 잇는 기계’다. 답을 만들 때 모델은 두 지식 출처를 동시에 갖는다 — 프롬프트에 주입된 근거(외부 기억)와, 학습으로 가중치에 새겨진 사전 지식(내부 기억). 문제는 둘이 같은 자리에서 경쟁한다는 데 있다. 주입된 근거는 관련 단어의 확률을 높일 뿐 다른 선택지를 배제하지는 못한다. 그래서 근거가 끌어올린 확률과, ‘제육볶음엔 간장’처럼 사전 지식에 강하게 새겨진 신호가 같은 분포 위에서 다툰다. 근거가 비거나 모호할수록 후자가 이긴다. 문법적으로 매끄럽고 그럴듯하기에, 거짓말은 종종 진실보다 더 자연스러워 보인다.
그래서 새 개념이 필요해진다. 근거 충실성(faithfulness)이다. 답이 ‘맞는가(정답인가)‘와 답이 ‘주어진 근거에 충실한가’는 다른 질문이다. faithfulness는 후자만 본다 — 이 답의 모든 주장이 주입된 근거에서 실제로 뒷받침되는가. 근거에 없는 말을 더했다면, 그게 세상에선 사실이어도 faithfulness 위반이다. RAG에서 우리가 가장 통제하고 싶은 게 이것이다. 모델의 박학을 자랑하려는 게 아니라, 우리가 준 근거 안에서만 말하게 하려는 것. 우리가 해설을 식단이라는 근거에 단단히 묶어 두려 한 이유도 여기 있다.
faithfulness를 끌어올리는 손잡이들은 사실 이 글 곳곳에 이미 흩어져 있었다. 검색을 정확히 해서 근거 자체를 옳게 주고, rerank로 군더더기를 덜고, 근거를 분명한 울타리로 주입하고 출처 인용을 요구하고, “근거에 없으면 모른다고 답하라”는 탈출구를 명시한다. 그래도 모델은 미끄러진다. 그렇다면 마지막 방어선은 하나다 — 생성된 답을 누군가 검사하는 것. 우리는 시범 때 사람이 환각을 검수했고, 파이프라인에선 또 다른 LLM이 그 검사를 맡았다. 모델이 모델을 심사하는 그 레이어가, 다음 파트의 문을 연다.
찾는 것과 먹이는 것은 다른 일이다
이번 편에서 우리가 한 일을 한 줄로 줄이면 이렇다. 가까운 것을 빠르게 찾는 데서 멈추지 않고, 그 후보를 거칠게 줍고 정밀하게 골라(rerank), 어디에·어떤 순서로·얼마나 차릴지를 정하고(injection), 모델이 그 근거 밖으로 새지 않게 묶었다(faithfulness). 검색이 좋아도 주입이 엉성하면 답이 무너지고, 주입이 좋아도 후보가 거칠면 노이즈를 먹인다. 단계마다 각자의 트레이드오프가 있고, 그걸 잇는 게 엔진의 일이다.
밥비서의 ‘LLM 리랭커 → 근거 주입 → 해설 생성’은 우리가 이 흐름을 한 제품 안에서 처음부터 끝까지 돌려 본 사례다. 그 과정에서 LLM의 자리를 분명히 그었다 — 식단을 짓는 생성자가 아니라, 결정론이 만든 후보를 고르고 설명하는 판단자. 같은 retrieve→rerank→inject→generate 파이프라인은 식단이 아닌 다른 도메인에도 그대로 옮겨 심을 수 있다. 그런데 마지막에 남은 질문 하나 — 생성된 답을 검사하는 그 ‘누군가’를 모델로 세우면, 모델이 모델을 심사하게 된다. 그게 스스로 검사하고 행동하는 단계로 들어가는 다음 파트(Judge·Tool Use·Agent·MCP)의 출발점이다.