Search a library of PDFs
By the end of this tutorial you will have a knowledge collection built from your own PDFs that answers a question with the exact passages that match it.
Four steps:
Every step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
-
A credential. A
nat_sk_…API key (or a session JWT from Auth) exported asNATURALI_TOKEN, and your client set up — the CLI, the SDK or plaincurl:export NATURALI_TOKEN=nat_sk_...export NATURALI_API=https://api.naturali.ai/v1 # curl examples only -
A project. Create a provider builds one — do it first if you haven't, and arrive with its id exported:
export PROJECT=proj_V1StGXR8Z5jdHi6Bexport NATURALI_PROJECT=$PROJECT # lets the CLI omit --project-id -
A PDF with a text layer, in the working directory as
manual.pdf. Any born-digital PDF works — one exported from a word processor, a datasheet, a contract. A scanned PDF is a picture of text with nothing to extract; that one needs a converter.
1. Create the collection
A collection is the named library your documents live in, and the unit retrieval is scoped to.
- CLI
- SDK
- curl
naturali create-knowledge-collection \
--project-id "$PROJECT" \
--name manuals \
--description 'Product manuals the support agent works from.'
const { data: collection } =
await naturali.knowledge.createKnowledgeCollection({
path: { project_id: PROJECT },
body: {
name: 'manuals',
description: 'Product manuals the support agent works from.',
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/collections" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "manuals",
"description": "Product manuals the support agent works from."
}'
{
"id": "kcol_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "manuals",
"description": "Product manuals the support agent works from.",
"document_count": 0
}
Export the id — every later step needs it:
export COLLECTION=kcol_V1StGXR8Z5jdHi6B
2. Upload a PDF into it
Send the bytes base64-encoded, with content_type: application/pdf. That
media type is what tells the platform to parse it as a PDF rather than treat it
as text.
The default page chunk strategy makes one
chunk per page, so a retrieved passage can cite the page it came from.
- CLI
- SDK
- curl
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--filename manual.pdf \
--content-type application/pdf \
--file "$(base64 -w0 manual.pdf)"
import { readFileSync } from 'node:fs';
const { data: document } = await naturali.knowledge.createKnowledgeDocument({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: {
filename: 'manual.pdf',
content_type: 'application/pdf',
file: readFileSync('manual.pdf').toString('base64'),
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"filename\": \"manual.pdf\",
\"content_type\": \"application/pdf\",
\"file\": \"$(base64 -w0 manual.pdf)\"
}"
{
"id": "doc_V1StGXR8Z5jdHi6B",
"collection_id": "kcol_V1StGXR8Z5jdHi6B",
"filename": "manual.pdf",
"content_type": "application/pdf",
"status": "pending",
"error": null,
"chunk_count": null,
"content": null
}
export DOCUMENT=doc_V1StGXR8Z5jdHi6B
The limit is on the decoded bytes, not the base64 you send. A larger file
comes back 413 file_too_large with its actual size; split it, or drop the
scanned images it is carrying.
Denser pages? Use size chunks
One chunk per page is too coarse when a page holds a lot of unrelated facts —
the whole page becomes one embedding and retrieval blurs. size splits the
text into fixed-width character windows instead. The trade-off is that windows
are not page-aligned, so they carry no page number:
- CLI
- SDK
- curl
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--filename manual.pdf \
--content-type application/pdf \
--chunk-strategy size \
--chunk-size 800 \
--chunk-overlap 150 \
--file "$(base64 -w0 manual.pdf)"
await naturali.knowledge.createKnowledgeDocument({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: {
filename: 'manual.pdf',
content_type: 'application/pdf',
chunk_strategy: 'size',
chunk_size: 800,
chunk_overlap: 150,
file: readFileSync('manual.pdf').toString('base64'),
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"filename\": \"manual.pdf\",
\"content_type\": \"application/pdf\",
\"chunk_strategy\": \"size\",
\"chunk_size\": 800,
\"chunk_overlap\": 150,
\"file\": \"$(base64 -w0 manual.pdf)\"
}"
Start with page and switch only if step 4 retrieves poorly.
3. Wait for it to be indexed
Ingestion — extract, chunk, embed — runs in the background, so the document
came back pending. Read it until status is indexed; chunk_count then
tells you how many passages it produced.
- CLI
- SDK
- curl
naturali get-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--document-id "$DOCUMENT"
const { data: indexed } = await naturali.knowledge.getKnowledgeDocument({
path: {
project_id: PROJECT,
collection_id: COLLECTION,
document_id: DOCUMENT,
},
});
console.log(indexed.status, indexed.chunk_count);
curl -sS \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION/documents/$DOCUMENT" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{
"id": "doc_V1StGXR8Z5jdHi6B",
"status": "indexed",
"chunk_count": 12,
"error": null,
"content": "X1000 Printer Quick Guide\nThe paper tray holds 250 sheets. …"
}
Rather than poll, subscribe a webhook to
knowledge.document_ingested and knowledge.ingest_failed — the same
transition, pushed.
A status of failed names the reason in error. The usual cause for a PDF
is no text layer at all, which is the
scanned-PDF fallback
case; fix the cause and retry with
POST /v1/projects/{project_id}/knowledge/collections/{collection_id}/documents/{document_id}:reingest.
4. Query it
Ask the collection a question. Retrieval runs at the chunk level, so each result names the document it came from and how strongly it matched — which is what lets an answer cite its source.
- CLI
- SDK
- curl
naturali query-knowledge-collection \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--query 'How many sheets does the paper tray hold?' \
--top-k 3
const { data: hits } = await naturali.knowledge.queryKnowledgeCollection({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: { query: 'How many sheets does the paper tray hold?', top_k: 3 },
});
for (const chunk of hits.data) {
console.log(chunk.score, chunk.document_id, chunk.content);
}
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION:query" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "query": "How many sheets does the paper tray hold?", "top_k": 3 }'
{
"data": [
{
"document_id": "doc_V1StGXR8Z5jdHi6B",
"chunk_id": "dchunk_V1StGXR8Z5jdHi6B",
"score": 0.78,
"content": "X1000 Printer Quick Guide\nThe paper tray holds 250 sheets. …"
}
]
}
The passage that answers the question comes back with its document_id and
score. That is the library working: add more PDFs to the same collection with
step 2 and they join the same index — no reconfiguration, no re-chunking of
what is already there.
If the top hit is off, raise top_k to see what else was close, then re-ingest
with size chunking (step 2) if whole pages are the problem.
What's next
- Ingest images and audio — register a converter so scanned PDFs, screenshots and recordings join the same collection.
- Knowledge — chunking, converters and the ingestion lifecycle in full.
- Webhooks — get the
indexed/failedtransition pushed instead of polling for it.