20220805

sentence-transformers 한글 검색 임베딩

상품 검색에 한국어 임베딩을 넣어보려고 sentence-transformers 몇 개 시도. 올해 초 Milvus vs Weaviate 비교할 때 임베딩 품질 아쉬웠던 걸 개선하는 작업.

테스트한 모델

  • paraphrase-multilingual-MiniLM-L12-v2 — 다국어, 가벼움(384d)
  • paraphrase-multilingual-mpnet-base-v2 — 다국어, 품질 더 좋은 대신 느림(768d)
  • jhgan/ko-sroberta-multitask — 허깅페이스의 한국어 특화 모델
  • BM-K/KoSimCSE-roberta-multitask — KoSimCSE. 한국어 STS 벤치 높음

평가 기준

사내에서 직접 라벨링한 상품 쿼리→정답 상품 1,200쌍. MRR@10, Recall@10.

from sentence_transformers import SentenceTransformer

m = SentenceTransformer("jhgan/ko-sroberta-multitask")
q_emb = m.encode(queries, batch_size=64, normalize_embeddings=True)
d_emb = m.encode(docs, batch_size=64, normalize_embeddings=True)

결과

모델MRR@10Recall@10인코딩 속도
multilingual-MiniLM0.410.58빠름
multilingual-mpnet0.520.69중간
ko-sroberta0.610.74중간
KoSimCSE-roberta0.630.76중간

한국어 전용 모델들이 확연히 좋다. 특히 상품명의 축약/약어("삼디충", "갤S22" 등)에서 차이가 큼. 다국어 mpnet도 나쁘진 않아서 영문 혼합 도메인이면 괜찮은 선택.

실운영 적용

KoSimCSE 기반으로 벡터 인덱싱. Weaviate에 올리고 BM25와 하이브리드 검색(70:30 가중). BM25 단독 대비 Recall@10이 0.55 → 0.78로 개선.

임베딩 모델도 주기적으로 재학습해야 함. 신규 카테고리 상품 들어오면 cold start 발생. 사내 쿼리 로그를 모아서 파인튜닝(contrastive loss) 하는 파이프라인을 분기별로 돌리는 걸로.

토막

  • 임베딩 차원 768 → 256으로 줄일 때 Matryoshka 방식 잘 안 맞는 모델도 있음. PCA로 줄이는 게 차라리 안정적이었다.
  • CPU 인코딩이 느려서 대량 색인은 GPU 필수. 100만 건 mpnet 기준 T4 GPU에서 약 20분.

20220709

Docker Desktop 유료 전환 이후 대안

작년 9월쯤 Docker Desktop 라이선스 정책 바뀐 이후로 사내 환경 정리 숙제가 있었는데 이번 분기에 정리. 개인 무료 / 소기업 무료, 중대형 유료라 우리는 유료 대상. 대안 한번 주욱 돌려봤다.

후보

  1. Rancher Desktop
  2. colima (macOS)
  3. podman + podman-compose
  4. Lima (barebone, 수동 세팅 필요)

macOS 팀 (개발 대다수)

colima가 가장 무난. Lima + docker cli + qemu/virtualization.framework. CLI 한 방 설치.

brew install colima docker docker-compose
colima start --cpu 4 --memory 8 --disk 60
docker ps  # 바로 됨

M1/M2에서 x86 이미지 빌드 시 --arch x86_64 주면 rosetta 통해 돌아감. 조금 느림.

Rancher Desktop도 써봤는데 GUI 있는 게 장점. k3s 같이 쓰는 사람은 이쪽. 다만 메모리 점유 더 크다.

리눅스 팀

원래부터 Docker Desktop 안 써서 변경 없음. podman 도입 여부만 별도 논의(root 없이 쓰는 게 장점).

Windows 팀

WSL2 + podman desktop 또는 WSL2 + docker engine 직접 설치. Rancher Desktop이 Windows에선 안정성 좋음.

걸렸던 이슈

  • colima에서 docker buildx bake 쓸 때 containerd 이미지 저장 이슈. docker runtime으로 바꿔서 해결(colima start --runtime docker).
  • docker-compose v2 플러그인 인식. 기본 brew 버전이 따로 놀아서 ~/.docker/cli-plugins 심볼릭 링크.
  • M1 쪽에서 MySQL 5.7 이미지가 arm 빌드 없어서 --platform linux/amd64 붙여야 함.

결론

Docker Desktop 굳이 유료로 갈 필요는 없다. 단 "설정 한 번 안 바꾸고 쓸 수 있는 편의성"에는 대가가 있다. 초기 세팅 1~2시간, 그 후엔 별 차이 없음. 사내 개발자 30명 × 라이선스 비용 생각하면 금방 이득.

