사내 지식은 고정돼 있지 않다. 새 부서, 새 제품, 새 규정 — KB가 끝없이 추가된다. 컬렉션이 KB만큼 늘어나기 시작한 순간, 이 구조의 수명은 정해져 있었다.
4. 다시 설계 — 운영 경계로 나눈 통합 컬렉션
그래서 1:1을 버렸다. 업계 선진사례(Qdrant·Azure AI Search·Elastic)의 공통 원칙은 같았다:
컬렉션은 운영 경계(콘텐츠 종류·테넌트·라이프사이클·ACL)로 나눈다. KB로 나누지 않는다. 같은 청크의 다른 임베딩 표현은 named vector로, 테넌트/KB 격리는 payload 필터 + 테넌트 인덱스로.
통합 텍스트 컬렉션 (모든 KB 공유)
├─ named vector: dense(의미) [+ sparse(정확어) — 품질 반전은 아래 §5]
└─ payload: { tenant, kb_id, tier, acl, ... }
▲
질의 시: payload 필터로 "이 사용자가 접근 가능한 KB"만 조회
(시각 자산은 별도 통합 컬렉션 — 텍스트와 다른 point population이므로)
효과는 즉각적이었다. KB가 100개 추가돼도 컬렉션은 그대로다. 교차 KB 검색은 단일 질의 + 필터. 새 KB 온보딩은 "컬렉션 생성"이 아니라 "payload 태깅". 끝없이 자라는 지식 범위에 드디어 대응이 된다.
심화 — 1:1 구조 vs 통합 구조, 코드 레벨로 뜯어보기
이 절은 깊다. 왜 1:1을 버렸는지 메커니즘까지 궁금한 분을 위한 detail이다. 바쁘면 §5로 건너뛰어도 좋다.
한눈에:
차원
1:1 (KB = 컬렉션)
통합 (운영 경계)
매핑
KB N개 = 컬렉션 N개 (kb_<id>)
content type별 소수 (텍스트 / 시각자산)
분리 기준
KB
point population (콘텐츠 종류)
격리
물리 (컬렉션 경계)
논리 (payload 필터 + 인덱싱된 필드)
벡터
컬렉션마다 dense + sparse
한 포인트에 named vector 다중
교차 KB 검색
N개 fan-out + 클라이언트 병합
단일 질의 + 필터 (서버사이드)
융합(fusion)
클라이언트가 점수 섞기
서버사이드 RRF
재색인
컬렉션별
alias blue/green 무중단
비용
컬렉션·HNSW 그래프 N배
소수 고정
표만으론 왜 그런지 안 보인다. 핵심 메커니즘 5개를 뜯어보자.
① named vector — "컬렉션을 늘리지 말고, 포인트에 표현을 얹어라"
흔한 오해: "dense랑 sparse는 다른 검색이니 컬렉션을 따로 만들자." 틀렸다. Qdrant에선 한 포인트에 여러 벡터를 이름 붙여 얹는다.
같은 청크의 다른 표현이 같은 포인트에 사니, 질의 때 dense·sparse를 함께 쓸 수 있다. 컬렉션을 새로 만드는 건 "벡터 종류"가 달라서가 아니라, "포인트 집단(콘텐츠 종류)"이 다를 때만이다. 텍스트 청크와 이미지/표 자산은 다른 집단이라 컬렉션을 나눴고, dense·sparse·ColBERT는 같은 청크라 named vector로 얹었다.
② payload 멀티테넌시 — 공유 컬렉션에서 "내 KB만" 좁히기
"다 합치면 느리지 않나?" 통합 컬렉션은 tenant_id / kb_id를 인덱싱된 payload 필드로 두고, 질의 시 그 필드로 먼저 좁힌 다음 벡터를 본다. 물리 분리 없이도 "내가 접근 가능한 KB만" 검색 범위에서 걸러낸다. 1:1이 "물리 벽으로 격리"했다면, 통합은 "인덱스로 격리"한다.
여기에 Qdrant는 한 단계 더 — 테넌트 수가 많을 때를 위한 tenant 전용 최적화 인덱스(is_tenant: true)를 제공한다. 정확히 이 용도다. 그런데 정직하게: 우리 라이브 컬렉션 생성 코드는 아직 plain keyword index만 부른다. tenant 최적화 인덱스는 "쓸 수 있는데 아직 안 켠" 상태다 — 이 얘기는 §심화 애프터노트에서 다시 한다(이 글이 자기 반전을 한 번 더 겪는 자리다).
③ 서버사이드 hybrid — prefetch + RRF (클라이언트 fan-out과 다른 점)
1:1에서 교차 KB 검색은 — N개 컬렉션에 각각 질의하고, 클라이언트가 서로 다른 점수 체계를 제멋대로 섞어 병합했다. 통합에선 Qdrant Query API가 서버 안에서 처리한다:
같은 청크는 항상 같은 ID → upsert가 덮어쓴다. 재색인이 안전해지고, 임베딩 버전을 ID에 넣으니 버전 교체도 깔끔하다.
⑤ HNSW 비용 + blue/green alias
컬렉션마다 HNSW 그래프가 따로 선다. 컬렉션 N개 = 그래프 N개 = 메모리·관리 비용 N배. 통합은 2개로 끝. 그리고 ColBERT 같은 재정렬 전용 벡터는 hnsw_config.m = 0으로 둔다 — 그래프를 아예 안 만들고 선형 스캔한다. 재정렬은 이미 추려진 후보만 보니 인덱스가 낭비이기 때문이다. 재색인이 필요하면 새 물리 컬렉션을 만들어 backfill한 뒤 alias만 바꿔(blue/green) 무중단으로 전환하도록 설계했다 — 코드도 있고 테스트도 됐다. 다만 이건 feature flag로 켜고 끄는데, 지금 라이브에선 꺼져 있다(그래서 실제 alias는 아직 0개). "만들어 두고 아직 안 켠" 메커니즘이다. 이것도 §애프터노트에서 다시.
⑥ 결정을 문서가 아니라 코드로 못 박기 (architecture-as-code)
"1:1을 버리고 통합으로 간다"는 건 설계 문서 한 줄로 끝날 수도 있었다. 문제는 — 문서는 읽지 않으면 그만이고, 6개월 뒤 누군가 "이 KB는 특별하니까 전용 컬렉션 하나 파자"라고 하면 조용히 원점이다. 그래서 우리는 이 결정을 런타임에서 강제했다.
ADR012_PROFILE_COLLECTIONS = frozenset({ "<통합 텍스트 컬렉션>", "<통합 시각자산 컬렉션>" })
def _assert_legacy_collection_creation_allowed(self, *, kb_id, collection_name):
"""ADR-012 컷오버 후, 실수로 per-KB 컬렉션을 만드는 걸 차단."""
if self._provider.config.allow_nonstandard_collection_creation:
return # 명시적·시한부 레거시 마이그레이션 창구에서만
raise NonStandardQdrantCollectionError(
f"비-ADR-012 컬렉션 생성 거부: kb_id={kb_id!r}, collection={collection_name!r}. "
"통합 프로파일 컬렉션을 쓰거나, 환경변수로 명시적 마이그레이션 창을 열어라."
)
레거시 per-KB 컬렉션 생성 경로로 들어오는 모든 호출은 sanctioned된 통합 컬렉션 이름 집합과 대조되고, 새 비표준 컬렉션은 명시적으로 이름 붙은 환경변수 escape hatch를 켜지 않는 한 기본적으로 거부된다. 결정이 문서로만 남지 않고 구조적으로 회귀 불가능해지는 것 — 앞 표의 "매핑: content type별 소수"가 지켜지는 진짜 이유다.
이 여섯이 "1:1을 버린 이유"의 실체다. 앞 표의 각 줄 뒤엔 이 메커니즘이 있다.
심화 애프터노트 — 이 글을 쓰고, 코드를 다시 봤더니
솔직한 고백으로 이 심화를 닫자. 위 ②(tenant 최적화 인덱스)와 ⑤(alias 무중단 전환)를 처음 쓸 때 우리는 "이렇게 한다"고 적었다. 발행하고 나서 코드를 다시 열었더니 — 서술이 코드보다 앞서 있었다. tenant 전용 인덱스는 라이브가 아직 안 부르고 있었고, alias 전환은 flag가 꺼져 실제 alias가 0개였다. 둘 다 "구조는 지원, 서빙은 아직" — 이 글 §5가 말하려는 바로 그 gap을, 이 글 자신이 저지르고 있었다. 그래서 위 두 문단을 고쳐 썼고, 이 애프터노트를 남긴다. 완료를 다시 정의한 글이 자기 서술까지 receipt로 검증받지 않으면 그건 위선이니까.
그리고 이왕 고백하는 김에 — ④(멱등 point_id)에도 뒷이야기가 있다. 결정적 ID는 재색인 시 중복을 막아준다. 그런데 그게 point-id의 타입 일관성까지 보장하진 않았다.
Qdrant의 point ID는 부호 없는 정수 아니면 UUID 문자열이어야 한다 — 절대 "정수를 문자열로 담은 값"이면 안 된다. Qdrant가 문자열 ID는 UUID로 파싱하려 들기 때문이다. 그런데 서로 다른 시기에 서로 다른 ingest 소스로 들어온 탓에, 두 개의 KB만 정수 ID로 색인돼 있었고 나머지는 전부 UUID 문자열이었다. 후보를 추린 뒤 본문·late-interaction 벡터를 다시 당겨오는 hydration 경로가 모든 ID를 일괄 stringify해서 Qdrant retrieve를 불렀고 — 정수-ID KB는 hydration이 100% "UUID 파싱 실패"로 떨어졌다. 그러면 재시도 루프가 돌면서 그 두 KB에서 나온 답변마다 약 24초가 얹혔다. 조용히 느려지기만 하다가, 하필 채팅 채널 sync timeout이 터지면서 사용자 눈에 보였다.
고친 건 작은 타입 강제 헬퍼 하나였다(전부 숫자면 int, 아니면 str 유지) + 다시 뒤집히지 않게 UUID 경로를 지키는 회귀 테스트. 교훈: 결정성(④)은 idempotent upsert를 보장하지만, 여러 ingest 소스로 유기적으로 자란 corpus에서 point-id "타입"의 일관성은 그와 별개의 두 번째 invariant였다 — 우리가 한 번 데어보고 배운.
이 세 자리(② ⑤ 정정 + point-id 사고)가 서로 다른 얼굴로 같은 말을 한다. 저장 구조가 "완성"돼도, 그게 라이브에서 그대로 도는지는 계속 다시 확인해야 한다. 그래서 ⑥처럼 결정을 코드에 못 박고, 이런 애프터노트를 남긴다.
벡터 옆에, 그래프 — "비슷한 것"과 "연결된 것"
지식을 잘 저장한다는 건 벡터만 잘 넣는 게 아니었다. 실제 업무 질문은 자주 관계로 온다.
"이 시스템과 연결된 담당 부서는?"
"이 장애가 어느 프로세스의 다음 단계랑 이어져?"
"A 문서엔 없는데, B와 연결하면 답이 나오는 질문"
벡터 검색은 "비슷한 문장"엔 강하지만 이런 연결 질문엔 약하다. 그래서 우리는 지식을 두 가지로 저장한다 — *Qdrant(벡터, 비슷함) + Neo4j(그래프, 연결).** 그래프의 엣지는 *수집 시점에 같이 쌓인다.
그런데 여기서 또 한 번 멈췄다. 검증 안 된 그래프 검색을 일반 봇 답변 경로에 바로 섞으면, 잘 답하던 대화 품질이 흔들릴 수 있다. 켜보고 측정했더니:
항목
상태
그래프 데이터(Neo4j)
있음 — 연결 재료는 쌓여 있다
그래프 확장기
동작 (chunk 1개 → 관련 후보 10개)
교차-KB 탐색
timeout — 답변 경로에 바로 넣기엔 불안정
검색 개선
0건 케이스 존재 — 항상 개선하진 않음
답변 경로
원복 — 기존 봇 답변 품질 보호 우선
→ "그래프가 나쁘다"가 아니라 "지금 전체 답변 경로에 켜는 방식이 맞지 않는다." 그래서 그래프는 답변 경로가 아니라 검증 레이어에 먼저 둔다:
① 답변 경로 유지 (그래프 마스터 기본 off)
② 전용 eval 경로 (실사용 질문 X, 검증용 질문 세트로 그래프 vs 벡터 비교)
③ 성공 조건 (timeout 없음 · 벡터-only 대비 recall↑ · 근거↑ · latency OK · 회귀 0)
④ 그 다음 좁은 canary (특정 KB · 질문 유형 · 봇만)
핵심은 GraphRAG의 가치는 대체*가 아니라 관계형 질문 *보강이라는 것, 그리고 증명되기 전엔 답변 품질을 지킨다는 것이다.
5. 반전 — "구조 완성"이 "검색 품질 완성"은 아니었다
저장 구조를 닫고 나서, 우리는 값진 걸 배웠다. 저장을 잘 설계한 것과 검색이 좋아진 것은 다른 일이었다.
특히 짧은 한국어 현장어 — "침수", "미송", "담배권", "재고감모" — 같은 질문. 의미(dense) 검색은 긴 문장엔 강하지만 이런 정확어엔 약하다. 설계엔 dense+sparse 하이브리드가 분명히 있었다. 그런데 "구현돼 있다"와 "서빙 경로가 그걸 쓴다"는 다른 일이었다. 실제로는 색인 파이프라인이 sparse named vector를 채워 넣긴 하는데, 질의 서빙 프로세스가 sparse 인코더를 일부러 로드하지 않는다(자원 트레이드오프 설정). 그래서 hybrid fusion 질의는 dense·sparse 두 다리에 똑같이 가중치를 주고 돌지만, sparse 다리는 매번 빈손으로 돌아온다.
이걸 어떻게 살릴지도 하나의 선택이었다. 우리가 지금 검토 중인 안(내부 ADR, 아직 PROPOSAL)은 dense 모델은 그대로 두고, sparse 전용 로컬 인코더만 질의 pod에 얹는 것 — 이미 만들어 둔 RRF 융합의 sparse 다리에 드디어 질의 시점 벡터를 주는, 가장 가벼운 변경이다. 기각한 대안은 임베딩 모델 전면 교체(dense+sparse+late-interaction을 한 모델이 함께 뽑도록): 이미 결론 난 임베딩 전략 ADR을 다시 열고, 전체 corpus를 blue/green으로 재색인해야 해서 무겁다. 근거로 인용한 숫자는 공개 논문 하나 — BGE-M3 논문(Table 2)이 한국어 검색에서 hybrid가 dense-only로 퇴화하면 약 -1.4 nDCG@10을 추정한다. 다만 같은 조건의 내부 paired 벤치는 아직 "미측정"이다. 외부 인용 + 구조적 gap 추적은 있고, 내부 하드 넘버는 없다 — 그것까지 정직하게 적는다. 벡터 하이브리드든, 위의 그래프든 — 결국 똑같이 "저장은 됐지만, 답변 품질로 증명돼야 완료"다.
그래서 우리는 "완료(Done)"를 다시 정의했다.
PR 머지 / dev에서 켜짐 / "코드에 있음" → 완료 아님 실제 사용자가, 측정된 품질로, 운영 경로에서, 영속된 증거(receipt)와 함께 더 나은 답을 받을 때 완료
이걸 팀의 엔지니어링 표준(4-layer Receipt)으로 못 박았다 — 저장 구조의 완성이, 검색 품질의 완료를 다시 정의하게 만든 자리에서.
(품질 coda): 첫 파일럿은 OFC봇이었다. 실제 사용자 로그에서 나온 짧은 질문 20건을 dev 운영 경로로 다시 흘려보냈다. 개선 전에는 답변은 생성됐지만 근거 문서를 찾지 못한 케이스가 많았다. 개선 후 근거 문서 연결률은 35%에서 100%로 올랐다(+65%p). 평균 근거 문서 수는 0.9개에서 4.15개로 늘었고, 약한 케이스는 13건에서 0건으로 줄었다.
6. 맺음 — 그리고 새로운 문제
드릴은 손에 익었다. 전동 드릴(Qdrant)로 구멍 뚫는 법 — 통합 컬렉션 + named vector + payload — 도 배웠다. 끝없이 자라는 사내 지식을 컬렉션 폭증 없이 저장하는 구조는 완성됐다.