검색(Retrieval)
검색(Retrieval)
사용자가 올린 문서 중에서 "이 질문과 의미가 가장 비슷한 조각"을 찾아야 할 때, 키워드 일치만으로는 한계가 있어요. Retrieval API는 여러분의 데이터 위에서 의미론적 검색(semantic search)을 수행하게 해주는데, 이는 키워드가 하나도 안 겹쳐도 의미가 비슷한 결과를 찾아내는 기법이에요. 검색만으로도 유용하지만, 모델과 결합해 답변을 종합하면 훨씬 강력해져요. 이 가이드에서는 의미론적 검색을 어떻게 수행하는지, 그 뒤에 있는 벡터 스토어(vector store)가 무엇인지 차근차근 살펴볼게요.
출처: 공식문서
퀵스타트
1. 벡터 스토어를 만들고 파일을 업로드합니다.
import OpenAI from "openai";
const client = new OpenAI();
const vector_store = await client.vectorStores.create({
// Create vector store
name: "Support FAQ",
});
await client.vectorStores.files.uploadAndPoll(
vector_store.id,
// Upload file
fs.createReadStream("customer_policies.txt")
);
from openai import OpenAI
client = OpenAI()
vector_store = client.vector_stores.create( # Create vector store
name="Support FAQ",
)
client.vector_stores.files.upload_and_poll( # Upload file
vector_store_id=vector_store.id,
file=open("customer_policies.txt", "rb")
)
2. 검색 쿼리를 보내 관련 결과를 받아옵니다.
user_query = "What is the return policy?"
results = client.vector_stores.search(
vector_store_id=vector_store.id,
query=user_query,
)
결과를 모델과 어떻게 함께 쓰는지는 응답 종합 섹션을 참고하세요.
의미론적 검색(Semantic search)
의미론적 검색은 벡터 임베딩을 활용해 의미상 관련 있는 결과를 찾아내는 기법이에요. 핵심은 키워드가 거의(또는 전혀) 겹치지 않는 결과도 포함한다는 점이에요. 고전적 검색 기법이 놓치기 쉬운 부분이죠.
예를 들어 "When did we go to the moon?"라는 쿼리에 대한 예상 결과를 볼게요.
| 텍스트 | 키워드 유사도 | 의미 유사도 |
|---|---|---|
| The first lunar landing occurred in July of 1969. | 0% | 65% |
| The first man on the moon was Neil Armstrong. | 27% | 43% |
| When I ate the moon cake, it was delicious. | 40% | 28% |
(키워드 유사도는 intersection over union, 의미 유사도는 text-embedding-3-small로 코사인 유사도를 씁니다.)
가장 관련성 높은 결과가 검색어의 단어를 하나도 포함하지 않는다는 점이 재밌어요. 이런 유연성 덕분에 의미론적 검색은 어떤 크기의 지식 베이스에서든 강력한 도구가 됩니다.
의미론적 검색은 벡터 스토어가 뒷받침해요. 이 섹션에서는 의미론적 검색의 작동 방식에 집중할게요.
의미론적 검색 수행
search 함수에 자연어 query를 지정해 벡터 스토어를 조회하면, 관련 청크(chunk)와 유사도 점수, 출처 파일이 담긴 결과 목록을 반환받아요.
results = client.vector_stores.search(
vector_store_id=vector_store.id,
query="How many woodchucks are allowed per passenger?",
)
결과 예시:
{
"object": "vector_store.search_results.page",
"search_query": "How many woodchucks are allowed per passenger?",
"data": [
{
"file_id": "file-12345",
"filename": "woodchuck_policy.txt",
"score": 0.85,
"attributes": {
"region": "North America",
"author": "Wildlife Department"
},
"content": [
{
"type": "text",
"text": "According to the latest regulations, each passenger is allowed to carry up to two woodchucks."
},
{
"type": "text",
"text": "Ensure that the woodchucks are properly contained during transport."
}
]
},
{
"file_id": "file-67890",
"filename": "transport_guidelines.txt",
"score": 0.75,
"attributes": {
"region": "North America",
"author": "Transport Authority"
},
"content": [
{
"type": "text",
"text": "Passengers must adhere to the guidelines set forth by the Transport Authority regarding the transport of woodchucks."
}
]
}
],
"has_more": false,
"next_page": null
}
응답은 기본적으로 최대 10개 결과를 반환하고, max_num_results 파라미터로 최대 50개까지 설정할 수 있어요.
쿼리 재작성(Query rewriting)
쿼리 스타일에 따라 검색 품질이 달라질 수 있어요. search를 수행할 때 rewrite_query=true로 설정하면 쿼리를 자동으로 재작성해 최적의 성능으로 만들어 줍니다. 재작성된 쿼리는 결과의 search_query 필드에서 확인할 수 있어요.
| 원본 | 재작성 |
|---|---|
| I'd like to know the height of the main office building. | primary office building height |
| What are the safety regulations for transporting hazardous materials? | safety regulations for hazardous materials |
| How do I file a complaint about a service issue? | service complaint filing process |
속성 필터링(Attribute filtering)
속성 필터링은 특정 날짜 범위로 검색을 제한하는 등, 기준을 적용해 결과를 좁히는 데 도움을 줘요. 의미론적 검색 전에 attribute_filter에서 기준을 정의하고 조합해 파일의 속성을 대상으로 삼을 수 있어요.
**비교 필터(comparison filter)**는 파일 attributes의 특정 key를 주어진 value와 비교하고, **복합 필터(compound filter)**는 and/or로 여러 필터를 조합해요.
비교 필터:
{
"type": "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "nin", // comparison operators
"key": "attributes_key", // attributes key
"value": "target_value" // value to compare against
}
복합 필터:
{
"type": "and" | "or", // logical operators
"filters": [...]
}
필터 예시 몇 개를 볼게요.
지역(region) 필터:
{
"type": "eq",
"key": "region",
"value": "us"
}
날짜 범위 필터:
{
"type": "and",
"filters": [
{
"type": "gte",
"key": "date",
"value": 1704067200 // unix timestamp for 2024-01-01
},
{
"type": "lte",
"key": "date",
"value": 1710892800 // unix timestamp for 2024-03-20
}
]
}
파일명 일치 필터:
{
"type": "in",
"property": "filename",
"value": ["example.txt", "example2.txt"]
}
파일명 제외 필터:
{
"type": "nin",
"property": "filename",
"value": ["draft.txt", "internal_notes.md"]
}
복합 조건 필터(특정 영어 이름의 최고 기밀 프로젝트):
{
"type": "or",
"filters": [
{
"type": "and",
"filters": [
{
"type": "or",
"filters": [
{
"type": "eq",
"key": "project_code",
"value": "X123"
},
{
"type": "eq",
"key": "project_code",
"value": "X999"
}
]
},
{
"type": "eq",
"key": "confidentiality",
"value": "top_secret"
}
]
},
{
"type": "eq",
"key": "language",
"value": "en"
}
]
}
랭킹(Ranking)
파일 검색 결과가 충분히 관련성 있지 않다면 ranking_options를 조정해 응답 품질을 높일 수 있어요. ranker(예: auto, default-2024-08-21)를 지정하고 score_threshold를 0.0~1.0 사이로 설정하는 방식이에요. score_threshold를 높이면 더 관련성 있는 청크만 남지만, 유용한 결과가 일부 빠질 수도 있어요. ranking_options.hybrid_search를 지정하면 hybrid_search.embedding_weight(rrf_embedding_weight)와 hybrid_search.text_weight(rrf_text_weight)도 조절할 수 있어요. reciprocal rank fusion이 의미 임베딩 매칭과 희소(sparse) 키워드 매칭의 균형을 어떻게 잡을지 제어하죠. 전자를 높이면 의미 유사도를, 후자를 높이면 텍스트 중복을 강조하고, 두 가중치 중 하나는 반드시 0보다 크게 유지하세요.
벡터 스토어(Vector stores)
벡터 스토어는 Retrieval API와 파일 검색 도구의 의미론적 검색을 뒷받침하는 컨테이너예요. 파일을 벡터 스토어에 추가하면 자동으로 청킹되고 임베딩되고 인덱싱돼요.
벡터 스토어는 vector_store_file 객체를 담고 있고, 이는 file 객체가 뒷받침해요.
| 객체 유형 | 설명 |
|---|---|
file |
Files API로 업로드한 콘텐츠를 나타내요. 벡터 스토어와 자주 함께 쓰지만 파인튜닝 등 다른 용도로도 써요. |
vector_store |
검색 가능한 파일의 컨테이너. |
vector_store.file |
청킹되고 임베딩된 file을 벡터 스토어와 연결해 나타내는 래퍼 유형. 필터링에 쓰는 attributes 맵을 담아요. |
요금(Pricing)
청크와 임베딩의 크기를 기준으로, 모든 벡터 스토어에 걸친 총 저장량에 대해 과금돼요.
| 저장량 | 비용 |
|---|---|
| 최대 1GB(모든 스토어 합산) | 무료 |
| 1GB 초과 | $0.10/GB/일 |
비용을 줄일 방법은 만료 정책을 참고하세요.
벡터 스토어 작업
주요 작업은 create(생성), retrieve(조회), update(수정), delete(삭제), list(목록)예요. 예를 들어 벡터 스토어를 만들고 파일을 첨부하는 코드는 다음과 같아요.
client.vector_stores.create(
name="Support FAQ",
file_ids=["file_123"]
)
조회는 vector_store_id로, 수정은 이름을 바꾸는 형태, 삭제·목록은 단순 호출로 이뤄져요. 전체 코드는 공식 문서의 언어별 탭을 참고하세요.
벡터 스토어 파일 작업
vector_store.file의 create 같은 일부 작업은 비동기라 시간이 걸릴 수 있어요. create_and_poll 같은 헬퍼 함수로 완료까지 블록하거나, 상태를 직접 확인할 수 있어요. 벡터 스토어에서 파일을 제거하는 것은 최종적으로 일관되며(eventually consistent), 제거된 파일의 콘텐츠가 잠시 검색 결과에 남을 수 있어요.
파일 추가는 벡터 스토어 ID별로 속도 제한이 적용돼요. /vector_stores/{vector_store_id}/files와 /vector_stores/{vector_store_id}/file_batches는 벡터 스토어당 분당 300회 제한을 공유해요.
파일을 추가하는 예시(폴링 방식):
client.vector_stores.files.create_and_poll(
vector_store_id="vs_123",
file_id="file_123"
)
업로드는 스트림으로:
client.vector_stores.files.upload_and_poll(
vector_store_id="vs_123",
file=open("customer_policies.txt", "rb")
)
속성은 attributes={...}로 설정하고, 파일 삭제·목록·조회도 유사한 형태예요. 세부 코드는 공식 문서의 언어별 탭을 확인하세요.
배치 작업(Batch operations)
배치를 만들 때는 모든 파일에 공통 설정을 적용할 file_ids(선택 attributes/chunking_strategy)를 넘기거나, 파일별 오버라이드를 위한 files 배열로 각각의 file_id + 선택 attributes/chunking_strategy 객체를 넘길 수 있어요. 두 옵션은 상호 배타적이라, 모든 파일이 같은 설정을 공유할지 파일마다 다를지 깔끔하게 제어할 수 있어요.
단일 벡터 스토어로의 고처리량 섭취가 목표라면 가능하면 배치 생성을 권장해요. 배치는 요청 하나에 최대 500개 파일을 담을 수 있어, 단일 파일 생성 요청을 여러 번 보내는 것보다 경합을 줄이고 종단간 지연을 개선해요.
예시(Python):
client.vector_stores.file_batches.create_and_poll(
vector_store_id="vs_123",
files=[
{
"file_id": "file_123",
"attributes": {"department": "finance"}
},
{
"file_id": "file_456",
"chunking_strategy": {
"type": "static",
"max_chunk_size_tokens": 1200,
"chunk_overlap_tokens": 200
}
}
]
)
속성(Attributes)
각 vector_store.file에는 의미론적 검색 시 속성 필터링으로 참조할 수 있는 attributes 사전을 연결할 수 있어요. 사전은 최대 16개 키, 키당 256자 제한이에요.
속성과 함께 파일을 만드는 예시:
client.vector_stores.files.create(
vector_store_id="<vector_store_id>",
file_id="file_123",
attributes={
"region": "US",
"category": "Marketing",
"date": 1672531200 # Jan 1, 2023
}
)
만료 정책(Expiration policies)
vector_store 객체에 expires_after로 만료 정책을 설정할 수 있어요. 벡터 스토어가 만료되면 연결된 모든 vector_store.file 객체가 삭제되고 더 이상 비용이 청구되지 않아요.
만료 정책 예시:
client.vector_stores.update(
vector_store_id="vs_123",
expires_after={
"anchor": "last_active_at",
"days": 7
}
)
한도(Limits)와 청킹(Chunking)
최대 파일 크기는 512MB이고, 파일당 토큰은 5,000,000개를 넘지 않아야 해요(파일 첨부 시 자동 계산돼요).
기본적으로 max_chunk_size_tokens는 800, chunk_overlap_tokens는 400으로 설정돼요. 즉 모든 파일이 800토큰 청크로 나뉘고, 연속 청크 사이에 400토큰이 겹쳐서 인덱싱됩니다. chunking_strategy로 조정할 수 있는 제약은 다음과 같아요.
max_chunk_size_tokens는 100 ~ 4096 범위(포함)여야 해요.chunk_overlap_tokens는 음수면 안 되고max_chunk_size_tokens / 2를 넘으면 안 돼요.
지원되는 파일 형식은 .c, .cpp, .cs, .css, .doc, .docx, .go, .html, .java, .js, .json, .md, .pdf, .php, .pptx, .py, .rb, .sh, .tex, .ts, .txt 등으로, 각각 대응하는 MIME 타입을 가져요. text/ MIME 타입의 인코딩은 utf-8, utf-16, ascii 중 하나여야 합니다. 전체 표는 공식 문서를 참고하세요.
응답 종합(Synthesizing responses)
쿼리를 수행한 뒤 결과를 바탕으로 응답을 종합하고 싶을 때가 있어요. 결과와 원래 쿼리를 모델에 제공하면, 근거가 있는(grounded) 응답을 얻을 수 있어요.
검색 쿼리 수행(Python):
from openai import OpenAI
client = OpenAI()
user_query = "What is the return policy?"
results = client.vector_stores.search(
vector_store_id=vector_store.id,
query=user_query,
)
결과 기반으로 응답 종합(Python):
formatted_results = format_results(results.data)
"\n".join("\n".join(c.text for c in result.content) for result in results.data)
completion = client.chat.completions.create(
model="gpt-6-astra",
messages=[
{
"role": "developer",
"content": "Produce a concise answer to the query based on the provided sources.",
},
{
"role": "user",
"content": f"Sources: {formatted_results}\n\nQuery: '{user_query}'",
},
],
)
print(completion.choices[0].message.content)
이 예시는 format_results 함수를 쓰는데, 대략 다음과 같이 구현할 수 있어요(Python):
def format_results(results):
formatted_results = ""
for result in results.data:
formatted_result = (
f"<result file_id='{result.file_id}' file_name='{result.file_name}'>"
)
for part in result.content:
formatted_result += f"<content>{part.text}</content>"
formatted_results += formatted_result + "</result>"
return f"<sources>{formatted_results}</sources>"