20220612

Python 3.11 속도 개선 벤치

3.11 beta3 나왔고 벤치 돌려봄. "Faster CPython" 프로젝트 결과가 공식 수치로 평균 25%라는데 실제 우리 코드에서 어떤지.

환경: macOS 12, M1 Pro, pyenv로 3.10.4 / 3.11.0b3 양쪽 설치. timeit으로 반복 측정.

테스트 1 — CPU 바운드 간단 루프

def sum_squares(n):
    s = 0
    for i in range(n):
        s += i * i
    return s

# n = 10_000_000
# 3.10: 0.93s
# 3.11: 0.67s  (약 28% 빠름)

테스트 2 — dict/tuple 많이 만들기

def rows(n):
    out = []
    for i in range(n):
        out.append({"id": i, "name": f"n{i}", "tags": (i, i*2)})
    return out

# n = 1_000_000
# 3.10: 1.32s
# 3.11: 0.91s  (약 31% 빠름)

테스트 3 — JSON 파싱

json.loads 1000회, 100KB 문서

  • 3.10: 0.88s
  • 3.11: 0.73s (약 17%)

테스트 4 — 예외 발생

3.11에서 많이 개선된 분야. try/except 거의 공짜.

def raise_catch(n):
    for i in range(n):
        try:
            raise ValueError
        except ValueError:
            pass

# n = 1_000_000
# 3.10: 0.56s
# 3.11: 0.22s  (약 60% 빠름)

기타 좋은 것

  • 에러 메시지가 훨씬 친절해짐. object.xx에 물결 줄로 정확한 위치 표시.
  • TaskGroup, Exception Group(PEP 654) — asyncio 쓸 때 여러 예외 한 번에 처리 편함.
  • tomllib 표준 라이브러리. pyproject.toml 읽을 때 외부 패키지 없이.

주의

C 확장 호환성. 일부 패키지는 재빌드 필요. numpy, pandas 등 주요 패키지는 이미 3.11 wheel 나왔다. 작게 쓰는 내부 C extension은 직접 ABI 확인 필요.

우리 메인 API는 async 비중 커서 25% 전체 개선은 아니지만 10~15% 정도는 체감될 것. 10월 정식 뜨면 바로 스테이지로 올릴 예정.

20220509

PostgreSQL 15 Merge 실전

PG 15 베타에 MERGE 구문이 들어왔다. 드디어. 그동안 INSERT ... ON CONFLICT DO UPDATE(upsert)로 대부분 해결했지만 조건 분기가 복잡해지면 지저분해졌음.

MERGE 구문 기본

MERGE INTO inventory AS t
USING stock_delta AS s
  ON t.sku = s.sku
WHEN MATCHED AND t.qty + s.delta = 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET qty = t.qty + s.delta
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.delta);

하나의 statement로 INSERT/UPDATE/DELETE 분기. 표준 SQL이라 오라클/SQL Server 쓰던 사람이 익숙할 것.

upsert 대비 차이점

  • DELETE까지 가능
  • source가 subquery일 때 표현이 더 깔끔
  • 조건 분기가 여러 개일 때 가독성 좋음
  • 단, RETURNING이 MERGE에서는 아직 지원 안 됨. 이건 15 beta 기준.
    → upsert 써야 할 이유가 여전히 있음.

동시성

MERGE가 "concurrent-safe"하냐는 질문엔 조심해야 한다. MATCHED 판단과 INSERT 사이에 race가 존재할 수 있음. unique constraint 없이 MERGE만 믿으면 중복 insert 나올 수 있음. 여전히 PK/UNIQUE 제약 같이 써야 안전함.

공식 문서에도 "MERGE does not seek locks for the matched rows ... you should ensure appropriate isolation or constraints"류의 안내가 있다.

실사용

대량 CSV 동기화(외부에서 들어오는 재고 파일) 처리에 써봤는데 확실히 코드가 줄어듦. 기존에 5개 구문으로 나눠썼던 게 하나로 정리됨. 성능은 upsert 대비 큰 차이는 없었는데, 여러 분기의 경우 플랜이 좀 더 깔끔하게 나옴.

15는 아직 beta. 정식 릴리즈는 가을쯤. 지금부터 익히면 나중에 편하다.

20220407

nginx unit 써보기

nginx unit 1.26 써보고 기록. unit은 nginx inc에서 만든 "앱 서버"인데, Python/PHP/Node/Go 등 여러 런타임을 통합 관리 해주는 도구. 처음 봤을 때 "이게 뭐지" 싶었는데 써보니 꽤 흥미로움.

