Knowledge base
Each agent has its own knowledge base: manual context, structured Q&A pairs and uploaded PDFs, all folded into the system prompt sent to the LLM on every reply.
Each agent has its own knowledge base: manual context, structured Q&A pairs and uploaded PDFs, all folded into the system prompt sent to the LLM on every reply. For agent-level settings see Agents.
Contents
Ways to add knowledge
All three live on an agent's detail page, under the Knowledge tab (/agents/{id}, tab knowledge).
- Manual context — a free-form text box (
PUT /agents/{id}/context, fieldmanual_context). Saved as-is on the agent row; no chunking, no embeddings. It is always included in full in every system prompt for that agent. - Structured Q&A pairs — a question (up to 2000 characters) and an exact answer (up to 8000 characters), added one at a time (
POST /agents/{id}/qa) and listed for deletion (DELETE /agents/{id}/qa/{qa_id}). Like manual context, every saved pair is always included in full in every system prompt — pairs are not searched or ranked, so keep the list to what genuinely needs an exact, guaranteed answer. - PDF upload — drag a PDF (up to 20 MB,
POST /agents/{id}/documents,multipart/form-data). The backend extracts text per page withpypdf. A scanned PDF with no text layer is saved withstatus: "error"and a message explaining it could not be processed (no OCR is performed). On success (status: "processed"), the extracted text is stored and, best-effort, immediately chunked and embedded for semantic search (see below) — embedding failure never fails the upload, it just means that document falls back to keyword search. Documents can be deleted (DELETE /agents/{id}/documents/{document_id}), which cascades to their stored chunks.
Unlike manual context and Q&A pairs, PDF content is not always included in full — see retrieval below.
How retrieval works
On every reply (apps/api/app/services/knowledge.py), retrieve_knowledge() decides how much of the agent's PDF text to include:
- Small knowledge base (all processed documents' extracted text totals ≤ 45,000 characters): every document is included in full, tagged as
DOCUMENTO: <filename>. - Larger knowledge base (> 45,000 characters total): the documents are split into ~1,800-character chunks (splitting on paragraph boundaries, falling back to a hard cut for oversized paragraphs) and only the most relevant ones are included, up to a 32,000-character budget:
- Semantic search — if the agency has an OpenAI key configured, each chunk's embedding (computed at upload time, model
text-embedding-3-small) is compared to the query's embedding by cosine similarity, and the highest-scoring chunks are selected. - Keyword fallback — if there is no OpenAI key, or a chunk has no stored embedding, chunks are instead ranked by how many query terms (3+ letter words) they contain, and the top-scoring ones are selected.
- Semantic search — if the agency has an OpenAI key configured, each chunk's embedding (computed at upload time, model
Embeddings always use the agency's OpenAI key for this step (resolve_provider_credentials(db, agent.agency_id, "openai")), even when the agent's chat model is configured for Anthropic — an agency with only an Anthropic key configured falls back to keyword search for large knowledge bases.
The selected knowledge text, together with manual context, Q&A pairs and the structured business brief, is assembled by build_system_prompt() into the system message sent to the model — this is the one deliberate exception to the project's English-only-code convention (see CLAUDE.md): the section labels and connective text of the system prompt (e.g. "INSTRUCCIONES PRINCIPALES", "CONOCIMIENTO DOCUMENTAL", "PREGUNTAS FRECUENTES") are written in Spanish rather than English, because this text is customer-facing conversational context sent to the LLM, not developer-facing code.
Storage
knowledge_documents— one row per uploaded PDF:filename, the raw file bytes (file_data), the extracted text (extracted_text), and astatus/error_messagepair.knowledge_chunks— one row per chunk of a document's text:content(the chunk text) andembedding, stored as a plain JSON array of floats in a standard Postgres column — not a vector-typed column.
This is a deliberate simplicity tradeoff, called out directly in the model's source comment: similarity is computed in Python (cosine_similarity() in apps/api/app/services/embeddings.py) instead of in the database, so no pgvector (or any other Postgres extension) is required to self-host Voysse. The tradeoff is that similarity search is O(n) over an agent's chunks rather than index-accelerated — reasonable at the scale of one agent's knowledge base, with a documented escape hatch ("swap to pgvector at scale") if that ever stops being true.
Voysse