자동차 사이버보안 RAG 시스템
자동차 사이버보안 담당자가 ISO/SAE 21434, UN R155 같은 표준 문서와 내부 TARA 표를 매번 직접 찾아보는 비효율을 없애고 싶어 시작한 RAG 시스템입니다. TARA 자동화 도구(tAIRA)가 분석 단계마다 이 시스템을 호출해 근거 컨텍스트를 받아갑니다.
담당 역할: 임베딩 모델 선정부터 검색 전략, LLM 답변 생성, API 연동까지 RAG 파이프라인 전체를 단독으로 설계·구현했습니다. 특히 '표준 식별자를 정확히 찾는 것'과 '민감 데이터를 외부로 보내지 않는 것' 두 가지 제약 아래에서 어떤 구조가 최선인지 고민하는 과정이 핵심이었습니다.
질문을 dense+sparse로 임베딩해 FAISS로 후보를 뽑고, 두 점수를 0.7/0.3으로 결합한 뒤 cross-encoder로 상위 5개를 재정렬해 Ollama에 근거로 넣어 한국어 답변을 생성합니다. 문서는 오프라인에서 청킹·임베딩해 증분 인덱싱합니다.
설계 근거
처음에는 dense 검색만으로 충분할 줄 알았는데, 'M013-1 위협은 뭐야?' 같은 식별자 질의에서 엉뚱한 결과가 나왔습니다. 의미 임베딩은 식별자처럼 의미가 없는 코드를 잘 구분하지 못했기 때문입니다. 그래서 sparse 검색을 함께 쓰는 하이브리드 구조로 바꿨고, BGE-M3가 한 모델에서 dense와 sparse를 모두 뽑아줘서 파이프라인을 단순하게 유지할 수 있었습니다. cross-encoder는 정확도가 좋지만 느려서 전체에 적용하면 응답이 수 초가 걸렸고, 상위 20개에만 적용하는 타협점을 찾았습니다. LLM은 GPT-4를 쓰면 성능이 좋겠지만 TARA 데이터가 사내 민감 정보라 외부 API에 보낼 수 없어서, 성능을 어느 정도 포기하고 사내 Ollama로 구성했습니다. 모델 크기는 GPU가 결정했습니다. g6.2xlarge의 L4 24GB에 올릴 수 있는 상한이 qwen2.5:32b Q4_K_M(약 20GB)이라, 그 이상은 애초에 선택지가 아니었습니다.
dense 모델과 sparse 모델을 따로 운영하면 파이프라인이 복잡해지는데, BGE-M3는 한 번의 인코딩으로 두 벡터를 모두 뽑아줘서 구조를 단순하게 가져갈 수 있었습니다.
Milvus나 Weaviate 같은 벡터DB도 검토했지만, 문서가 수천 건 수준이라 별도 서버를 띄우는 건 과한 판단이었습니다. 파일 기반 FAISS로 충분했고 배포도 간단했습니다.
1차 검색 결과의 순위가 만족스럽지 않았는데, cross-encoder로 질문과 청크를 함께 보면 관련도 판단이 훨씬 정확해졌습니다. 다만 전체에 적용하면 너무 느려서 상위 20개로 제한했습니다.
GPT-4가 답변 품질은 좋았지만 TARA 데이터를 외부 API에 보낼 수 없어 사내에서 돌릴 수 있는 모델로 좁혔고, 그중 한국어 성능이 가장 나은 qwen2.5를 택했습니다. 사이즈는 GPU가 정했습니다. g6.2xlarge의 NVIDIA L4는 VRAM이 24GB인데, qwen2.5:32b의 Q4_K_M 양자화가 약 20GB로 이 한 장에 올릴 수 있는 사실상 최대치였습니다. 14b로 내리면 여유는 생기지만 보안 표준 문서처럼 긴 근거를 요약할 때 품질 차이가 눈에 띄었고, 대신 남는 VRAM이 적어 컨텍스트 길이를 무한정 늘릴 수 없다는 제약을 안고 가기로 했습니다. 그래서 재정렬로 상위 5개만 근거로 넣는 구조가 선택이 아니라 필수였습니다.
tAIRA가 Python 기반이라 같은 언어로 연동하는 게 자연스러웠고, 추론 호출이 전부 동기 블로킹이라 asyncio.to_thread로 스레드풀에 넘기는 전략을 쓰기 위해 async 프레임워크가 필요했습니다.
공개 표준 문서는 이미지에 번들링하고, 민감한 TARA 데이터와 인덱스는 호스트 볼륨으로 분리했습니다. 데이터 유출 없이 이미지만 교체해 배포할 수 있는 구조를 의도했습니다.
- 이슈
- 'M013-1 위협을 설명해줘'처럼 표준·위협 식별자를 묻는 질의에서 정작 해당 항목을 찾지 못하고 엉뚱한 결과가 나왔습니다.
- 분석
- dense 임베딩은 'M013-1'처럼 의미가 없는 코드를 '자동차', '보안' 같은 일반 단어와 구분하지 못했습니다. reranker도 식별자를 서브워드로 쪼개버려서 오히려 정확 매칭을 방해했습니다.
- 해결
- dense 0.7 + sparse 0.3으로 점수를 결합해 정확 토큰 매칭을 보강하고, 입력에서 ID 패턴(M013-1, ISO 15.4 등)이 감지되면 reranker를 우회한 뒤 sparse 매칭을 부스트하도록 분기했습니다. '모든 질의를 같은 파이프라인으로 처리해야 한다'는 생각을 버린 게 핵심이었습니다.
- 결과
- 식별자 질의에서 해당 항목이 안정적으로 상위에 노출됐고, 일반 질의는 기존대로 reranker를 거쳐 관련도를 유지했습니다.
- 이슈
- 임베딩·FAISS·reranker·Ollama 호출이 모두 동기 블로킹이라 동시에 2개 이상 질의가 들어오면 뒤의 요청이 앞의 추론이 끝날 때까지 멈췄습니다.
- 분석
- FastAPI는 async인데 추론 라이브러리들은 전부 동기여서, async 함수 안에서 직접 호출하면 이벤트 루프 자체가 블로킹됐습니다.
- 해결
- 모든 블로킹 호출을 asyncio.to_thread로 스레드풀에 위임했고, RAG 쪽이 실패하더라도 tAIRA의 TARA 분석은 빈 컨텍스트로 계속 진행되도록 graceful degradation을 설계했습니다. RAG는 보조 도구이지 tAIRA의 핵심 흐름을 막으면 안 된다는 판단이었습니다.
- 결과
- 동시 질의에서 이벤트 루프 블로킹이 사라졌고, RAG 장애가 tAIRA 전체 분석을 중단시키지 않습니다.
회사가 개발 조직이 아니라 자동차 사이버보안 컨설팅 회사였기 때문에, 이 시스템은 기획부터 배포까지 혼자 만들어야 했습니다. 코드를 리뷰해줄 사람도, 설계를 같이 따져볼 사람도 없는 상태에서 RAG 파이프라인을 짜는 게 가장 큰 제약이었습니다. 그래서 AI를 페어 프로그래머처럼 옆에 두고 개발하되, 리뷰어가 없는 자리를 테스트 코드로 대신 메우는 방식을 택했습니다.
청킹 단위를 바꿨을 때 검색 결과가 어떻게 달라지는지, 식별자 질의가 하이브리드 분기로 제대로 빠지는지, 동시 요청에서 응답이 섞이지 않는지를 각각 테스트로 고정해두고, 구현을 맡긴 뒤에는 항상 테스트부터 돌렸습니다. 사람이 봐주지 않는 환경에서는 '내가 무엇을 검증해뒀는가'가 코드를 믿을 유일한 근거가 되더군요. 다만 그 검증이 동작 범위에 머물렀다는 건 아쉽습니다. recall@k 같은 검색 품질 지표는 끝내 만들지 못해서, 파라미터를 조정할 때마다 개선인지 퇴보인지는 여전히 감으로 판단해야 했습니다.
동시에 AI가 대신해주지 못하는 영역도 분명했습니다. 왜 dense 임베딩만으로는 M013-1 같은 식별자를 못 찾는지, cross-encoder를 왜 전체가 아니라 상위 20개에만 걸어야 하는지 — 이런 판단은 제가 문제를 이해하고 있을 때만 내릴 수 있었습니다. AI는 제가 정한 방향을 빠르게 코드로 옮겨줬을 뿐, 방향 자체를 정해주지는 않았습니다. 혼자서 이 규모를 만들 수 있었던 건 AI 덕분이지만, 그게 실제로 굴러가게 만든 건 결정마다 근거와 테스트를 남겨둔 쪽이었다고 생각합니다.