Retriever-Free Retrieval이라는 표현이 더 적절한 것 같습니다.
최근 'Retriever-Free Retrieval: Navigating Documents with Decision Models'라는 논문이 arXiv에 제출되었습니다.
이 논문을 바탕으로 구현 검증 및 나이브 RAG(Naive RAG)와의 비교 평가까지 진행된 아래 글도 인기를 얻고 있습니다.
의사결정 모델(Decision Model, Typesafe.ai가 제공하는 Jev 측에서는 이 표현을 사용하지 않는다고 합니다만)과 같은 판별 모델을 사용한 검색 아이디어는 이전부터 있었던 것으로 생각하지만, 실제로 평가를 거친 논문 등이 나오고 있다는 것은 흥미로운 상황이라고 생각합니다.
저 역시 벡터 검색 후 리랭킹(re-ranking)에 의사결정 모델을 사용하는 검증을 해본 적이 있지만, 기왕 하는 김에 벡터 검색조차 동반하지 않는 RAG를 한번 시도해 보고 싶어서 Databricks의 기능만 사용하여 테스트해 보았습니다.
사용하는 기능으로는 Databricks의 Spark 선언형 파이프라인(declarative pipeline)을 이용해 데이터를 트리 구조로 변환하고, AI Functions인 ai_decide를 활용하여 의사결정 모델을 사용한 검색을 구현했습니다.
검색 대상은 늘 그렇듯이 제 Qiita 기사(약 300개)이며, 이를 '태그 → 기사 → 섹션 → 본문'의 4단계 트리 구조로 만들어 활용하고 있습니다. 평가를 거친 것은 아니며, 일단 구현해 보는 것을 목적으로 합니다.
ai_decide는 기사 작성 시점 기준으로 베타 기능입니다. 워크스페이스의 미리보기 설정에서 활성화해야 하며, 아직 Free Edition에서는 사용할 수 없었습니다. 따라서 이번 글은 유료 버전에서만 동작 가능합니다.
SQL에서 호출할 수 있는 AI Function 중 하나로, 주어진 입력에 대해 판별을 수행하고 구조화된 결과를 반환합니다.
이번 용도에서 중요한 특징은 다음과 같습니다:
- SQL에서 호출 가능: 다른 AI Functions와 마찬가지로 테이블의 각 행에 대해 판별을 가할 수 있습니다.
- 판별 유형: 판별 결과를 확률(
probability)로 받을 수 있습니다. 임계값(threshold)이나 top-k를 이용해 필터링하는 용도에 적합합니다.
- instructions와 criteria: 판별의 관점(무엇을 판별할지)과 true/false 각각의 기준을 자연어로 작성할 수 있습니다.
판별 결과는 VARIANT 형태로 반환되므로, :response.answers.<키>.probability::double 와 같이 추출합니다.
대략적으로 다음 3가지로 구성됩니다.
- Qiita 기사를 가져와 트리 구조로 변환하는 파이프라인
ai_decide를 사용한 검색용 UC 함수 (User Defined Function)
- 상기 검색 함수를 사용한 Genie 에이전트
파이프라인의 전체적인 흐름은 다음과 같습니다.
Qiita API ──(UC HTTP 연결 + 커스텀 데이터 소스)──> qiita_changes (bronze)
│ AUTO CDC (SCD1, 삭제 반영)
▼
...
트리의 각 단계와 판별 시 ai_decide에 전달하는 정보는 다음과 같습니다.
| 단계 | 테이블 | 노드 | 판별에 전달되는 state |
|---|
| L1 | tag_nodes | 태그 (소문자로 정규화) | 태그명・건수・최신순 기사 제목 10개 |
| L2 | articles | 기사 | 제목 |
| L3 | sections | 헤딩 단위의 섹션 | 제목+헤딩+본문 초반 200자 |
| L4 | sections | 동일 (본문) | 제목+헤딩+본문 (앞부분 6000자) |
공들인(꼭 그렇다고 할 수는 없지만) 부분은 트리 구축을 결정론적(deterministic)으로 했다는 점입니다. 즉, LLM을 전혀 사용하지 않았습니다.
LLM으로 요약하거나, 클러스터링으로 계층을 만들거나, 수작업 대응표를 준비한 것은 없습니다.
태그는 Qiita에 지정된 것을 그대로 사용했고, 섹션은 Markdown의 헤딩을 그대로 사용했습니다.
같은 기사 데이터에서는 항상 같은 트리가 생성되므로, 검색 결과가 이상할 때 '트리 구조가 문제인지, 판별(decision)이 문제인지'를 분리하여 확인하기 쉽습니다.
레포지토리 구성은 Declarative Automation Bundles (DABs)로 되어 있어, 파이프라인과 설정용 작업을 묶어 배포할 수 있도록 했습니다.
(Genie 에이전트는 DAB로 관리하지 않고 별도로 생성했습니다.)
qiita_decision_rag/
├── databricks.yml # 파이프라인 + 설정 작업
├── src/
...
워크스페이스의 미리보기 설정에서 ai_decide를 활성화해 둡시다.
데이터 소스가 될 자신의 Qiita 기사를 가져오기 위해, Qiita API 접근에는 Unity Catalog의 HTTP 연결을 사용합니다. 사전에 연결을 생성합니다 (토큰은 시크릿에서 가져옴).
참고로, SQL이 아닌 UI에서도 생성이 가능합니다.
CREATE CONNECTION qiita_api TYPE HTTP OPTIONS (
host 'https://qiita.com', port '443', base_path '/api/v2/',
bearer_token secret('qiita', 'token'));
Qiita 기사 가져오기는 PySpark의 Python Data Source로 구현했습니다.
UC Proxy (/api/2.0/unity-catalog/connections/{connection}/proxy/...)를 통해 authenticated_user/items를 호출하고, (updated_at, id)를 커서로 사용하여 새로 업데이트된 기사만 스트림으로 흘려보냅니다.
차이 계산(plan_next / replay_between)은 Spark에 의존하지 않는 순수 함수로 분리했기 때문에 로컬에서 단위 테스트가 가능합니다.
스트림 리더는 SimpleDataSourceStreamReader를 사용하며, 장애 복구 시에는 readBetweenOffsets로 동일한 범위를 결정적으로 재생합니다.
qiita_datasource.py 전체 (길어서 접기)
"""
Qiita 자신의 기사용 PySpark Python Data Source(UC Proxy 경유・커서 방식의 차이점 가져오기)
- 인증: Unity Catalog의 HTTP 연결 + UC Proxy. Qiita 토큰은 UC 쪽에 보관되며,
...
"""
파이프라인 측(Lakeflow Spark Declarative Pipelines)에서는 이 데이터 소스를 readStream으로 읽어 bronze 테이블로 만들고, AUTO CDC를 통해 기사 ID별 최신 버전(SCD Type 1)에 적재합니다.
태그는 이 시점에서 소문자 + 앞뒤 공백 제거로 정규화했습니다 (Databricks와 databricks가 별도의 노드가 되는 것을 방지하기 위해).
먼저 파이프라인 파일의 시작 부분입니다. 데이터 소스 등록과, 파이프라인 설정(configuration) 읽기를 수행합니다.
from pyspark import pipelines as dp
from pyspark.sql import functions as F
from pyspark.sql import types as T
...
bronze 테이블입니다. 인증 정보는 드라이버에서 WorkspaceClient로부터 가져와 리더에 옵션으로 전달하고 있습니다. payload (기사 JSON)는 parse_json을 사용하여 VARIANT로 만듭니다.
@dp.table(
name="qiita_changes",
comment="Qiita 기사의 변경 스트림(upsert 및 삭제 툼스톤)",
...
AUTO CDC에 전달하기 전에, VARIANT payload를 타입이 지정된 컬럼으로 전개하는 임시 뷰를 삽입합니다.
@dp.temporary_view(name="qiita_changes_flat")
def qiita_changes_flat():
return spark.readStream.table("qiita_changes").select(
...
이것을 소스로, AUTO CDC를 통해 articles에 반영합니다. is_deleted가 true인 행은 삭제로 처리됩니다.
dp.create_streaming_table(
name="articles",
comment="Qiita 기사의 최신 버전(L2 노드). AUTO CDC로 기사별 upsert / 삭제를 반영",
...
태그 노드에는 ai_decide에 전달할 설명문(description)을 부여합니다.
내용은 '태그명・기사 발생 횟수・최신 업데이트 순서의 기사 제목 10개'를 단순히 기계적으로 연결한 것일 뿐입니다. collect_list는 순서를 보장하지 않으므로, (updated_at, article_id)로 정렬한 후 추출했습니다.
@dp.materialized_view(name="tag_nodes")
def tag_nodes():
titles = spark.read.table("articles").select("article_id", "title", "updated_at")
...
이것은 기사와 태그를 연결하는 엣지 테이블입니다. 하나의 기사는 자신이 가진 태그의 수만큼 행을 가집니다. 정규화를 통해 같은 태그가 된 항목들은 array_distinct로 모았습니다.
@dp.materialized_view(
name="article_tags",
comment="기사×태그(L1과 L2를 연결하는 엣지). 하나의 기사는 자신이 가진 태그의 수만큼 행을 가집니다",
...
다시 한번 말씀드리지만, 태그 노드에는 ai_decide에 전달할 설명문(description)이 필요합니다.
내용은 '태그명・기사 발생 횟수・최신 업데이트 순서의 기사 제목 10개'를 단순히 기계적으로 연결한 것일 뿐입니다. collect_list는 순서를 보장하지 않으므로, (updated_at, article_id)로 정렬한 후 추출했습니다.
@dp.materialized_view(name="tag_nodes")
def tag_nodes():
titles = spark.read.table("articles").select("article_id", "title", "updated_at")
...
기사 본문은 제목 단위로 분할합니다. Spark에 의존하지 않는 순수 함수(pure function) 형태로 만들고, UDF를 통해 호출합니다. 규칙은 다음과 같습니다.
- 기사 내에서 사용된 최상위 제목 레벨로 분할합니다 (제목이
#으로 시작하는 기사와 ##로 시작하는 기사가 모두 있으므로, 기사별로 자동 판정).
- 첫 번째 제목보다 앞선 본문은 '도입' 섹션으로 처리합니다.
- 3000자를 초과한 섹션만 한 단계 낮은 제목으로 재분할합니다 (제목 형식은 '부 > 자' 형태).
- 코드 블록 내의
#은 제목으로 취급하지 않습니다.
markdown_sections.py 전체 (길어서 접기)
"""
Qiita 기사 본문(Markdown)을 제목 단위 섹션으로 분할하는 순수 함수입니다.
Spark에 의존하지 않으므로, 로컬에서 단독 테스트가 가능합니다.
...
"""
파이프라인 측면에서는 이 함수를 UDF로 감싸 sections를 만듭니다. section_id는 '기사ID-순번' 형태를 사용하여, 몇 번을 재구축해도 동일한 ID가 되도록 했습니다.
_SECTIONS_TYPE = T.ArrayType(
T.StructType([
...
파이프라인을 실행하면 다음과 같은 트리 구조가 완성되었습니다.
SELECT
(SELECT count(*) FROM articles) AS n_articles,
(SELECT count(*) FROM tag_nodes) AS n_tags,
...
| n_articles | n_tags | n_tags_single | n_edges | n_sections | avg_chars | max_chars |
|---|
| 342 | 166 | 97 | 1091 | 2508 | 1288 | 35519 |
태그 분포는 다음과 같습니다.
| tag | n_articles |
|---|
| databricks | 299 |
| ... | |
총 342개의 기사 중 299개에 databricks 태그가 붙어 있습니다.
L1의 '태그로 필터링' 효과라는 관점에서 볼 때, 이 태그는 거의 아무것도 필터링하지 못하고 있는 상황입니다.
반면, 97개의 태그는 단 하나의 기사에만 붙어 있다는 의미이므로, 태그의 세분화 정도가 상당히 편향된 트리라고 할 수 있습니다.
탐색 로직은 SQL 테이블 함수(UC 함수)로 구현했습니다. 흐름은 다음과 같습니다.
- L1: 모든 태그를
ai_decide로 판별합니다 (여기서는 필터링하지 않음). - 기사별로 '확률이 높은 상위 2개 태그의 평균'을 tag_score로 계산합니다.
로 하고, 상위 20건을 후보로 삼는다 -
L2: 후보 기사의 제목을 판별하고, 상위 5건 -
L3: 제목 + 소제목 + 본문 시작 부분을 판별하고, 상위 5개 섹션 -
L4: 본문에서 '답변 가능한지'를 판별하여, 임계값(0.5) 이상인 것을 반환
L1에서 모든 태그를 판별한 후 나중에 합성하는 이유는, 하나의 기사에 여러 개의 태그가 붙어 있기 때문입니다.
'상위 2개 태그의 평균'으로 설정한 것은, databricks처럼 무엇에나 붙는 단 하나의 태그로 후보가 결정되는 것을 피하고 싶었기 때문입니다. 개인적으로 이 부분이 가장 자신이 없는 부분이며, 완전히 휴리스틱(heuristic)이므로 더 좋은 합성 방법이 있을 것이라고 생각합니다.
각 계층의 판별은 이런 형태로 작성했습니다 (L2 예시).
coalesce(ai_decide(
to_json(named_struct('question', q, 'title', a.title)),
'{"rel": {"type": "noul",
...
참고로, ai_decide가 실패한 행은 확률 0으로 처리하고 있습니다.
또한, 함수 전체는 CTE를 단일 경로로 참조하는 형태로 만들었습니다. 동일한 CTE를 여러 곳에서 참조할 경우 ai_decide가 재계산되어, L2에서 선택된 기사와 L3에서 판별되는 기사가 서로 다르게 나오는 현상이 발생할 수 있기 때문입니다 (판별은 같은 입력이라도 호출마다 달라질 수 있습니다).
UC 함수 전체 (길어서 접기)
CREATE OR REPLACE FUNCTION __CATALOG__.__SCHEMA__.decision_rag_search(
q STRING COMMENT '질문 문장',
n_candidates INT DEFAULT 20 COMMENT 'tag_score로 남길 L2 후보 기사 수',
...
배포는 DABs에서 진행합니다. 설정 작업(setup job)이 파이프라인(획득 → 트리 구축)을 실행한 후, 자세한 내용은 생략하지만 노트북으로 UC 함수를 작성했습니다.
탐색 함수를 실행해 보겠습니다. 기회가 되어서 ai_decide 자체에 대해 질문을 해보았습니다.
SELECT result_rank, title, heading, top_tags,
round(tag_score, 3) AS tag_score, round(p_title, 3) AS p_title,
round(p_head, 3) AS p_head, round(p_body, 3) AS p_body
...
| rank | title | heading | top_tags | tag_score | p_title | p_head | p_body |
|---|
| 1 | langchain과 Databricks에서(제가)배우는 RAG : Self-Reflective RAG 그2 CRAG실습편 | Step6. 노드와 에지 정의 > decide_to_generate | langchain, langgraph | 0.665 | 0.56 | 0.55 | 0.81 |
| ... | | | | | | | |
……LangGraph의 decide_to_generate가 1위로 나왔습니다. | | | | | | | |
뭐, ai_decide에 대한 기사는 아직 작성하지 않았기 때문에 (이 글 자체가 그것입니다), 정답이 트리 안에 존재하지 않는 것은 당연합니다.
(그러고 보니 명시적으로는 아직 안 썼습니다... 벡터 검색의 리랭크(re-rank)에서 상당히 시도하고 있지만요)
그럼에도 불구하고, '관련 없는 기사밖에 없을 때', 이름이 비슷한 decide_to_generate 섹션을 p_body=0.81로 '답변 가능'하다고 판정해 버리는 것은 꽤 흥미로운 결과라고 생각합니다. 솔직히 좀 웃겼습니다.
결과를 보면서 알게 된 점을 메모해둡니다.
L1이 거의 효과가 없다: 상위 결과가 모두 langchain, langgraph
tag_score가 동일(0.665)합니다. 태그의 세분화 정도가 거칠어서 L1은 '대략적인 방향을 결정하는' 것 이상의 것은 할 수 없는 것 같습니다 -
L4 판단이 너무 관대함: '주제는 가깝지만 근거가 되지 않음'을 false 기준에 작성했지만, 글자면이 가까우면 true 쪽으로 기울어지는 경향이 있습니다. criteria 작성 방식에서 개선할 여지가 있어 보입니다 -
'해당 없음' 반환 불가: 임계값을 0.2까지 낮춘 것도 이유 중 하나이지만, 나무를 따라가는 방식이다 보니 각 계층에서 반드시 누군가가 상위에 남게 되어, '답이 없다'는 것을 감지하려면 L4의 임계값에 의존할 수밖에 없는 구조입니다.
호출 횟수의 예상치는 질문당 '태그 수(166) + 후보 기사 수 + 선택된 기사의 섹션 수 + k_sections'이며, 이번 나무 구조는 기본 설정으로 약 200~250회 정도입니다. 나무 구조를 더 최적화한다면 호출 횟수를 줄이고 레이턴시도 개선할 수 있을 것입니다.
현재는 XXS의 SQL Warehouse를 이용해 검색당 약 10초가 걸립니다. 실용성에는 다소 부족합니다.
어쨌든 작동은 했기 때문에 탐색 함수(exploration function)를 이용하는 Genie Agent를 만들어서 사용해 보겠습니다.
Genie Agent는 SQL 함수를 소스로 활용할 수 있기 때문에, 이 탐색 함수만 설정한 Genie Agent를 준비하여 에이전트 모드로 질문해 보겠습니다.
SDP라는 약어가 불분명했던 것 같아, 해당 용어가 사용된 기사들로부터 'Spark Declarative Pipelines' 키워드로 재검색하기 시작했습니다.
결국 제 글에서 답변을 구성할 수 있었습니다.
현시점의 설계로는 레이턴시는 꽤 크지만, 벡터 검색 없이도 어느 정도의 정확도로 검색이 되고 있는 것 같습니다.
이번에는 ai_decide를 사용했습니다.
함수를 각 레코드에 대해 배치적으로 Yes/No로 판단하는 설계를 했기 때문에 함수 호출 수가 많고, 또한 아마도 ai_decide 함수는 현재 병렬 실행되지 않는 것 같아(추정), 호출 횟수가 많을수록 시간이 걸리는 구조입니다.
특히 첫 번째 태그 탐색을 Choice 방식 판별로 하면 호출을 1회로 줄일 수 있어 레이턴시를 줄일 수 있을 것입니다.
그 외에, 함수 호출 횟수를 어떻게 줄이느냐가 레이턴시(와 비용)에 기여할 것이므로, 나무 구조의 재설계를 포함하여 검토하는 것이 개선 포인트라고 생각합니다.
현시점에서 정확도 평가를 하지 않았기 때문에 뭐라고 말씀드리긴 어렵지만, 감각적으로는 나무 구조의 설계가 중요하다고 생각합니다.
이번에는 Qiita 기사라는 특성상 태그나 제목, 섹션으로 구조화했지만, LLM을 사용한 요약문도 구조에 활용할 수 있을 것이라고 생각합니다.
다만, 이 나무 구조를 만드는 것은 문서의 특성에 따라 시행착오가 필요한 영역이라고 보고 있어 매번 어렵게 느껴집니다. 최대한 결정론적으로 하고 싶습니다.
벡터 검색은 이 부분을 어느 정도 완화해주기 때문에 여전히 메리트가 크다고 생각합니다.
제 자신의 Qiita 기사를 태그·기사·섹션·본문의 4개 계층의 나무로 만들고, ai_decide를 사용해 탐색하는 DecisionRAG(모방)을 만들어 보았습니다. 원 논문은 대략 읽어보았지만, 이번에는 '느낌적인 구현'으로 진행했습니다. 바이브(Vibe) 구현이니 양해 부탁드립니다.
개인적으로는 임베딩이나 인덱스를 만들지 않고, SDP와 SQL 함수만으로 '나무를 따라가는 검색'을 구성할 수 있다는 것이 흥미롭습니다. Databricks에서는 탐색을 UC 함수로 래핑하여 그것 하나로 할 수 있는 것도 매력적입니다.
그렇다고 해도 태그 계층이 거의 필터링에 효과가 없다(편향됨)거나, 호출 횟수가 많다는 등의 과제가 있어 실용화 관점에서는 아직 갈 길이 멀다고 솔직히 말씀드릴 수 있습니다.
이번 나무 구조도 상당히 대충 급하게 디자인한 것이라 좀 더 제대로 개선하고 평가도 해보고 싶습니다.
ai_decide나 Jev 같은 메커니즘을 가지고 더 실용적인 활용을 모색해 보고 싶네요.