설치 후 config는 REST API로 푸시한다. 파일 편집 아님.

curl -X PUT --data-binary @config.json \
  --unix-socket /var/run/control.unit.sock \
  http://localhost/config/

config.json 예 (fastapi 앱).

{
  "listeners": {
    "*:8000": { "pass": "applications/api" }
  },
  "applications": {
    "api": {
      "type": "python 3.10",
      "path": "/srv/app",
      "module": "main",
      "callable": "app",
      "processes": { "max": 8, "spare": 2 }
    }
  }
}

장점 체감:

  • 런타임 교체 무중단. config 푸시하면 graceful reload. gunicorn 재시작 고민 안 해도 됨.
  • 프로세스 오토스케일. spare, max로 부하에 맞춰 워커 수 자동 조절.
  • 멀티앱을 한 인스턴스에 올리기 편함. 사내 툴용 소규모 서비스 세 개 하나로 합쳤다.

단점 및 주의:

  • 운영 도구(로그, 메트릭) 생태계가 nginx 본체보다 약함. access log 포맷이 JSON만.
  • 공식 helm chart가 아직 공식 지원은 아님. 직접 구성.
  • 문서 부족. Python+fastapi 조합은 잘 되지만 Node.js express 연동하다가 static file serving에서 약간 삽질.

결론 — gunicorn/uwsgi 대체 용도로는 꽤 괜찮다. 다만 넣어야 할 이유가 명확해야 함. 단순 단일 앱이면 기존 스택 유지가 낫다. 우리는 런타임 여러 개 섞인 사내 포털에 적용.

20220324

Kafka Streams 실전 패턴

Kafka Streams를 실서비스에 넣은 지 3개월 정도. 2.8 쓰다 3.1로 올림. 사용하면서 굳어진 패턴 몇 개.

1. topic 이름은 엔티티_이벤트_상태 3단

order.created.v1, order.paid.v1 이런 식. 버전 suffix 꼭 넣음. 스키마 깨지면 v2로 포킹. 다운스트림이 v1/v2 전환 시간 가질 수 있게.

2. KTable 쓸 때 log compaction 필수

changelog topic 자동 생성되는데 retention 무제한 안 두면 state store 복원이 안 된다. 이거 모르고 retention 7d 걸어뒀다가 장애 한 번.

cleanup.policy=compact
min.cleanable.dirty.ratio=0.1
segment.ms=604800000

3. join 윈도우는 보수적으로

stream-stream join 할 때 window를 크게 잡으면 state store 폭증. 5분 짜리로 시작해서 필요 시 늘리는 방향. 처음에 "한 시간 정도 넉넉히"로 했다가 디스크 먹는 속도 보고 바로 5분으로 축소.

4. 예외 처리 전략 설정

기본은 DEFAULT_DESERIALIZATION_EXCEPTION_HANDLER_CLASS_CONFIG가 LogAndFail. prod에 그대로 두면 포이즌 메시지 하나에 앱 죽는다. LogAndContinue로 바꾸고 DLQ에 보내는 handler 직접 구현 권장.

public class DlqHandler implements DeserializationExceptionHandler {
    @Override
    public DeserializationHandlerResponse handle(
            ProcessorContext ctx, ConsumerRecord<byte[], byte[]> rec, Exception e) {
        dlqProducer.send(new ProducerRecord<>("deser-dlq", rec.key(), rec.value()));
        return DeserializationHandlerResponse.CONTINUE;
    }
}

5. topology.optimization 켜기

StreamsConfig.TOPOLOGY_OPTIMIZATION을 "all"로. default가 none이다. rekey 시 중복 topic 안 만들도록 해주는 옵션 포함.

6. state store 복원 시간 대비

인스턴스 재시작 시 changelog topic 끝까지 읽어서 복원함. 수백GB면 수십 분 걸릴 수 있음. standby.replicas를 1~2로 둬서 failover 즉시 가능하게.


아직 Exactly-once v2 모드는 못 써봄. 이게 트랜잭션 비용 많이 줄였다는데 성능 차이 궁금. 다음 달 스테이지에 올려보기로.

20220224

Vector DB 처음 써봄 — Milvus vs Weaviate

상품 유사도 추천에 임베딩 기반 검색을 붙여보려고 vector DB 두 개를 비교 테스트. Milvus 2.0(작년 11월쯤 2.0 GA), Weaviate 1.10. 둘 다 처음 써봄. 솔직히 관련 지식 얕아서 사용기 수준.

