Contact

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 configured score_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.

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:

FieldRequiredNotes
queryyesThe search text, plain string
corpus_idyes, in practiceCorpus to search. Typed optional, but there is no default-corpus fallback — omitting it is unsupported and currently fails. Always send it.
filtersnoMetadata filters — see below
limitnoMax results. Default 10; caps at 100
score_thresholdnoMinimum cosine score. Results below this are filtered out entirely
score_floornoSoft threshold — results below this are returned but marked below_threshold: true for UI dimming
include_vectorsnoWhen true, each result includes its raw embedding. Large response; use sparingly
granularitynotopic (fastest, broadest), context (balanced), precise (most nuanced), adaptive (multi-stage). Default depends on the corpus
oversample_factornoFetch N × limit internally, re-rank, return limit. Improves quality at the cost of latency
extended_resultsnoWhen true, include per-result diagnostics in the response envelope
hybrid_alphano0.01.0 blend between dense-vector (1.0) and sparse-BM25 (0.0) in hybrid retrieval
resolutionnoFor corpora with Adaptive Resolution, pick the dimension tier: "low", "mid", "high", or explicit dimension count

Always scope the search to a corpus. corpus_id is declared optional at every layer — this schema, the handler, and the SearchRequest proto — 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 --corpus flag 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.

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:

FieldNotes
resultsRanked list, highest score first
search_time_msQdrant-side vector search, excluding the query-embed call
embed_time_msTime spent embedding the query through the corpus’s model
total_candidatesTotal chunks considered before top-K filtering
threshold_appliedThe effective score threshold (may be lower than requested if the voice’s gate was stricter)
results_above_thresholdHow many results exceeded the threshold; count before limit clamping
applied_granularityWhich granularity tier actually served this query (for adaptive corpora)
applied_dimensionsWhich 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/search is 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.