Published on

LangChain RAG에서 No relevant docs 7가지 원인

Authors

서론

RAG(Retrieval-Augmented Generation)를 LangChain으로 붙여놓고 가장 허무한 메시지가 바로 No relevant docs입니다. 에러가 아니라 “검색 결과가 없다”는 신호이기 때문에, 원인을 좁히지 못하면 프롬프트만 계속 만지다가 시간을 버리기 쉽습니다.

이 글은 LangChain 기반 RAG에서 검색 결과가 0개로 떨어지는 대표 원인 7가지를 “어디서 어떻게 깨지는지” 관점으로 분해합니다. 특히 다음 3가지를 목표로 합니다.

  • 재현 가능한 체크포인트(어느 단계에서 0이 되는지)
  • 관측 지표(로그/메트릭으로 확인)
  • 즉시 적용 가능한 수정 코드(retriever, embeddings, chunking, 필터, score threshold)

참고로 RAG 품질 전반(반복/환각, 리랭커, 청킹 전략 등)은 별도 글인 LangChain LlamaIndex RAG 디버깅 체크리스트도 함께 보면 진단 속도가 빨라집니다.


0) 먼저: “정말 문서가 0개인가?”를 분리하기

LangChain에서 No relevant docs는 대개 아래 두 경우 중 하나입니다.

  1. Retriever가 실제로 빈 리스트를 반환(top_k=0, 필터로 전부 제외, 인덱스 비어있음 등)
  2. Retriever는 뭔가 반환했지만, 후처리 단계에서 전부 탈락(score threshold, MMR 설정, reranker cutoff, 길이 제한 등)

따라서 가장 먼저 “retriever 출력”을 직접 찍어보는 것이 정석입니다.

최소 진단 코드: retriever 결과를 그대로 출력

from langchain_core.documents import Document

def debug_retrieve(retriever, query: str, n: int = 5):
    docs = retriever.get_relevant_documents(query)
    print(f"retrieved={len(docs)}")
    for i, d in enumerate(docs[:n]):
        meta = getattr(d, "metadata", {})
        snippet = (d.page_content or "")[:200].replace("\n", " ")
        print(f"[{i}] meta={meta} snippet={snippet}")
    return docs

이 출력이 0이면 검색 단계 문제, 1개 이상이면 후처리/체인 단계 문제로 범위를 줄일 수 있습니다.


1) 인덱스/컬렉션이 비어있거나, 다른 곳을 보고 있다

가장 흔한데 가장 늦게 의심하는 원인입니다.

  • 로컬에서는 ./chroma에 넣었는데 서버에서는 다른 경로를 바라봄
  • Pinecone/Weaviate 등에서 index name / namespace가 달라서 빈 공간을 조회
  • 배포 시점에 ingest job이 실패했는데 앱은 정상 기동

확인 방법

  • VectorStore의 문서 수(count) 를 확인
  • namespace/collection 이름을 로그로 고정 출력

예시(Chroma):

from langchain_chroma import Chroma

vectorstore = Chroma(
    collection_name="kb",
    persist_directory="./chroma",
    embedding_function=embeddings,
)

# Chroma 내부 컬렉션 count 확인
count = vectorstore._collection.count()
print("collection=kb count=", count)

해결 포인트

  • 배포 환경에서 persist_directory/볼륨 마운트가 실제로 유지되는지 확인
  • 멀티테넌트면 namespace를 요청 단위로 동적으로 바꾸는 로직이 있는지 점검

추가로, RAG 시스템이 커지면 벡터 인덱스 메모리 문제(OOM)로 ingest가 중간에 죽어 “사실상 비어있는” 상태가 되기도 합니다. FAISS 사용 중이라면 FAISS RAG 메모리 폭증 OOM 체크리스트도 함께 점검하세요.


2) 쿼리 임베딩과 문서 임베딩 모델이 다르다(또는 차원이 다르다)

