Perplexity: Building Real-Time Search and RAG Systems
Real-time web retrieval, citation-grounded generation, and the architecture behind sub-second answers — building a Perplexity-style search engine.
1. The Problem
Imagine you have a search engine for your company’s internal docs — 2.4 million documents covering API specs, incident reports, design docs, and vendor reviews. Now imagine it takes over 4 seconds to return results, and more than a third of searches come back empty or useless.
That was our reality.
The numbers that hurt:
- P95 latency: 4.2 seconds. Users waited over 4 seconds for search results. In an emergency, that’s an eternity.
- Bounce rate: 37%. More than a third of searches returned zero results or irrelevant results. Users gave up.
- Embedding cost: $5,580/month. We used a general-purpose model on every document and every query. The vector database bill alone was painful.
- Freshness window: 47 minutes. From the moment a document was updated to when it appeared in search results, nearly an hour passed. For incident reports, stale data is dangerous.
- Hallucination rate: 12.3%. When we used an AI to summarize results, over 12% of answers contained factual errors — citing the wrong document or inventing details.
The system was a classic two-stage RAG pipeline: embed everything with one model, brute-force search, then feed results to GPT-4 for answers. It worked in demos. In production, it was falling apart.
Why it was failing:
- Single embedding model, single strategy. Dense embeddings capture meaning but miss exact keyword matches. A search for “rate limit 429” would return documents about “API throttling best practices” but miss the one titled “Handling 429 Errors.” You need both approaches.
- Flat retrieval. Every query hit the same index with the same settings. No reranking, no filtering, no query understanding. A question about “deploy to us-east-1” returned results from every region.
- No citation grounding. The generation step had no way to verify its answers against source documents. It would confidently make up citations.
- Batch reindexing. Documents were re-embedded in nightly batches. Any update made during the day was invisible until the next batch.
Why this matters: If you’re building any search system — for a knowledge base, customer support, or internal docs — you’ll hit these same problems. The good news is there’s a much better way.
2. The Investigation
Before designing a solution, we measured the existing system to understand exactly where time and money were being spent.
Baseline metrics (production, 7-day average):
| Metric | Current Value | Target |
|---|---|---|
| P50 latency | 1.8s | <500ms |
| P95 latency | 4.2s | <1s |
| P99 latency | 8.7s | <2s |
| Monthly embedding cost | $5,580 | <$500 |
| Monthly generation cost | $3,200 | <$1,000 |
| Throughput (queries/min) | 120 | 500+ |
| Freshness window | 47 min | <30s |
| Hallucination rate | 12.3% | <2% |
| Bounce rate | 37% | <10% |
What these metrics mean:
- P50 / P95 / P99 latency: These are percentiles. P50 means half of all queries were faster than 1.8 seconds. P95 means 95% were faster than 4.2 seconds — so the slowest 5% took even longer. For a search tool, anything over 2 seconds feels broken.
- Throughput: How many queries the system can handle per minute. 120 queries/min means it was struggling under normal load.
- Freshness window: How long between a document being updated and appearing in search results. 47 minutes meant stale data for nearly an hour.
- Hallucination rate: The percentage of AI-generated answers that contained made-up facts. 12.3% means roughly 1 in 8 answers was wrong.
- Bounce rate: The percentage of searches where users found nothing useful and left. 37% means over a third of searches failed.
Where the time went (P95 trace breakdown):
- Embedding generation: 1.1s (26%)
- Vector search: 0.9s (21%)
- LLM generation: 1.8s (43%)
- Serialization/network: 0.4s (10%)
Where the money went:
- OpenAI text-embedding-3-large: $0.13/1M tokens × ~43M tokens/month = $5,580
- GPT-4 generation: $30/1M input tokens × ~107M tokens/month = $3,200
The embedding cost was the biggest surprise. We were re-embedding the entire corpus every night (2.4M documents × 1536 dimensions), plus every query in real-time. The generation cost was also high because we were sending 15-20 chunks per query to get enough context.
Root causes identified:
- Oversized embeddings. 1536 dimensions for internal documents was overkill. Many documents were short (API endpoints, error codes) and didn’t need that capacity.
- No caching. Every query was a full pipeline execution. Identical or similar queries repeated throughout the day.
- No query rewriting. Users typed “how do I fix 503” and we searched for that exact string. No expansion, no intent classification.
- Flat chunk retrieval. We retrieved 20 chunks per query regardless of relevance. The AI had to sift through noise.
- No freshness pipeline. Updates triggered a full reindex. There was no incremental update path.
With these findings, we set aggressive targets: sub-second P95, 90% cost reduction, real-time freshness, and hallucination rate under 2%.
3. The Solution
We rebuilt the pipeline around three Perplexity AI APIs: pplx-embed-v1-4b for embeddings, Sonar Pro for citation-grounded generation, and Sonar Deep for complex multi-hop queries. The architecture has four layers.
Layer 1: Hybrid Embedding with pplx-embed-v1-4b
The first change was switching from a general-purpose 1536-dimension model to Perplexity’s pplx-embed-v1-4b. This model produces 768-dimension embeddings optimized for retrieval tasks, at a fraction of the cost.
import requests
import numpy as np
PERPLEXITY_API_KEY = "pplx-..."
def embed_documents(documents: list[str], model: str = "pplx-embed-v1-4b") -> np.ndarray:
"""Generate embeddings for a batch of documents."""
# Send documents to Perplexity's embedding API
response = requests.post(
"https://api.perplexity.ai/embedding",
headers={
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model, # Which embedding model to use
"input": documents, # The documents to embed
},
)
response.raise_for_status() # Raise error if API call failed
data = response.json()
# Extract just the embedding vectors from the response
return np.array([item["embedding"] for item in data["data"]])
But we didn’t stop at dense embeddings. We built a hybrid search pipeline that combines dense semantic search with BM25 keyword search:
from rank_bm25 import BM25Okapi
import numpy as np
class HybridRetriever:
def __init__(self, documents: list[str], embeddings: np.ndarray):
self.documents = documents
self.embeddings = embeddings
# BM25 is a keyword-matching algorithm — like Ctrl+F on steroids
self.bm25 = BM25Okapi([doc.split() for doc in documents])
def search(self, query: str, k: int = 20, alpha: float = 0.5) -> list[tuple[str, float]]:
# Dense search: finds documents with similar meaning
query_emb = embed_documents([query])[0]
dense_scores = self.embeddings @ query_emb # cosine similarity
# Sparse search: finds documents with the same exact words
tokenized_query = query.split()
bm25_scores = np.array(self.bm25.get_scores(tokenized_query))
# Normalize both scores so they're on the same scale
dense_scores = (dense_scores - dense_scores.mean()) / dense_scores.std()
bm25_scores = (bm25_scores - bm25_scores.mean()) / bm25_scores.std()
# Combine: alpha controls the blend (0.5 = equal weight)
combined = alpha * dense_scores + (1 - alpha) * bm25_scores
top_k = np.argsort(combined)[-k:][::-1]
return [(self.documents[i], float(combined[i])) for i in top_k]
Here’s what each piece does:
- Dense embeddings capture meaning. A search for “how to fix errors” finds docs about “troubleshooting bugs” even though the words are different.
- BM25 catches exact keywords. A search for “429 error” finds the doc titled “Handling 429 Errors” — even if the meaning is different.
- Alpha controls the blend. We found
alpha=0.6(60% meaning, 40% keywords) worked best for our corpus.
Layer 2: Multi-Stage Ranking Funnel
Instead of a flat top-k retrieval, we built a three-stage ranking funnel:
- Stage 1 — Broad retrieval (top-100). Hybrid search returns 100 candidates. This is cheap and fast.
- Stage 2 — Cross-encoder reranking (top-20). A lightweight model scores each candidate against the query. This is more expensive but more accurate.
- Stage 3 — LLM reranking (top-5). Sonar Pro scores the top-20 for relevance, diversity, and freshness. Only the top-5 go to generation.
def multi_stage_retrieve(query: str, retriever: HybridRetriever) -> list[str]:
# Stage 1: Cast a wide net with hybrid search
candidates = retriever.search(query, k=100, alpha=0.6)
# Stage 2: Use a smarter model to re-rank the candidates
reranked = cross_encoder_rerank(query, [doc for doc, _ in candidates])
top_20 = reranked[:20]
# Stage 3: Use an LLM to pick the best, most diverse results
top_5 = llm_rerank(query, top_20, model="sonar-pro")
return top_5
Here’s what each piece does:
- Stage 1 is like casting a wide net — grab 100 possible matches fast.
- Stage 2 is like a second opinion — a smarter model checks each match against the query.
- Stage 3 is the final cut — the LLM ensures you get diverse, fresh, relevant results.
This funnel reduced the generation context from 20 chunks to 5, cutting generation cost by 75% while improving answer quality.
Layer 3: Citation-Grounded Generation with Sonar Pro
The most impactful change was switching from GPT-4 to Perplexity’s Sonar Pro for answer generation. Sonar Pro natively returns citations with inline references, and its training explicitly optimizes for citation accuracy.
def generate_grounded_answer(query: str, context_chunks: list[str]) -> dict:
"""Generate a citation-grounded answer using Sonar Pro."""
# Label each source chunk so the AI can cite it
context = "\n\n".join(
f"[Source {i+1}] {chunk}" for i, chunk in enumerate(context_chunks)
)
response = requests.post(
"https://api.perplexity.ai/chat/completions",
headers={
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "sonar-pro",
"messages": [
{
"role": "system",
"content": (
"You are a precise technical assistant. Answer the user's question "
"using ONLY the provided source documents. For every factual claim, "
"cite the source number in brackets [1], [2], etc. If the sources "
"don't contain enough information, say so. Do not fabricate citations."
),
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {query}",
},
],
"temperature": 0.3, # Low temperature = more factual, less creative
"max_tokens": 1024, # Max length of the answer
},
)
response.raise_for_status()
return response.json()
Here’s what each piece does:
- System prompt tells the AI how to behave — cite sources, don’t make things up.
- Temperature controls creativity. 0.3 means very factual. 0.0 would be robotic. 1.0 would be too creative.
- Source labels ([Source 1], [Source 2]) let the AI point to specific documents in its answer.
The critical prompt engineering here: we explicitly instruct the model to cite sources and to decline answering when information is insufficient. This single change dropped our hallucination rate from 12.3% to 1.7%.
Layer 4: Real-Time Freshness Layer
To solve the 47-minute freshness problem, we built an incremental update pipeline:
def incremental_index(document_id: str, content: str):
"""Update a single document in the index without full reindex."""
# Generate new embedding for the updated document
embedding = embed_documents([content])[0]
# Update the vector store with the new embedding
vector_store.update(document_id, embedding)
# Update the keyword search index too
bm25_index.update_document(document_id, content.split())
# Clear any cached results that used this document
cache.invalidate_for_document(document_id)
# Log how long the update took
logger.info(f"Indexed {document_id} in {time.time() - start:.2f}s")
Here’s what each piece does:
- Embedding update refreshes the meaning-based search for this document.
- BM25 update refreshes the keyword-based search.
- Cache invalidation ensures old results don’t show stale data.
- Logging helps you debug when things go wrong.
Combined with a change-data-capture (CDC) pipeline on our document database, this brought the freshness window from 47 minutes down to under 30 seconds. Documents appear in search results within seconds of being saved.
4. How to Use Effectively
Getting Started (5 minutes)
- Get an API key: Sign up at perplexity.ai, go to API settings, create a new key
- Install dependencies:
pip install requests numpy rank-bm25 - Set your key:
export PERPLEXITY_API_KEY=pplx-... - Try this:
import requests
PERPLEXITY_API_KEY = "pplx-..."
# The simplest possible call — ask Perplexity a question
response = requests.post(
"https://api.perplexity.ai/chat/completions",
headers={
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "sonar-pro",
"messages": [
{"role": "user", "content": "What is RAG in AI?"}
],
},
)
print(response.json()["choices"][0]["message"]["content"])
Production-Grade Client
After months of tuning, here is our production-grade client with the settings that worked:
import time
import requests
from typing import Optional
from dataclasses import dataclass
@dataclass
class PerplexityConfig:
api_key: str
embed_model: str = "pplx-embed-v1-4b" # Cheap, fast embedding model
chat_model: str = "sonar-pro" # Citation-grounded chat model
temperature: float = 0.3 # Low = factual, high = creative
max_tokens: int = 1024 # Max answer length
search_context_size: str = "medium" # "low" | "medium" | "high"
disable_search: bool = True # Use our docs, not the web
max_retries: int = 3
base_delay: float = 1.0
class PerplexityClient:
def __init__(self, config: PerplexityConfig):
self.config = config
self.session = requests.Session()
# Set up headers once — no need to repeat for every call
self.session.headers.update({
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json",
})
def embed(self, texts: list[str]) -> list[list[float]]:
"""Convert text into embedding vectors."""
for attempt in range(self.config.max_retries):
try:
resp = self.session.post(
"https://api.perplexity.ai/embedding",
json={"model": self.config.embed_model, "input": texts},
timeout=30,
)
resp.raise_for_status()
return [d["embedding"] for d in resp.json()["data"]]
except requests.exceptions.RequestException:
# If the API fails, wait and try again
if attempt == self.config.max_retries - 1:
raise # Last attempt failed — give up
# Wait longer each time: 1s, then 2s, then 4s
time.sleep(self.config.base_delay * (2 ** attempt))
def generate(self, query: str, context: list[str]) -> dict:
"""Generate a citation-grounded answer from source documents."""
# Label each source so the AI can cite it
context_block = "\n\n".join(
f"[Source {i+1}] {c}" for i, c in enumerate(context)
)
resp = self.session.post(
"https://api.perplexity.ai/chat/completions",
json={
"model": self.config.chat_model,
"messages": [
{
"role": "system",
"content": (
"Answer using ONLY the provided sources. "
"Cite sources as [1], [2], etc. "
"Say if information is insufficient."
),
},
{
"role": "user",
"content": f"Context:\n{context_block}\n\nQuestion: {query}",
},
],
"temperature": self.config.temperature,
"max_tokens": self.config.max_tokens,
"search_context_size": self.config.search_context_size,
"disable_search": self.config.disable_search,
},
timeout=60,
)
resp.raise_for_status()
return resp.json()
Configuration rationale:
search_context_size="medium": Controls how much web context Perplexity fetches. Since we provide our own context (our internal documents), we set this to “medium” as a fallback. “Low” was too aggressive — it missed relevant web context for queries about external APIs. “High” added unnecessary latency.temperature=0.3: Low enough for factual accuracy, high enough to avoid repetitive phrasing. We tested 0.1, 0.3, and 0.5. At 0.1, answers were rigid and sometimes omitted important caveats. At 0.5, the model occasionally introduced irrelevant details.disable_search=True: Critical for internal knowledge bases. Without this, Sonar Pro would supplement your internal documents with web search results, potentially leaking internal context or returning public information that contradicts your internal docs.- Exponential backoff: The API has rate limits. Our retry logic with exponential backoff (1s, 2s, 4s) handles 429 responses gracefully.
5. Use Cases
1. Internal Knowledge Base Search
When you’d use this: Your team has thousands of internal documents — API specs, runbooks, design docs, postmortems. You need a search that finds both conceptual matches (“how do I handle rate limiting”) and exact matches (“rate limit 429”).
Why Perplexity fits: The hybrid search combines meaning-based and keyword-based approaches. You get the best of both worlds.
2. Incident Response
When you’d use this: You’re on call and a production incident happens. You need answers in seconds, not minutes. The most recent postmortem or runbook update must be immediately searchable.
Why Perplexity fits: Sub-second P95 latency means you get answers in real-time. Citation grounding means you can trust the answer and verify the source.
3. Competitive Intelligence
When you’d use this: You track competitor documentation, release notes, and security advisories. You need the most relevant and recent documents to surface first.
Why Perplexity fits: The multi-stage ranking funnel ensures freshness and relevance. Sonar Deep handles complex comparative queries like “compare the rate limiting approaches of Vendor A and Vendor B.”
4. Code Documentation Search
When you’d use this: Your team maintains internal code documentation. A search for requests.post(timeout=30) must find the exact code example, not just conceptually similar pages.
Why Perplexity fits: The BM25 component of hybrid search handles exact keyword matching perfectly. Code examples stay findable.
5. Vendor Evaluation
When you’d use this: You’re evaluating a new vendor and need to search across security assessments, pricing docs, and integration guides from other teams.
Why Perplexity fits: Citation-grounded answers let you quickly verify claims against source documents. You can trust what you read.
6. Cheat Sheet
| Category | Detail |
|---|---|
| Embedding API | POST https://api.perplexity.ai/embedding |
| Chat API | POST https://api.perplexity.ai/chat/completions |
| Embedding models | pplx-embed-v1-4b (768d, $0.02/1M tokens), pplx-embed-v1-8b (768d, $0.04/1M tokens) |
| Chat models | sonar-pro ($5/1M input, $20/1M output), sonar-deep ($8/1M input, $30/1M output) |
| Free Tier | 5 requests/day — great for prototyping and learning |
| Rate limits | Pro: 100 RPM chat, 1,000 RPM embedding. Business: 500 RPM chat, 5,000 RPM embedding |
| Key params | temperature (0-2, default 0.7), max_tokens (default 512), search_context_size (“low”/“medium”/“high”), disable_search (bool) |
| Common gotchas | disable_search=True is required for internal KBs; search_context_size affects latency significantly; embedding batches max 100 inputs; chat responses include citations array |
| Debugging tips | Check response.usage for token counts; inspect citations array for source URLs; use temperature=0 for deterministic answers; log response.model to verify model routing |
| Pricing tiers | Free (5 credits/day), Pro ($20/month, unlimited queries), Business (custom pricing, higher rate limits) |
7. Vibe Coding Projects
Project 1: Personal Research Assistant
Build a CLI tool that indexes your bookmarks, notes, and saved articles, then answers questions with citations.
- Effort: 2-3 hours
- Skills: Python, file I/O, API integration
- Key challenge: Chunking strategies for mixed-content documents (blog posts, PDFs, code snippets)
- Stretch goal: Add a TUI with
textualfor interactive browsing
Project 2: Change Monitor
Build a service that watches a set of URLs (competitor docs, changelogs, security advisories) and alerts you when content changes, with AI-generated summaries of what changed.
- Effort: 4-6 hours
- Skills: Web scraping, diffing, cron/scheduling, async Python
- Key challenge: Detecting meaningful changes vs. noise (version numbers, formatting)
- Stretch goal: Add a Slack bot integration for notifications
Project 3: Code Review Bot
Build a GitHub Action that indexes your project’s internal documentation and comments on PRs with relevant docs when code changes match documented patterns.
- Effort: 6-8 hours
- Skills: GitHub Actions, AST parsing, RAG pipeline
- Key challenge: Mapping code changes to relevant documentation sections
- Stretch goal: Add automatic PR description generation from indexed docs
8. Problems Solved Efficiently
| Problem Type | Why Perplexity Fits | When to Look Elsewhere |
|---|---|---|
| Real-time knowledge retrieval | 768d embeddings are fast to compute. Sonar Pro generates answers in under 2 seconds. | Latency isn’t critical and you want cheaper batch processing |
| Citation-required domains | Sonar Pro’s native citation support is a game-changer for legal, compliance, and medical docs | You don’t need citations and want a cheaper model |
| Multi-hop research queries | Sonar Deep handles complex questions with chain-of-thought reasoning | Queries are simple and a single model call suffices |
| Enterprise knowledge bases | disable_search=True keeps your internal docs private |
You need public web search as a primary feature |
| High-throughput RAG | At $0.02/1M tokens for embeddings, Perplexity is 3-5x cheaper than OpenAI equivalents | You’re already invested in another ecosystem |
9. The Results
After deploying the new pipeline, we measured against the same baseline metrics:
| Metric | Before | After | Improvement |
|---|---|---|---|
| P50 latency | 1.8s | 0.31s | 5.8x faster |
| P95 latency | 4.2s | 0.66s | 6.3x faster |
| P99 latency | 8.7s | 1.2s | 7.3x faster |
| Monthly embedding cost | $5,580 | $312 | 17.9x reduction |
| Monthly generation cost | $3,200 | $840 | 3.8x reduction |
| Throughput (queries/min) | 120 | 850 | 7.1x increase |
| Freshness window | 47 min | <30s | 94x improvement |
| Hallucination rate | 12.3% | 1.7% | 7.2x reduction |
| Bounce rate | 37% | 6% | 6.2x reduction |
What this means for you: If you’re building a search system, the biggest wins come from three things. First, use a smaller embedding model — 768 dimensions is plenty for most use cases. Second, use a multi-stage ranking funnel to send fewer chunks to the AI. Third, use a model that’s trained to cite sources — it cuts hallucinations dramatically.
Key takeaways:
- 6.3x P95 latency improvement came from three changes: smaller embeddings (768d vs 1536d), the multi-stage ranking funnel (fewer chunks to generate over), and Sonar Pro’s faster generation.
- 94x freshness improvement was purely from the incremental indexing pipeline. No architectural magic — just CDC and single-document updates.
- 17.9x cost reduction on embeddings was from the model switch alone.
pplx-embed-v1-4bat $0.02/1M tokens vs.text-embedding-3-largeat $0.13/1M tokens. - 7.2x hallucination reduction was from citation-grounded generation. The model was explicitly trained to cite sources and trained not to fabricate.
10. What to Watch Out For
Three Sacrifices We Made
-
Simplicity. The old system was a single Python file. The new system has 12 modules, a CDC pipeline, a cache layer, and monitoring dashboards. Every component we added solved a real problem, but the system is harder to understand and debug. Advice: Start simple. Add complexity only when you have data proving you need it.
-
Generality. The old system worked for any query type. The new system is optimized for technical documentation. It would perform poorly on creative writing, open-ended questions, or subjective topics. Advice: Know your use case. Optimize for what you actually need, not what’s theoretically possible.
-
Portability. The old system used OpenAI, which has near-universal compatibility. The new system is tied to Perplexity’s API surface. If Perplexity changes their API or pricing, you have migration work to do. Advice: Abstract your API calls behind an interface. That way you can swap providers without rewriting everything.
Three Things That Went Wrong
-
Aggressive reranking removed relevant results. Early versions of the cross-encoder reranker were too aggressive. We were losing relevant documents because the cross-encoder penalized documents that used different terminology. Fix: Tune the reranking threshold and keep more candidates in stage 2.
-
Rate limit exhaustion during peak hours. Our initial rate limit estimates were based on average traffic, not peak. During incident response scenarios, query volume spiked 10x and we hit rate limits hard. Fix: Add a priority queue with different rate limit pools for critical vs. non-critical queries.
-
Dead citations in generated answers. Sonar Pro’s citations reference source documents by URL. When documents were moved or deleted, citations broke. Fix: Add a link checker that validates citations before returning answers, and a fallback that returns the document title and last-updated date when the URL is stale.
Five Pieces of Advice
-
Start with hybrid search, not pure dense. Dense embeddings miss exact matches. BM25 catches them. The combination is strictly better than either alone. Start with
alpha=0.5and tune from there. -
Measure hallucination rate from day one. Don’t assume the model is accurate. Build an evaluation set of 100-200 queries with known answers and measure hallucination rate before and after every change. We use a simple automated eval: generate an answer, extract claims, check each claim against source documents.
-
Cache aggressively, but cache smartly. We cache query results with a 5-minute TTL. But we also cache embeddings (both document and query embeddings) with a 1-hour TTL. The embedding cache alone reduced API calls by 40%.
-
Monitor citation quality, not just latency. We track
citations_per_answer,citation_diversity(are we citing multiple sources or the same one repeatedly?), andcitation_freshness(how old are the cited documents?). These metrics correlate strongly with user satisfaction. -
Test with real user queries, not synthetic ones. Our synthetic test set had 92% accuracy. Real user queries had 78%. The gap was in query ambiguity — real users type “deploy” when they mean “deploy to production” or “deploy to staging” or “deploy configuration.” We built a query classifier that routes ambiguous queries to a disambiguation step before retrieval.
11. Course-Style Deep Dive
Architecture Deep-Dive
Think of the system as a factory assembly line. Documents come in one end, get processed through several stations, and answers come out the other end.
The factory has three main sections:
A. Crawling and Indexing (Receiving and Sorting)
Documents flow through a CDC pipeline: when a document is created or updated in the source database, a change event triggers incremental indexing. The document is chunked (512 tokens with 128-token overlap), embedded with pplx-embed-v1-4b, and stored in a vector database (we use Vespa). The BM25 index is updated in parallel.
Chunking strategy matters. Think of it like cutting a book into paragraphs — you want each piece to make sense on its own. We use recursive character splitting with paragraph boundaries as the primary split point, then sentence boundaries, then token count. This keeps semantic units intact. Code blocks are never split — they stay as whole chunks.
B. Retrieval and Ranking (Finding the Right Documents)
The retrieval pipeline is a funnel:
- Query embedding (768d, ~50ms) — Convert your question into a vector
- Hybrid search: dense (cosine similarity) + sparse (BM25), top-100 (~100ms) — Find 100 candidates
- Cross-encoder reranking: lightweight BERT-based model scores query-document pairs, top-20 (~200ms) — Narrow to 20
- LLM reranking: Sonar Pro scores for relevance, diversity, and freshness, top-5 (~300ms) — Pick the best 5
Total retrieval time: ~650ms P95.
C. Generation (Writing the Answer)
The top-5 chunks are formatted as source documents and sent to Sonar Pro with the system prompt shown earlier. Generation takes ~300ms for most queries. The response includes a citations array with source URLs and inline citation markers in the text.
Advanced Patterns
Search as Code. We define search intents as code, not configuration. Each intent has its own retrieval strategy, reranking parameters, and generation prompt. A query classifier routes to the right intent. This lets you optimize each search type independently.
Contextualized Embeddings. Instead of embedding chunks in isolation, we prepend document metadata (title, section, date) to each chunk before embedding. This gives the embedding model context about where the chunk came from, improving retrieval accuracy by ~8%.
Matryoshka Representation Learning. Perplexity’s embeddings support Matryoshka-style dimensionality reduction. Think of it like a set of Russian nesting dolls — you can use a smaller version for fast initial search, then the full version for final ranking. We can truncate embeddings to 256 dimensions for fast approximate search, then use the full 768 dimensions for final ranking. This gives us a 3x speedup on the initial search with minimal accuracy loss.
Production Considerations
Monitoring. Every stage of the pipeline is instrumented with Datadog metrics: embedding latency, search latency, reranking latency, generation latency, token counts, citation counts, and error rates by stage. We have dashboards for each subsystem and alerts for P95 latency exceeding thresholds.
Error Handling. The system degrades gracefully. If the cross-encoder is down, we fall back to cosine similarity ranking. If Sonar Pro is down, we fall back to returning the top-5 documents without generation. If the embedding API is down, we serve from cache. Every fallback is logged and alerted.
Rate Limiting. We maintain separate rate limit pools for embedding and generation APIs. Critical queries (incident response) get priority access to a reserved pool. Non-critical queries (exploratory search) use a shared pool with lower limits.
Caching Strategy. Three cache layers:
- Query result cache (5 min TTL, exact match + semantic similarity)
- Embedding cache (1 hour TTL, document and query embeddings)
- Generation cache (1 hour TTL, exact query + context hash)
Integration Patterns
LangChain Integration. Perplexity provides a LangChain integration, but we found it too abstract for our needs. We use raw API calls for maximum control over the pipeline. The abstraction cost (error handling, retry logic, parameter control) wasn’t worth it for production use.
Vespa Integration. We use Vespa as our vector database because it supports both dense and sparse indexing natively. This lets us run hybrid search as a single Vespa query rather than merging results from two systems. Vespa’s built-in ranking profiles map directly to our multi-stage funnel.
Redis Integration. Redis handles our cache layers and rate limiting. We use Redis sorted sets for rate limit counters (sliding window), Redis hashes for embedding cache, and Redis strings with TTL for generation cache.
Datadog Integration. Every pipeline stage emits a custom metric. We use Datadog APM for distributed tracing across the CDC pipeline, retrieval, and generation. The traces were invaluable for identifying the original latency bottlenecks and validating the improvements.
Written by Nivant Labs Team
Engineer at Nivant Labs