index a folder, ask it a question, and eval it against labeled questions. pplx-embed-v2-context-9b embeds your chunks either alone or with the rest of their page through one endpoint. The demonstration corpus is the FastAPI documentation, 144 files and 2,683 chunks. The tool reads any folder of .md files, with one caveat the chunking section explains: the character budgets it uses for the API’s token caps were tuned for this corpus.
The v2 models serve both input modes from one checkpoint. Send a flat list of strings and each text is embedded on its own: dense mode, one vector per text. Send a list of lists, where each inner list is the chunks of one document in reading order, and each chunk vector reflects the rest of its document: contextual mode, one vector per chunk, so a section headed “Run it” or “Check it” carries what the page is about. Every request names its purpose with input_type, document for anything you index and query for a question. The two sizes of the model share one vector space, and both can return shorter vectors, which gives the tool its four configurations: dense at 2048 dimensions, contextual at 2048, contextual at 1024, and contextual at 1024 with the questions embedded by the 0.6b model.
The research behind the contextual mode, Contextual Embeddings Beyond the Gold Chunk, measures retrieval by the share of questions whose answer chunk lands in the top K results. The eval command reports the same idea as recall@k on your questions. The weights are published on Hugging Face as pplx-embed-v2-context-9b and pplx-embed-v2-context-0.6b; the API serves them as pplx-embed-v2-context-9b and pplx-embed-v2-context-0.6b.
Here is what one ask looks like on the FastAPI docs, with the default two configurations, dense and contextual at 2048 dimensions:
tutorial/path-params.md. Dense mode puts the parent section first, “Path parameters containing paths”, which states the problem and hands off to the next section; the section that answers it, “Path convertor” with /files/{file_path:path}, is its sixth result. Contextual mode puts “Path convertor” first and fills the next two places with neighboring sections of the same page. The rest of this page builds the tool, runs it, and shows where the configurations differ.
What you need
- Python 3.12 or newer on macOS or Linux (the pinned numpy release needs 3.12). The shell commands use Unix syntax.
- A Perplexity API key with API credits. Indexing the FastAPI docs sends the corpus three times; the recorded run used 1,815,960 tokens and cost $0.2179 at $0.12 per million tokens for the 9b model. The 0.6b model is $0.02 per million. Both rates were confirmed by direct probes while this page was written, and the indexing total agrees: $0.2179 for 1,815,960 tokens at $0.12 per million. The pricing page is the reference for current rates. Each
askorevalrun embeds only the questions. - httpx and numpy, installed in the next step. The recipe calls the REST endpoint directly because the Perplexity Python library release current at the time of writing (0.46.1) has no
input_typeparameter and does not accept nested input onembeddings.create. curl,tar, andpython3on the PATH to fetch and prepare the sample corpus, or any folder of Markdown files you already have.
Set up
Everything lives in one directory. You create the four files from this page; the corpus is fetched with one command.requirements.txt
requirements.txt
requirements.txt in it, then create a virtual environment, install the packages, and export your key.
Install and set the key
Install and set the key
Fetch a corpus
The sample corpus is the English documentation of FastAPI 0.115.0, from the release tarball. The FastAPI pages do not hold their code examples inline; a line like{!../../../docs_src/path_params/tutorial001.py!} pulls the file in when the site is built. A search index built from the raw pages would have 416 chunks that hold an include path where the code should be, so the second step inlines every include before the folder is indexed:
[ln:a-b] line range when the include names one, and accepts the {!> ...!} form the docs also use. The last line keeps the English docs folder, images and stylesheets included, and removes the rest. The tool reads only the .md files: 144 of them, about 2.2 million characters once the code is inlined, including a tutorial, an advanced guide, deployment pages, reference pages, and a 445 KB release-notes file that is longer than a single contextual document is allowed to be. That last file is why the tool knows how to split a document. This preprocessing is specific to FastAPI; docsearch.py reads plain Markdown and knows nothing about includes.
To use your own docs instead, skip this step and point index at your folder.
Write the tool: docsearch.py
One file, five parts. Each part is below with what it does; the complete file is at the end of the page, in two blocks you append in order.Name the configurations and chunk a folder of Markdown files
AConfig is one column of the evaluation: how the corpus was embedded (mode and dimensions) and which model embeds the questions. The four in CONFIGS share three indexes, because the fourth reuses the contextual 1024 index and only changes the query model. The index is always built with INDEX_MODEL, the 9b model; SMALL_MODEL appears only as a query model. Add a configuration by adding a line.
A chunk is one heading section, heading included, so every chunk starts with its own title. Sections longer than about 1,500 characters are split at blank lines outside code fences, then at line breaks, so no chunk exceeds CHUNK_CHARS; a # inside a code fence is not a heading. A document is one file in reading order, which is the order the contextual mode needs. The API caps a text or a contextual document in tokens, and DOC_CHARS is a character budget for that cap, tuned to this corpus: the first draft used 60,000 and the first contextual request came back as a 400 for a 19,689-token document. If your corpus has fewer characters per token (CJK text, minified code, long link lists), lower it or count tokens with the published tokenizer. A file over the budget becomes consecutive documents named path#2, path#3.
docsearch.py: imports, constants, and chunking
docsearch.py: imports, constants, and chunking
One endpoint, two input shapes
embed builds every request the same way: model, input_type, input, dimensions, and encoding_format. The shape of input selects the mode. A flat list of strings is dense input and the response has one embedding per item in data. A list of lists is contextual input, one inner list per document, and the response nests one data list per document with one embedding per chunk, even for a one-chunk document. The tool sends questions as flat strings with input_type: "query", because a question has no surrounding document; that is the recipe’s choice, not an API rule. Responses are sorted by index at both levels before the vectors are stacked, so row i of the matrix is chunk i whatever order the items arrive in, and a response whose indices are not exactly 0 to n-1 is an error rather than a shifted matrix.
Vectors arrive as base64_int8, signed 8-bit integers, which is the default encoding and the only one besides packed binary; asking for float is a 400. decode turns the string into a numpy int8 vector and checks that its length is the dimensions the request asked for. The tool keeps the vectors as int8 from here on.
batches splits the inputs into requests within three limits: 512 texts or documents per request and 16,000 chunks per contextual request, both from the API reference, and 120,000 tokens per request, checked live. The token cap is enforced as a budget of 240,000 characters, REQUEST_CHARS, sized for this corpus like DOC_CHARS; if the API still returns a 400 for a request over the token cap, lower both (see Troubleshooting). An item that is over a limit on its own raises a ValueError naming the item and the limits instead of going out in a request the API will reject. post retries a 429 or a 5xx with backoff and stops on any other error with the API’s own message, so a rejected parameter is readable. record adds up requests and tokens per model, and cost when the response reports it.
docsearch.py: embed
docsearch.py: embed
Build, save, and load the index
build_index chunks the folder and embeds it once per distinct index in CONFIGS: every chunk alone at 2048 dimensions, every document’s chunks together at 2048, and the same at 1024. Three files go to index/: vectors.npz with one int8 matrix per index, chunks.json with the document, heading, and text of each chunk, and models.json with the model id and the mode and dimensions of each matrix. Int8 keeps 2048 dimensions in 2 KB per chunk and 1024 in 1 KB; the three matrices for 2,683 chunks total 13.7 MB, where float32 would take 55 MB.
An Index is a matrix and the float32 norm of each row. rank orders rows by cosine: an integer dot product divided by the row norms, with the query’s norm left out because it is the same for every row and does not change the order. int8 times int8 summed over 2048 dimensions stays far inside int32, and the division runs in float32. load_index refuses an index built with another model, one missing a matrix a configuration needs, or one whose saved matrices do not have one row per chunk and the recorded dtype and dimensions. embed_queries embeds the questions once per configuration, with that configuration’s query model and dimensions; the small model produces 512 or 1024 dimensions and never 2048, so rank also checks the query against the matrix, and a 1024-dimensional question against the 2048 index is an error rather than a wrong answer.
docsearch.py: index
docsearch.py: index
Ask a question
ask loads the index, embeds the question for every configuration, and prints the top k chunks for the configurations you name with --configs (default: dense-2048 and context-2048), with the file, the heading, and the first 160 characters of the chunk, so you can see what each would hand to a reader or a RAG prompt.
docsearch.py: ask
docsearch.py: ask
Score the configurations on labeled questions
A label is a question, the file that answers it, and a phrase the answering chunk contains, or a list of phrases when the answer appears in more than one form.hit says a chunk answers the question when it comes from that file (any part of a split file counts, and only the #2 part suffix is stripped, so a file named C# guide.md still matches) and contains any of the phrases, case-insensitively. check_labels runs first and stops on a label with no phrase, a repeated question, or a label that matches no chunk in the index, so a typo in a phrase shows up as an error rather than as a zero. evaluate records the rank of the first hit in the top EVAL_DEPTH (100) for each question and configuration, or 0 when there is none. summarize turns the ranks into recall@1, recall@3, recall@5, and MRR@100, which is MRR with every answer below rank 100 counted as zero. print_eval shows the summary and then a per-question table, because the table is where you learn which sections each configuration handles well.
docsearch.py: eval and output
docsearch.py: eval and output
Index the folder
release-notes.md into twelve. Each pass takes ten requests under the 240,000-character budget, thirty in all. The API counted 1,815,960 tokens for the three passes, about 605,000 per pass, and reported $0.2179 in usage.cost, which the tool sums. index/ is 16 MB: 13.7 MB of int8 vectors in vectors.npz and 2.5 MB of chunk text in chunks.json.
Ask it questions
Eachask embeds the question once per configuration and prints the rankings you ask for. Two more from the recorded run, chosen because the modes disagree:
advanced/websockets.md, and neither puts the code first. Dense mode’s first result is the chunk ## WebSockets client, a heading with no body of its own (the content is in its subsections), followed by the “In production” prose and then the heading line of “Create a websocket”. Contextual mode puts that heading line first, then the “Try the WebSockets with dependencies” and “Install WebSockets” sections. The label is the code, @app.websocket, which appears in ten chunks of the page, all of them inlined examples that are mostly an HTML page inside a Python string with the decorator near the end. The first of those is rank 6 for context-2048 and rank 11 for dense-2048, so by the label both configurations miss the top 5 on this question, and a reader who opened the contextual result would be one heading above the code they wanted.
deployment/manually.md and two sections of deployment/concepts.md; the first chunk that shows how to register startup code is the seventh. Contextual mode puts advanced/events.md first: the page’s opening section, which describes code that runs once before the application starts receiving requests but does not yet name the parameter, and then the startup event section with @app.on_event("startup"), which the label accepts, at rank 2. The lifespan parameter, the current way to do this, is in the “Lifespan” section further down the same page.
Score the configurations
questions.jsonl holds 30 questions a FastAPI user might ask, each labeled with the file that answers it and a phrase the answering chunk contains, or a list of phrases where the docs state the answer in more than one way. The questions are written the way people ask them (“How do I turn off the automatic documentation pages?”) rather than with the words of the heading (“Docs URLs”), and the phrases are the API call or the fact a reader is looking for (docs_url, convert_underscores, status_code=201). Every label was checked chunk by chunk against the index: each chunk a label accepts was read against its question, and phrases were tightened where they accepted a chunk that only mentions the topic and widened where they rejected a chunk that answers it.
questions.jsonl
questions.jsonl
Rank of the first answering chunk for each question and configuration
Rank of the first answering chunk for each question and configuration
eval on the same index produced byte-identical output, summary and table.
Read the numbers
context-2048 beats dense-2048 on every summary number: recall@1 0.567 against 0.267, recall@3 0.867 against 0.700, recall@5 0.933 against 0.767, MRR@100 0.724 against 0.493. Per question, the contextual index ranks the answer higher on 15 of the 30, lower on 3, and the same on 12, ten of those ties at rank 1 or 2 for both.
The largest moves: “How do I get the current user from a token in every route?” goes from 14 to 1; the label accepts 25 chunks of tutorial/security/get-current-user.md, the same complete example repeated once per Python version tab. “How do I serve a folder of images at a URL path?” goes from 10 to 2; the one chunk the label accepts, with StaticFiles(directory="static"), says “mount” and “static”, never “images” or “folder”. “How do I run FastAPI behind a proxy that strips a path prefix?” goes from 6 to 1; the label, --root-path, accepts five chunks of advanced/behind-a-proxy.md. The path-convertor question (6 to 1) and the startup question (7 to 2) are the two ask examples above, and the websocket question (11 to 6) is the one where both configurations miss the top 5.
Three questions moved the other way. “How do I apply one dependency to every route in a router?” goes from 1 to 7: the contextual index puts the Global Dependencies pages ahead of tutorial/bigger-applications.md, and they use the same dependencies= argument on the application instead of the router. The recorded ask below shows it. “What status code is returned when request validation fails?” goes from 2 to 5: the only chunk of tutorial/handling-errors.md that contains 422 is the example that overrides the validation handler, so both ranks measure finding that example rather than the fact itself. “How do I turn off the automatic documentation pages?” goes from 3 to 5: the answer, docs_url=None, is one section of tutorial/metadata.md, a page otherwise about titles, versions, and tags.
tutorial/bigger-applications.md within four results; the label is rank 7 in both columns of the table. The large query model keeps three of its four results on global-dependencies.md, including the code with dependencies=[Depends(verify_token)]; the small one spreads across the two dependency pages. Both configurations miss here; the two columns differ only in which chunks fill the four places.
Query a 9b index with the 0.6b model
Thecontext-1024-small column builds nothing new. It reuses the contextual 1024 index and embeds the questions with pplx-embed-v2-context-0.6b, which produces 1024-dimensional vectors in the same space. You would run a search service this way when the index is built once and queried often: the large model embeds the corpus once, and the small model handles the stream of questions, where latency and per-query cost add up. The gap between context-1024 and context-1024-small is the price of that choice on your questions. Here it is 0.533, 0.767, 0.800, and 0.679 against 0.567, 0.833, 0.867, and 0.715: the small query model does better on one question, worse on six, and the same on 23.
1024 or 2048 dimensions
The 9b model returns 1024 dimensions when you ask for them, and the 0.6b model returns 512 or 1024, so 1024 is the one size both models share, and that is why the cross-size column uses it. At 1024, storage halves: 1 KB per chunk instead of 2 KB, 2.7 MB instead of 5.5 MB for this corpus, and a dot product over half as many values. The gap betweencontext-2048 and context-1024 is what the second kilobyte buys on your questions. Here it is 0.567, 0.867, 0.933, and 0.724 against 0.567, 0.833, 0.867, and 0.715: the 1024 index is never better, equal on 26 questions, and worse on four. A 512-dimensional index needs the 0.6b model as the index model, which this tool does not do.
Cautions
Thirty questions show a direction, not a precise number: one question is 0.033 of recall@1, so a gap of one or two questions on a line is within what one author’s question set can produce, and you should write more before you trust it. A label is a substring test, not a judgment, so a label that reads well can accept a chunk that only mentions its phrase; the only cure is to read what it accepts, which the index files make easy. Some labels accept several chunks of their file because the same code is repeated once per Python version tab (Depends(get_current_user) matches 25 chunks), so for those questions the table measures finding the right page and code more than one exact section; 10 of the 30 labels pin a single chunk. And the result is specific to this corpus and this chunker: move the chunk boundaries, or run on docs where sections lean on the surrounding page, and the table will move.
The useful conclusion is the one the tool is built for: on your docs, with your chunker and your questions, you can see which mode, which dimension count, and which query model find the right section, before you commit to a vector database.
Migrate from v1
The v1 models keep working./v1/embeddings serves the v1 dense ids and both v2 ids; /v1/contextualizedembeddings serves only the two v1 contextual ids and returns a 400 for a v2 id, and a v1 contextual id on /v1/embeddings is a 400 too. That was checked for pplx-embed-v1-4b and pplx-embed-context-v1-4b while this page was written. Vectors are not interchangeable across versions: a v2 question scored against a v1 index produces rankings that mean nothing, and load_index refuses an index whose models.json names another model. To move an index to v2, run index again with the v2 model; there is no conversion. The v2 endpoint requires input_type on every request and takes contextual input on /v1/embeddings as nested lists, where v1 used a separate endpoint.
Adapt it to your docs folder
- Your corpus.
python docsearch.py index path/to/docsreads every.mdunder the folder. For other formats, changeload_folderto produce documents (ordered lists ofChunk) however you like; everything after it is format-agnostic. - Your chunker.
chunk_markdownsplits at headings because that is how documentation is written. If your production pipeline chunks by token count, use that here instead, because the result depends on where the boundaries fall. Keep each document’s chunks in reading order; the contextual mode depends on it. - Your questions. Write 30 to 50 the way your users ask them, then record the file and a phrase from the chunk that answers each. Include easy ones. A set made only of hard cases tells you about the hard cases.
- Your configurations. Each
Configline is one column. Drop the ones you do not need to save an embedding pass, or addConfig("dense-1024", "dense", 1024, INDEX_MODEL)to see dense mode at the smaller size. A configuration whose query model isSMALL_MODELhas to use a 1024-dimensional index. - Your vector database. The numpy index is enough to pick a mode, a dimension count, and a chunker. Once you have, store the int8 vectors where your application already keeps its records; the Embeddings quickstart shows that pattern, and the chunk text in
chunks.jsonis what you would hand to a RAG prompt.
Troubleshooting
export PERPLEXITY_API_KEY before running this scriptmeans the key is not exported in the shell that runs the script.FileNotFoundError: index/models.jsonmeansaskorevalran beforeindex, or in a different directory.models.jsonis the first fileload_indexreads.HTTP 400 from the Embeddings API: ...quotes the API’s message. The ones this tool can provoke, in the API’s words:input_type is required for model pplx-embed-v2-context-9b; use query or documentif you edit a request and drop the field;Output dimensions must be one of [1024, 2048], got 512.for adimensionsvalue the model does not serve (the 0.6b model answers with[512, 1024]); andinput_ids length 16922 must be between 1 and max sequence length (16384)for a contextual document over the 9b cap, in which case lowerDOC_CHARS. The 0.6b model reports the same case asDocument 0 has too many tokens: got 34296, maximum is 32768.Any otherencoding_formatgetsencoding format must be one of: base64_int8 base64_binary.Input total size exceeds maximum number of allowed tokens: got 129618, maximum is 120000.means a request passed 120,000 tokens inside the 240,000-character budget. Two causes: text under two characters per token (CJK text, minified code, long link lists), or a contextual request whose documents hold thousands of short chunks, since each chunk costs one extra token. LowerREQUEST_CHARS; if the documents are the cause, lowerDOC_CHARStoo.HTTP 429 ... after 5 attemptsmeans the rate limit held for five tries with backoff up to 16 seconds. Rerunindex.ValueError: item N alone measures ...; the request limits are ...means one document is over a request budget by itself. The folder loader splits documents atREQUEST_CHUNKSandDOC_CHARS, so you see this only if you raisedDOC_CHARSaboveREQUEST_CHARSor changed the loader.RuntimeError: the API returned a N-dimensional vector for a M-dimensional requestmeans the service ignoreddimensions. The 9b model serves 1024 or 2048 and the 0.6b model 512 or 1024.RuntimeError: the API returned indices [...]; expected 0 to Nmeans a response skipped or repeated an item, andRuntimeError: ... embedding count ... does not match chunk countmeans a response had fewer vectors than inputs. Rerunindex.RuntimeError: ... run index againmeans the saved index does not match the constants or itself: another model id, a missing matrix after you added aConfig, or a dtype, dimension, or row-count mismatch betweenvectors.npz,chunks.json, andmodels.json. Rebuild it.N label(s) match no chunk in the indexfromevallists each label whose file and phrase match nothing. Fix the phrase, or rebuild the index if the corpus changed.
Prove it without an API key
The 22 tests run offline against a fake Embeddings API behind an httpx transport. The fake answers both request shapes with shuffled base64 int8 vectors and rejects malformed requests. Its wording for a missinginput_type, an unsupported dimensions value, and an unsupported encoding_format is the live API’s; its other checks (mixed flat and nested input, empty strings, num_chunks on text) are the recipe’s own, not verified API behavior. The tests cover the chunker and the folder loader, every request limit and the shape of every request, retry and error reporting in post, response ordering and decoding, the checks in load_index and rank, including the 0.6b query against each index, and the scoring functions as the Score section describes them. The file is in the next section.
Copy the complete files
requirements.txt is in Set up and questions.jsonl in Score the configurations. The other two files are below, each in two parts so the blocks stay short: expand both parts of a file and append them in order into a single file with that name.
docsearch.py (part 1 of 2)
docsearch.py (part 1 of 2)
docsearch.py (part 2 of 2)
docsearch.py (part 2 of 2)
test_docsearch.py (part 1 of 2)
test_docsearch.py (part 1 of 2)
test_docsearch.py (part 2 of 2)
test_docsearch.py (part 2 of 2)