데이터

상품 텍스트(이름+설명) 임베딩 300만 벡터, 차원 768(multilingual sentence embedding). query 당 top-10 ANN 검색.

설치

Milvus는 docker-compose로 minio+etcd+pulsar까지 같이 올라옴. 처음 보면 어질어질함.

wget https://github.com/milvus-io/milvus/releases/download/v2.0.0/milvus-standalone-docker-compose.yml
docker compose up -d

Weaviate는 훨씬 가볍.

services:
  weaviate:
    image: semitechnologies/weaviate:1.10.0
    ports: ["8080:8080"]
    environment:
      QUERY_DEFAULTS_LIMIT: 25
      DEFAULT_VECTORIZER_MODULE: 'none'

색인 구성

둘 다 HNSW 인덱스. 파라미터는 M=16, efConstruction=200로 맞췄다. 검색 시 ef는 64.

결과

항목Milvus 2.0Weaviate 1.10
인덱스 빌드 (3M)약 18분약 32분
p95 latency (top-10)22ms31ms
recall@100.940.93
메모리약 12GB약 9GB
운영 단순성낮음(컴포넌트 많음)높음
메타데이터 필터OKGraphQL 기반, 표현력 높음

개인적 인상

성능만 보면 Milvus가 미세하게 좋고 확장성도 강해 보인다. 그런데 운영할 컴포넌트가 많아서 처음 도입에 부담. Weaviate는 단일 바이너리 가까운 느낌이라 실험 단계엔 훨씬 편하다. GraphQL 쿼리 인터페이스는 익숙해지면 편리한데 팀 전체가 학습해야 됨.

결정: PoC 단계에서는 Weaviate로 간다. 트래픽 올라가면 Milvus 재검토. 어차피 벡터 자체는 재업로드만 하면 되니 락인은 작음.

공통 주의점

  • 벡터 DB만 있다고 검색이 좋아지진 않는다. 임베딩 품질이 전부임. 다국어 문제 심각한 카테고리(전기용품 등)는 임베딩 모델 튜닝이 먼저.
  • ANN 파라미터(ef, M) 따라 recall/latency 트레이드오프 큼. 실데이터로 튜닝 필수.
  • vector index rebuild 시간 고려. 스키마 변경이 무겁다.

20220112

Django 4 async view 실제 사용 후기

Django 4.0 나오고 async view를 사이드 프로젝트에 실제로 굴려본 후기. 3.0에서 문 열고 3.1/3.2 거치면서 ORM async까지 오긴 했는데 쓸만한 상태가 된 건 4.0부터라고 본다.

무엇이 되는가

  • async view / class-based async view
  • async middleware
  • QuerySet의 일부 async 메서드: aget(), acreate(), aupdate(), alen(), afirst(), async for로 iterate

쓴 코드

from django.http import JsonResponse
from .models import Order

async def order_detail(request, order_id):
    try:
        order = await Order.objects.aget(pk=order_id)
    except Order.DoesNotExist:
        return JsonResponse({"error": "not found"}, status=404)
    items = [i async for i in order.items.all()]
    return JsonResponse({
        "id": order.pk,
        "status": order.status,
        "items": [{"sku": x.sku, "qty": x.qty} for x in items],
    })

걸리는 한계

  1. prefetch_related, select_related는 여전히 sync. async context 안에서 쓰려면 sync_to_async로 감싸야 함. 깊은 관계 조회가 많은 API는 결국 반쪽.
  2. Manager 메서드 커스텀한 것들은 async 대응 별도. 팀 내 custom manager 많으면 귀찮다.
  3. ASGI 서버 필수. uvicorn or daphne. 기존 gunicorn wsgi 쓰던 파이프라인 손봐야 함.
  4. middleware 체인에서 sync/async 섞이면 async adapter가 자동 삽입되지만 성능 이득이 깎임. 일관성 유지 필요.

체감

외부 HTTP 호출 섞인 엔드포인트에서 동시성 이득 확실. 순수 DB 조회만 하는 엔드포인트는 async 한다고 빨라지지 않는다(어차피 postgres 드라이버 블로킹인 시간은 그대로). "async view 안에서 httpx async client로 외부 콜 여러 개 gather"가 진짜 이득 볼 때.

결론

"전체를 async로 가자"라고 전환하면 후회한다. 외부 호출이 몰린 엔드포인트만 선택적으로. FastAPI만큼 급진적으로 async 하고 싶으면 FastAPI가 맞음. Django가 장기적으로 가는 길에 올라탄 것.