쿼리
쿼리 (Querying)
이제 데이터를 로드하고, 인덱스를 만들고, 나중을 위해 저장했다면 LLM 애플리케이션의 가장 중요한 부분인 쿼리를 할 준비가 됐어요.
가장 단순한 형태에서 쿼리는 그냥 LLM에 대한 프롬프트 호출이에요. 질문에 답을 얻는 것, 요약을 요청하는 것, 훨씬 더 복잡한 지시일 수도 있죠.
더 복잡한 쿼리는 반복/체이닝된 프롬프트 + LLM 호출을 포함하거나, 여러 컴포넌트에 걸친 추론 루프일 수도 있어요.
시작하기
모든 쿼리의 기반은 QueryEngine이에요. QueryEngine을 얻는 가장 간단한 방법은 인덱스가 직접 만들어 주게 하는 거예요:
query_engine = index.as_query_engine()
response = query_engine.query(
"Write an email to the user given their background information."
)
print(response)
쿼리의 단계
하지만 쿼리에는 겉으로 보이는 것보다 더 많은 것이 있어요. 쿼리는 세 가지 뚜렷한 단계로 구성돼요:
- 검색 (Retrieval):
Index에서 쿼리에 가장 관련성 높은 문서를 찾아 반환하는 단계예요. 인덱싱에서 앞서 논의했듯, 가장 흔한 검색 유형은 'top-k' 시맨틱 검색이지만 다른 검색 전략도 많아요. - 포스트프로세싱 (Postprocessing): 검색된
Node들이 선택적으로 재랭킹, 변환, 필터링되는 단계예요. 예를 들어 키워드 같은 특정 메타데이터가 붙어 있어야 한다고 요구하는 식이죠. - 응답 신시사이즈 (Response synthesis): 쿼리와 가장 관련성 높은 데이터, 프롬프트가 결합돼 응답을 돌려주기 위해 LLM으로 보내지는 단계예요.
문서와 노드에 메타데이터를 붙이는 방법은 문서와 노드에서 확인할 수 있어요.
쿼리 단계 커스터마이징
LlamaIndex는 쿼리를 세밀하게 제어할 수 있는 저수준 구성 API를 갖추고 있어요.
이 예제에서는 리트리버를 커스터마이징해 top_k에 다른 값을 쓰고, 검색된 노드가 포함되려면 최소 유사도 점수에 도달해야 하는 포스트프로세싱 단계를 추가해요. 관련 결과가 있을 때는 데이터를 많이 주지만, 관련성 있는 것이 없으면 데이터가 없을 수도 있어요.
from llama_index.core import VectorStoreIndex, get_response_synthesizer
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
# build index
index = VectorStoreIndex.from_documents(documents)
# configure retriever
retriever = VectorIndexRetriever(
index=index,
similarity_top_k=10,
)
# configure response synthesizer
response_synthesizer = get_response_synthesizer()
# assemble query engine
query_engine = RetrieverQueryEngine(
retriever=retriever,
response_synthesizer=response_synthesizer,
node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.7)],
)
# query
response = query_engine.query("What did the author do growing up?")
print(response)
자체 검색, 응답 신시사이즈, 전체 쿼리 로직을 해당 인터페이스를 구현해 추가할 수도 있어요.
구현된 컴포넌트와 지원 구성의 전체 목록은 레퍼런스 문서를 확인해요.
각 단계를 커스터마이징하는 방법을 더 자세히 살펴볼게요:
리트리버 구성
retriever = VectorIndexRetriever(
index=index,
similarity_top_k=10,
)
알아두면 좋을 아주 다양한 리트리버가 있는데, 리트리버 모듈 가이드에서 배울 수 있어요.
노드 포스트프로세서 구성
검색된 Node 객체의 관련성을 더 개선할 수 있는 고급 Node 필터링과 증강을 지원해요. 이는 LLM 호출의 시간/횟수/비용을 줄이거나 응답 품질을 개선하는 데 도움이 돼요.
예를 들어:
KeywordNodePostprocessor:required_keywords와exclude_keywords로 노드를 필터링해요.SimilarityPostprocessor: 유사도 점수에 임계값을 설정해 노드를 필터링해요(따라서 임베딩 기반 리트리버만 지원).PrevNextNodePostprocessor:Node관계를 기반으로 검색된Node객체에 추가 관련 컨텍스트를 보강해요.
노드 포스트프로세서의 전체 목록은 Node Postprocessor Reference에 문서화돼 있어요.
원하는 노드 포스트프로세서를 구성하려면:
node_postprocessors = [
KeywordNodePostprocessor(
required_keywords=["Combinator"], exclude_keywords=["Italy"]
)
]
query_engine = RetrieverQueryEngine.from_args(
retriever, node_postprocessors=node_postprocessors
)
response = query_engine.query("What did the author do growing up?")
응답 신시사이즈 구성
리트리버가 관련 노드를 가져온 뒤, BaseSynthesizer가 정보를 결합해 최종 응답을 신시사이즈해요.
다음과 같이 구성할 수 있어요:
query_engine = RetrieverQueryEngine.from_args(
retriever, response_mode=response_mode
)
지금은 다음 옵션을 지원해요:
default: 각 검색된Node를 순차적으로 훑으며 "create and refine" 방식으로 답을 만들어요. Node마다 별도의 LLM 호출이 발생해요. 더 상세한 답변에 좋아요.compact: 각 LLM 호출에서 최대 프롬프트 크기에 들어갈 수 있을 만큼 많은Node텍스트 청크를 채워("compact") 프롬프트를 최적화해요. 한 프롬프트에 넣을 청크가 너무 많으면 여러 프롬프트를 훑으며 "create and refine" 방식으로 답을 만들어요.tree_summarize:Node객체 집합과 쿼리가 주어지면 재귀적으로 트리를 구성하고 루트 노드를 응답으로 반환해요. 요약 목적에 좋아요.no_text: 리트리버만 실행해 LLM에 보냈을 노드를 실제로 보내지 않고 가져와요. 그런 다음response.source_nodes를 확인해 검사할 수 있어요. 응답 객체는 5장에서 더 자세히 다뤄요.accumulate:Node객체 집합과 쿼리가 주어지면 각Node텍스트 청크에 쿼리를 적용하면서 응답을 배열에 축적해요. 모든 응답의 연결 문자열을 반환해요. 같은 쿼리를 각 텍스트 청크에 대해 별도로 실행해야 할 때 좋아요.
구조화된 출력 (Structured Outputs)
출력이 구조화되길 원할 수도 있어요. Query Engines + Pydantic Outputs에서 쿼리 엔진 클래스에서 Pydantic 객체를 추출하는 방법을 확인해요.
전체 Structured Outputs 가이드도 꼭 확인해 보세요.
나만의 쿼리 워크플로 만들기
복잡한 쿼리 흐름을 설계하고 싶다면, 프롬프트/LLM/출력 파서부터 리트리버, 응답 신시사이저, 나만의 커스텀 컴포넌트까지 다양한 모듈을 아우르는 쿼리 워크플로를 직접 구성할 수 있어요.
자세한 내용은 Workflow Guide를 살펴보세요.