File Search and Vector Stores
The Problem File Search Solves
Unit 7, Lesson 2 covered sending a PDF or other document directly to the model as input_file, and Unit 7, Lesson 5 built a project around answering questions from a single uploaded document. That approach works well for a handful of documents queried directly, but it doesn't scale to a genuinely large document collection — hundreds or thousands of files, a full knowledge base, a company's entire internal documentation. Sending every document in a large collection as input_file on every request would be enormously wasteful (and for a large enough collection, would exceed any practical size limit), since most of a large collection is irrelevant to any single question asked against it.
File search solves this by separating two concerns: first, indexing a document collection once, ahead of time, into a structure optimized for finding relevant pieces of text quickly; and second, at request time, automatically retrieving only the small number of passages actually relevant to a given question and feeding just those into the model, rather than the entire collection. This retrieve-then-generate pattern is often called retrieval-augmented generation, and file search is the platform's built-in implementation of it.
Vector Stores: The Underlying Index
A vector store is the object that holds an indexed document collection. Creating one and uploading files to it is a separate, one-time step from actually querying it.
vector_store = client.vector_stores.create(name="Company Policy Documents")
with open("employee_handbook.pdf", "rb") as f:
client.vector_stores.files.upload(vector_store_id=vector_store.id, file=f)
with open("expense_policy.pdf", "rb") as f:
client.vector_stores.files.upload(vector_store_id=vector_store.id, file=f)
Note: The exact method names for creating vector stores and uploading files to them, and the exact supported file types, can vary by SDK version. Confirm the current interface against your installed SDK version's documentation before relying on these specifics.
Behind the scenes, the platform breaks each uploaded document into smaller chunks, converts each chunk into a numerical representation (an embedding — the subject of Unit 10) capturing its meaning, and stores those representations in a structure that supports fast similarity search. None of that machinery is something you need to implement yourself; it is precisely the value a built-in tool provides over building a retrieval system from scratch, which would otherwise require choosing a chunking strategy, an embedding model, and a vector database, and wiring all three together correctly.
Querying With File Search
Once a vector store is populated, enabling file search on a request and pointing it at that vector store lets the model automatically retrieve relevant passages and use them to answer a question.
response = client.responses.create(
model="gpt-5.6-terra",
input="How many vacation days do employees get in their first year?",
tools=[{"type": "file_search", "vector_store_ids": [vector_store.id]}],
)
print(response.output_text)
The model never sees the full contents of employee_handbook.pdf and expense_policy.pdf in this request — only whichever chunks the retrieval step determined were most relevant to the specific question asked. This is the core efficiency gain over Unit 7's input_file approach: cost and context usage scale with the size of the relevant excerpt, not with the size of the entire underlying document collection, which is what makes file search practical for collections far too large to send in full on every request.
Inspecting What Was Retrieved
As with web search, a response using file search includes structured output items describing the retrieval step, which is worth inspecting both for debugging and for giving users visibility into which documents informed an answer.
response = client.responses.create(
model="gpt-5.6-terra",
input="What is the maximum reimbursable amount for a client dinner?",
tools=[{"type": "file_search", "vector_store_ids": [vector_store.id]}],
)
for item in response.output:
if item.type == "file_search_call":
print(f"File search performed with query: {item.queries}")
elif item.type == "message":
for content_item in item.content:
for annotation in getattr(content_item, "annotations", []):
if getattr(annotation, "type", None) == "file_citation":
print(f"Cited file: {annotation.filename}")
Note: The exact structure of
file_search_callitems and file citation annotations can vary by SDK version. Confirm the current output shape against your installed SDK version's documentation.
Surfacing which specific file a piece of an answer was drawn from — mirroring the citation guidance Lesson 1 gave for web search — matters for the same underlying reason: it lets a user (an employee checking the reimbursement policy, say) verify the answer against the actual source document rather than trusting a synthesized answer without any way to check it, which is especially important for policy or compliance questions where getting the specific number wrong has real consequences.
Comparing File Search to input_file
Unit 7, Lesson 2 and Lesson 5 covered sending a document directly with input_file. It's worth being precise about when each approach is the right one, since they solve related but distinct problems.
| Aspect | input_file (Unit 7) | File search (this lesson) |
|---|---|---|
| Best for | A small number of specific documents relevant to the current request | A large collection where only a small, unknown-in-advance subset is relevant to any given question |
| Setup | None — attach the file directly to the request | Requires creating a vector store and uploading files ahead of time |
| Cost per request | Scales with the size of the documents sent | Scales with the size of the retrieved passages, not the whole collection |
| The model sees | The entire document (or documents) sent | Only the specific chunks retrieval determined were relevant |
| Typical use | "Answer this question about this specific report" (Unit 7, Lesson 5's project) | "Answer this question, drawing from our entire internal knowledge base" |
A useful rule of thumb: if you already know which one or two documents are relevant to a given question before you ask it, input_file is simpler and avoids retrieval's inherent uncertainty about whether the right passage was actually found. If the relevant document (or even which document) isn't known in advance, and the collection is too large to send in full, file search is the tool built for exactly that situation.
Managing a Vector Store Over Time
A vector store is a persistent resource — documents can be added or removed from it independently of any specific query, letting a knowledge base stay current as underlying documents change.
# Add a newly published document to an existing vector store
with open("updated_travel_policy.pdf", "rb") as f:
client.vector_stores.files.upload(vector_store_id=vector_store.id, file=f)
# Remove an outdated document
client.vector_stores.files.delete(vector_store_id=vector_store.id, file_id="file-abc123")
# List what's currently indexed
files_in_store = client.vector_stores.files.list(vector_store_id=vector_store.id)
for file_entry in files_in_store.data:
print(file_entry.id, file_entry.status)
Treating a vector store as a living resource that needs upkeep — removing a superseded policy document when a new version is published, for instance — matters in practice: file search has no way to know that expense_policy.pdf has since been replaced by updated_travel_policy.pdf unless the outdated file is actually removed from the store, and an outdated document left in an otherwise current knowledge base is a realistic source of a subtly wrong answer that looks just as confident and well-cited as a correct one.
Multiple Vector Stores and Scoped Search
A single request can search across more than one vector store at once, letting an application organize document collections by category (a "Policies" store, a "Product Documentation" store, an "Engineering Runbooks" store) while still supporting a combined query when needed.
policy_store = client.vector_stores.create(name="Policies")
product_docs_store = client.vector_stores.create(name="Product Documentation")
response = client.responses.create(
model="gpt-5.6-terra",
input="What's our return policy, and does the Model X support USB-C charging?",
tools=[{"type": "file_search", "vector_store_ids": [policy_store.id, product_docs_store.id]}],
)
Organizing document collections into separate, purpose-specific vector stores (rather than one large undifferentiated store containing everything) mirrors Unit 8, Lesson 4's guidance on grouping related tools by area of responsibility — it makes it straightforward to scope a specific request to only the relevant subset of documents (a customer support feature might only ever search product_docs_store, while an internal HR tool might only search a separate policies store), rather than always searching the same single undifferentiated collection regardless of what a given feature actually needs access to.
Combining File Search With Structured Outputs
File search's retrieved content can feed into a structured-output request exactly as Unit 7, Lesson 5's PDF question-answering project used input_file content, applying the same tiered-confidence pattern to a much larger underlying document collection.
from pydantic import BaseModel
from enum import Enum
class AnswerConfidence(str, Enum):
HIGH = "high"
PARTIAL = "partial"
NOT_FOUND = "not_found"
class PolicyAnswer(BaseModel):
confidence: AnswerConfidence
answer: str
source_document: str | None
response = client.responses.parse(
model="gpt-5.6-terra",
instructions="Answer using only the retrieved policy documents. If the documents don't address the question, say so.",
input="What's the policy on remote work for new hires?",
tools=[{"type": "file_search", "vector_store_ids": [policy_store.id]}],
text_format=PolicyAnswer,
)
print(response.output_parsed)
This combines three separate techniques from across the course into one request: file search (this lesson) retrieves the relevant passages, client.responses.parse() with text_format (Unit 6) shapes the final answer into a validated structure, and the AnswerConfidence enum (following the same tiered-outcome design Unit 7, Lesson 5 introduced) lets the response honestly represent whether the retrieved documents actually addressed the question, rather than forcing a single confident-looking string regardless of how well-supported the answer actually is.
Tuning Retrieval: Result Count and Filters
File search typically exposes a small number of configuration options controlling how retrieval behaves, worth adjusting deliberately rather than leaving at whatever default applies.
response = client.responses.create(
model="gpt-5.6-terra",
input="What's the policy on expensing client meals?",
tools=[{
"type": "file_search",
"vector_store_ids": [policy_store.id],
"max_num_results": 3,
"filters": {"type": "eq", "key": "department", "value": "finance"},
}],
)
Note: The exact set of supported configuration options for file search (
max_num_results, metadatafilters, and any others) and their exact syntax can vary by SDK version. Confirm current options against your installed SDK version's documentation.
max_num_results bounds how many retrieved chunks are fed into the model — a smaller number reduces cost and keeps the model focused on only the most relevant passages, while a larger number gives the model more surrounding context at the expense of additional cost, mirroring the same "more input isn't automatically better" theme Unit 3 raised for prompt construction generally. Metadata filters, where supported, narrow retrieval to documents matching specific attributes (a department, a document type, a date range) attached to files when they were uploaded — useful when a vector store intentionally holds documents from multiple categories but a specific request should only ever draw from one of them, which is a finer-grained alternative to fully separating documents into distinct vector stores as the earlier multi-store example did.
Testing Retrieval-Dependent Logic Without Real Vector Stores
Following this course's established dependency-injection pattern, code that processes a file-search response (extracting citations, checking confidence, and so on) can be tested with a fake response object, without needing a real populated vector store or a real model call.
class FakeAnnotation:
def __init__(self, filename):
self.type = "file_citation"
self.filename = filename
class FakeContentItem:
def __init__(self, annotations):
self.annotations = annotations
class FakeMessageItem:
def __init__(self, content):
self.type = "message"
self.content = content
class FakeResponse:
def __init__(self, output):
self.output = output
def extract_file_citations(response) -> list[str]:
filenames = []
for item in response.output:
if item.type != "message":
continue
for content_item in item.content:
for annotation in getattr(content_item, "annotations", []):
if getattr(annotation, "type", None) == "file_citation":
filenames.append(annotation.filename)
return filenames
def test_extract_file_citations():
fake_response = FakeResponse(output=[
FakeMessageItem(content=[FakeContentItem(annotations=[FakeAnnotation("employee_handbook.pdf")])]),
])
result = extract_file_citations(fake_response)
assert result == ["employee_handbook.pdf"]
print("PASS: extract_file_citations correctly pulls filenames from a fake response")
test_extract_file_citations()
Building this small hierarchy of fake classes mirrors the same testing pattern this course has used for every other tool-augmented response shape (web search in Lesson 1, function calls throughout Unit 8): it lets citation-extraction and response-processing logic be verified deterministically, without depending on a populated vector store, real document content, or a real, potentially non-deterministic model call to actually exercise retrieval.
Common Mistakes
Sending an entire large document collection with input_file instead of using file search, incurring unnecessary cost and risking exceeding practical size limits, when only a small subset of the collection is actually relevant to any given question.
Letting outdated documents remain in a vector store after they've been superseded, producing confident, well-cited answers based on policy or information that is no longer current.
Not surfacing file citations in a user-facing feature, removing the ability to verify a retrieval-based answer against its actual source document.
Using file search for a request where the relevant document is already known in advance, adding retrieval's inherent uncertainty (whether the right passage was actually found) to a case where input_file would have been simpler and more direct.
Best Practices
Use file search for large, evolving document collections, and input_file for a small, already-identified set of documents, matching the tool to the actual scale and certainty of the retrieval problem.
Keep vector stores current by removing superseded documents as they're replaced, rather than letting a knowledge base silently accumulate outdated content alongside current content.
Organize documents into purpose-specific vector stores rather than one large undifferentiated collection, making it straightforward to scope a given feature's search to only the relevant subset of documents.
Surface file citations in any user-facing feature built on file search, and combine file search with structured outputs and a tiered-confidence schema when an application needs to distinguish a well-supported answer from one the retrieved documents didn't actually address.