RAG에서 “문서가 있는데도 못 찾는” 전형적인 상황입니다.

  • 문서는 text-embedding-3-large로 넣었는데, 검색은 text-embedding-3-small로 함
  • 로컬에서 SentenceTransformer로 넣고, 서버에서 OpenAI embedding으로 검색
  • 차원이 다른데도 라이브러리가 조용히 실패/무의미한 결과를 반환하는 경우도 있음

확인 방법

  • ingest 시점과 query 시점의 embedding 모델명을 메타데이터로 기록
  • 벡터 차원(dimension)을 출력
q = "환불 정책이 뭐야?"
vec = embeddings.embed_query(q)
print("query_dim=", len(vec))

해결 포인트

  • ingest와 query에서 embedding 객체를 동일하게 구성
  • 모델 버전 고정(환경 변수로만 두지 말고 코드/설정으로 고정)

3) 청킹이 잘못되어 “찾을 수 없는 형태”로 들어갔다

문서가 인덱스에 있어도, 청킹이 엉망이면 검색이 0처럼 보이기도 합니다.

대표 패턴:

  • chunk_size가 너무 커서 핵심 키워드가 희석됨
  • chunk_size가 너무 작고 overlap이 0이라 문맥이 끊김
  • PDF/HTML 파싱이 깨져서 의미 없는 토큰(공백/개행/머리글 반복)만 잔뜩 들어감

확인 방법

  • 실제로 저장된 chunk의 page_content 샘플을 20개 정도 랜덤으로 확인
  • 동일 쿼리를 keyword search(BM25)로는 찾는데 vector search로 못 찾는지 비교

해결 포인트(권장 기본값 예시)

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=150,
    separators=["\n\n", "\n", " ", ""],
)

docs = splitter.split_documents(raw_docs)

청킹과 리트리벌 품질을 함께 다듬는 방법(Overlap, MMR, reranker, 토큰 예산)은 위에서 언급한 RAG 디버깅 체크리스트 글에서 더 깊게 다뤘습니다.


4) Metadata filter가 전부 걸러버린다(특히 tenant/user/time 필터)

실무에서 No relevant docs의 “진짜 범인”인 경우가 많습니다.

예:

  • {"tenant_id": "A"} 필터를 걸었는데, ingest는 tenantId로 저장
  • 날짜 필터가 UTC/로컬 혼용으로 전부 제외
  • 권한 필터 로직이 바뀌었는데 과거 문서 메타데이터가 마이그레이션되지 않음

확인 방법

  • 필터 없이 한번 검색해보고 결과가 나오면 필터 문제 확정
  • 반환 문서의 metadata key 목록을 출력
# (예) Chroma retriever
retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 6,
        "filter": {"tenant_id": "A"},
    }
)

# 필터 제거 후 비교
retriever_no_filter = vectorstore.as_retriever(search_kwargs={"k": 6})

해결 포인트

  • 메타데이터 스키마를 코드로 고정하고(상수/타입), ingest 파이프라인에서 강제
  • 필터를 적용하기 전에 “필터 적용 대상 문서 수”를 메트릭으로 남기기

5) Similarity score threshold / search_type 설정이 너무 공격적이다

LangChain에서 retriever는 다양한 search 전략을 갖습니다.

  • similarity(기본)
  • mmr
  • similarity_score_threshold

여기서 similarity_score_threshold를 쓰면, 임계값보다 낮은 문서는 전부 버려 0개가 될 수 있습니다. 데이터가 작거나 도메인이 넓을 때 특히 자주 발생합니다.

재현 예시

retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        "k": 10,
        "score_threshold": 0.85,  # 너무 높으면 0개가 흔함
    },
)

해결 포인트

  • threshold를 낮추거나, 먼저 similarity로 baseline을 확인
  • k를 늘리고 reranker로 정밀도를 회수하는 방식 고려

실전 팁: 처음에는 threshold를 두지 말고, “항상 5~10개는 가져오게” 만든 다음, 후단에서 rerank/압축으로 품질을 올리는 편이 디버깅이 쉽습니다.


6) 쿼리가 너무 짧거나(또는 너무 길거나) 전처리로 의미를 잃는다

