Search
Enscrive’s search is neural by default — your query is embedded with the corpus’s model, the resulting vector is compared against every stored chunk, and the top matches (by cosine similarity) are returned with their content, metadata, and scores.
Two endpoints serve the two common needs:
POST /v1/search— raw search. Every request specifies its own retrieval parameters explicitly (limit, score threshold, granularity, …).POST /v1/voices/search— voice-tuned search. The voice’s configuredscore_threshold/default_limit/ granularity / hybrid alpha are applied, so you don’t have to re-specify retrieval tuning on every request.
Both endpoints return the same result shape.
Raw search
POST /v1/search
{
"query": "how do I create a corpus?",
"corpus_id": "787cfadf-ef36-46ec-8c23-8341fc2358cb",
"limit": 10,
"score_threshold": 0.3,
"include_vectors": false
}
Fields:
| Field | Required | Notes |
|---|---|---|
query | yes | The search text, plain string |
corpus_id | yes, in practice | Corpus to search. Typed optional, but there is no default-corpus fallback — omitting it is unsupported and currently fails. Always send it. |
filters | no | Metadata filters — see below |
limit | no | Max results. Default 10; caps at 100 |
score_threshold | no | Minimum cosine score. Results below this are filtered out entirely |
score_floor | no | Soft threshold — results below this are returned but marked below_threshold: true for UI dimming |
include_vectors | no | When true, each result includes its raw embedding. Large response; use sparingly |
granularity | no | topic (fastest, broadest), context (balanced), precise (most nuanced), adaptive (multi-stage). Default depends on the corpus |
oversample_factor | no | Fetch N × limit internally, re-rank, return limit. Improves quality at the cost of latency |
extended_results | no | When true, include per-result diagnostics in the response envelope |
hybrid_alpha | no | 0.0–1.0 blend between dense-vector (1.0) and sparse-BM25 (0.0) in hybrid retrieval |
resolution | no | For corpora with Adaptive Resolution, pick the dimension tier: "low", "mid", "high", or explicit dimension count |
Always scope the search to a corpus.
corpus_idis declared optional at every layer — this schema, the handler, and theSearchRequestproto — but type-level optionality is not evidence of behaviour. Nothing resolves a default corpus when it is omitted, and an unscoped search currently returns HTTP 500. The CLI’s--corpusflag is optional for the same reason and should be treated the same way.
Filters
{
"filters": {
"document_id": "doc-abc",
"user_id": "user-123",
"metadata": { "category": "reference", "lang": "en" },
"layer": "primary",
"strategy": "baseline",
"exclude_document_ids": ["doc-xyz", "doc-123"]
}
}
All filter fields are optional. metadata is AND-matched — every provided key must match. Filters apply before scoring, so filtered-out chunks never count against your limit.
exclude_document_ids drops results from the named documents — the complement of document_id. Useful for “things like this paragraph, but not from the doc I’m currently editing.” Combinable with every other filter; empty or omitted is a no-op. Capped at 64 entries — longer lists get a 400, never silently truncated. Unlike the other filters, exclusion is applied by the server after retrieval rather than as part of the vector search, so it’s a best-effort compensation against your limit: if a single excluded document dominates the top of the ranking, the returned set can come back with fewer than limit results.
Voice-tuned search
POST /v1/voices/search
{
"voice_id": "…",
"query": "how do I create a corpus?",
"corpus_id": "…",
"include_vectors": false
}
The voice’s score_threshold, default_limit, and any retrieval-layer tuning are applied automatically. You can still override any field on a per-request basis — the request body takes precedence over the voice’s defaults.
The CLI shortcut is:
enscrive voices search \
--query "how do I create a corpus?" \
--voice-id docs-default \
--corpus <CORPUS_ID>
Response shape
Both endpoints return the same envelope:
{
"results": [
{
"id": "chunk-uuid",
"document_id": "doc-abc",
"corpus_id": "…",
"score": 0.81,
"content": "… the matching chunk text …",
"metadata": { "category": "reference" },
"chunk_index": 3,
"below_threshold": false
}
],
"search_time_ms": 14,
"embed_time_ms": 42,
"total_candidates": 1083,
"threshold_applied": 0.3,
"results_above_threshold": 7
}
Key envelope fields:
| Field | Notes |
|---|---|
results | Ranked list, highest score first |
search_time_ms | Qdrant-side vector search, excluding the query-embed call |
embed_time_ms | Time spent embedding the query through the corpus’s model |
total_candidates | Total chunks considered before top-K filtering |
threshold_applied | The effective score threshold (may be lower than requested if the voice’s gate was stricter) |
results_above_threshold | How many results exceeded the threshold; count before limit clamping |
applied_granularity | Which granularity tier actually served this query (for adaptive corpora) |
applied_dimensions | Which vector dimension tier served this query (for MRL corpora) |
Query embeddings (no retrieval)
POST /v1/query-embeddings
Returns just the embedding vector for a query string — no database lookup. Useful when you want to pre-compute vectors for external reranking, offline analysis, or custom index builds.
{ "query": "how do I create a corpus?", "corpus_id": "…" }
The corpus is needed because the embedding model (and dimensions) are corpus-bound.
Snippet rendering and deep-linking
When enscrive-docs serves a neural-search UI in front of /v1/search, it also renders a 280-character snippet for each result and appends a Text Fragment to the result URL, so clicking a result scrolls the reader straight to the matching passage. That is a client-side convention, not a server behavior — the /v1 response is pure content plus score.
Common patterns
Narrow to a single document: filters.document_id = "…". Useful when you’re pulling chunks for a specific page, for example while rendering a “Read more from this page” sidebar.
Filter by metadata facet: filters.metadata = { "category": "reference" }. Metadata keys come from what you supplied at ingest time.
Exclude a document you’re currently editing: filters.exclude_document_ids = ["doc-being-edited"]. Handy for “find related passages” while writing — you don’t want the search matching itself.
A/B two voices against one query: use POST /v1/voices/compare rather than two separate search calls — one round-trip instead of two, and the server emits the results in a directly comparable envelope.
Rerank externally: request with include_vectors: true and a large limit + oversample_factor, then rerank offline.
What this endpoint does not do
- Stream results —
/v1/searchis request/response. Streaming neural search is not on the public surface. - Search across corpora — a single request hits one corpus. For cross-corpus search, fan out in the client and merge-rank.
- Agent-style RAG orchestration — Enscrive is the retrieval layer. Prompt construction and LLM generation happen in your application code.