검색은 쿼리 품질에 민감합니다.

  • 사용자 입력이 "응", "그거"처럼 지시어뿐이면 임베딩이 무의미
  • 반대로 대화 전체를 통째로 넣어 쿼리가 장문이 되면 핵심이 흐려짐
  • 전처리에서 숫자/기호 제거, 소문자화, 불용어 제거를 과하게 해서 의미 손실

해결 포인트: 쿼리 리라이트(간단 버전)

대화형 RAG라면 “마지막 사용자 질문만” 또는 “질문을 검색용으로 재작성”을 한 번 거치는 것이 안전합니다.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", "사용자 질문을 문서 검색에 적합한 한 문장 쿼리로 재작성하라."),
    ("human", "대화 요약: {summary}\n사용자 질문: {question}\n검색 쿼리:")
])

def rewrite_query(summary: str, question: str) -> str:
    return (prompt | llm).invoke({"summary": summary, "question": question}).content.strip()

이후 rewrite_query() 결과로 retriever를 호출하고, 원문 질문과 함께 로그로 남겨 비교하면 “왜 못 찾는지”가 빨리 드러납니다.


7) 네트워크/레이트리밋/타임아웃으로 임베딩 호출이 실패해 빈 결과처럼 보인다

특히 OpenAI 임베딩을 쓰는 경우, 임베딩 호출 실패가 예외로 터지지 않고 상위에서 삼켜져 결과가 빈 것처럼 처리되는 코드가 종종 있습니다(try/except로 뭉개는 경우).

또한 다음 상황이면 임베딩/벡터DB 호출이 지연되다가 타임아웃나고, fallback 로직이 빈 문서로 이어질 수 있습니다.

  • 429 rate limit
  • 408/timeout
  • 벡터DB 네트워크 오류

레이트리밋 대응은 OpenAI API 429 폭탄 대응 가이드, 타임아웃 재현/해결은 OpenAI Responses API 408 가이드에서 패턴을 잡아두면 RAG 장애 분석이 빨라집니다.

해결 포인트: 임베딩/리트리벌 단계에 “실패를 실패로” 기록

  • 임베딩 실패 시 빈 벡터/빈 문서로 진행하지 말고 즉시 오류로 처리
  • 재시도(지수 백오프) 및 큐잉
  • retriever 호출 시간을 측정해 슬로우 쿼리를 분리

간단한 타이밍/예외 로깅 예시:

import time
import logging

log = logging.getLogger("rag")

def safe_embed(embeddings, text: str):
    t0 = time.time()
    try:
        v = embeddings.embed_query(text)
        return v
    except Exception:
        log.exception("embedding_failed")
        raise
    finally:
        log.info("embedding_ms=%d", int((time.time() - t0) * 1000))

실전용 체크리스트: 10분 안에 범인 잡기

아래 순서대로 보면 대부분 10분 내로 원인을 좁힙니다.

  1. retriever 결과를 그대로 출력해서 0인지 확인
  2. VectorStore count/namespace/collection 확인
  3. query embedding 차원/모델 확인(ingest와 동일?)
  4. 필터 제거하고 검색 → 나오면 metadata 스키마 문제
  5. score_threshold/mmrsearch 설정 완화
  6. 저장된 chunk 샘플 20개를 열어 텍스트 품질/청킹 확인
  7. 임베딩/벡터DB 호출에 429/408/timeout이 있는지 로그 확인

마무리

No relevant docs는 “LLM이 멍청해서”가 아니라, 거의 항상 검색 파이프라인의 설정/데이터/관측 부재에서 나옵니다. 특히 실무에서는 (1) 인덱스/namespace 착각, (4) 필터로 전부 제외, (7) 외부 API 실패를 빈 결과로 처리 이 세 가지가 압도적으로 많습니다.

위 7가지를 순서대로 점검하면서 retriever 출력과 메타데이터/임베딩/청킹 샘플을 함께 로그로 남기면, 같은 장애가 재발해도 “왜 0개가 됐는지”를 빠르게 설명하고 고칠 수 있습니다.