Retrieval
This page is the operator's how-to for querying a Raven knowledge base. It covers the public HTTP surface, the request and response shapes, the knobs you can turn, and how to debug a result list that does not look right.
For the why and how of the retrieval pipeline itself — pgvector + PostgreSQL tsvector running in parallel, fused with Reciprocal Rank Fusion, optionally reranked — read Concepts → Hybrid Retrieval first. This page assumes you already know what those words mean.
Raven exposes retrieval through three endpoints:
| Use case | Endpoint | Pipeline |
|---|---|---|
| Get raw chunks ranked by relevance | GET …/knowledge-bases/{kb_id}/search | BM25 only (ts_rank_cd) |
| Get raw chunks ranked by fused vector + BM25 | POST …/knowledge-bases/{kb_id}/hybrid-search | pgvector + BM25 → RRF |
| Ask a question, get a streamed LLM answer with citations | POST /api/v1/chat/{kb_id}/completions | Embed → vector + BM25 → RRF → (optional rerank) → LLM |
The hybrid endpoint is the standalone SearchService.HybridSearch in internal/service/search.go exposed over HTTP. It runs the same RRF fusion as the chat path but stops short of LLM generation — useful when you want to plug Raven's retrieval into a downstream pipeline (a custom LLM, an analytics dashboard, a Python notebook) without paying for token generation. The chat completions endpoint still owns the full embed → retrieve → rerank → generate flow that lives in ai-worker.
Hybrid search endpoint
Source: internal/handler/search.go (SearchHandler.HybridSearch), mounted in cmd/api/main.go:
POST /api/v1/orgs/{org_id}/workspaces/{ws_id}/knowledge-bases/{kb_id}/hybrid-searchUnlike /search, this is a POST with a JSON body so callers can supply a pre-computed query embedding for the vector leg. Raven's Go API service does not embed text itself (embedding lives inside the Python ai-worker), so to get a true hybrid response the caller must embed the query upstream (typically by calling the same provider that ingestion used) and pass the float vector in. If you omit embedding the request still succeeds — the vector leg is silently skipped and the response degrades to a BM25-only RRF ranking, identical in ordering (modulo k) to what /search returns.
curl -s -X POST \
-H "Authorization: Bearer $RAVEN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "refund window for enterprise customers",
"top_k": 10,
"embedding": [0.0123, -0.0456, /* … 1536 dims … */]
}' \
"https://api.example.com/api/v1/orgs/$ORG/workspaces/$WS/knowledge-bases/$KB/hybrid-search"Request body
| Field | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The user query. Trimmed; an empty or whitespace-only value returns 400. |
top_k | int | no | Top-K after fusion. 0/omitted uses retrieval.default_limit (default 10). Values above retrieval.max_limit (default 100) are clamped server-side, not rejected. |
filters | map<string,string> | no | Free-form bag, mirroring the chat-completions shape. Today these are not pushed into the SQL — they exist so the same client code can target both endpoints. |
doc_ids | string[] | no | Restrict to specific document IDs. Currently informational; document-scoped hybrid filtering is on the roadmap (see Filters). |
embedding | float[] | no | Pre-computed query embedding, length = the dimension the KB was ingested with (1536 for the default OpenAPI provider). Empty / omitted disables the vector leg. |
Response
The response carries the full HybridSearchResult shape — both leg scores, both ranks, and the fused RRF score, exactly as the chat pipeline sees them internally:
{
"query": "refund window for enterprise customers",
"top_k": 10,
"results": [
{
"chunk_id": "01J...",
"org_id": "01H...",
"knowledge_base_id": "01H...",
"document_id": "01J...",
"content": "Refunds are processed within 14 days...",
"chunk_index": 7,
"heading": "Refunds & cancellations",
"chunk_type": "text",
"created_at": "2026-04-19T11:22:01Z",
"vector_score": 0.8521,
"bm25_score": 12.34,
"rrf_score": 0.0312,
"vector_rank": 1,
"bm25_rank": 3
}
]
}top_k in the response is the effective top_k after server-side clamping — useful for detecting that you asked for 999 and got 100.
Search endpoint
Source: internal/handler/search.go, mounted in cmd/api/main.go:
GET /api/v1/orgs/{org_id}/workspaces/{ws_id}/knowledge-bases/{kb_id}/searchThis is a GET with query parameters — not a POST with a JSON body. It runs PostgreSQL full-text search (to_tsvector('english', …) over the heading and content columns, ranked with ts_rank_cd) inside the requesting org's RLS scope.
curl -s \
-H "Authorization: Bearer $RAVEN_TOKEN" \
--get "https://api.example.com/api/v1/orgs/$ORG/workspaces/$WS/knowledge-bases/$KB/search" \
--data-urlencode "q=refund window for enterprise customers" \
--data-urlencode "limit=20"To narrow the search to specific documents, repeat the doc_ids parameter:
curl -s \
-H "Authorization: Bearer $RAVEN_TOKEN" \
--get "https://api.example.com/api/v1/orgs/$ORG/workspaces/$WS/knowledge-bases/$KB/search" \
--data-urlencode "q=refund window" \
--data-urlencode "doc_ids=01H..." \
--data-urlencode "doc_ids=01J..."Request
The search endpoint takes three query parameters (see SearchHandler.Search):
| Field | Type | Default | Notes |
|---|---|---|---|
q | string | required | The user query. Trimmed and whitespace-collapsed by sanitizeQuery. |
limit | int | 10 | Top-K. Clamped to [1, 100] by clampLimit. |
doc_ids | repeated string | — | Restrict to specific document IDs. Maps to TextSearchWithFilters in the service. |
The chat-completions endpoint takes a JSON body. From internal/model/chat.go:
{
"query": "what is the refund window?",
"session_id": "01J3...",
"model": "claude-3-7-sonnet-20250219",
"provider": "anthropic",
"filters": { "rerank": "cohere" },
"stream": true
}| Field | Type | Required | Notes |
|---|---|---|---|
query | string | yes | The user message. |
session_id | string | no | Resume an existing chat session; omit to start a new one. |
model | string | no | Provider-specific model id; falls back to the org's default. |
provider | string | no | anthropic, openai, etc. Falls back to the org's default. |
filters | map<string,string> | no | Free-form bag. Today only rerank=cohere is honoured (see Reranking). |
stream | bool | no | Whether to stream; the handler always returns SSE today. |
Two server-controlled fields are never read from the body and are populated from the request context: UserID (from the SuperTokens JWT sub claim) and ConversationSessionID (cross-channel memory id). Sending them in the client body has no effect.
Response
/search returns a JSON SearchResponse (internal/model/search.go):
{
"total": 3,
"results": [
{
"id": "01J...",
"org_id": "01H...",
"knowledge_base_id": "01H...",
"document_id": "01J...",
"source_id": "01J...",
"content": "Refunds are processed within 14 days...",
"chunk_index": 7,
"token_count": 184,
"page_number": 3,
"heading": "Refunds & cancellations",
"chunk_type": "text",
"created_at": "2026-04-19T11:22:01Z",
"rank": 0.4187
}
]
}The rank is the raw ts_rank_cd score — it is not normalised and is only comparable within a single response.
The hybrid pipeline (run from the chat endpoint) builds richer results internally as HybridSearchResult. Each carries both leg scores plus the fused score, which is what makes the pipeline debuggable end to end:
| Field | Source |
|---|---|
vector_score | 1 - (embedding <=> query) — cosine similarity in [0, 1] |
bm25_score | ts_rank_cd(...) |
rrf_score | Σ 1 / (k + rank_i) across both lists, k = 60 |
vector_rank | 1-based position in the vector candidate list (or 0 if absent) |
bm25_rank | 1-based position in the BM25 candidate list (or 0 if absent) |
For the chat endpoint, only a flattened subset surfaces in the SSE source events — document_id, document_name, chunk_text (first 500 chars), and score. To see the full hybrid result shape from outside the worker, call the /hybrid-search endpoint — it returns the unflattened HybridSearchResult array verbatim.
Filters
What the codebase supports today:
- By document ID —
doc_idson/search. Implemented viaSearchRepository.TextSearchWithFilters. - By knowledge base — path parameter (
{kb_id}). The hybrid pipeline inai-workeraccepts multiple KBs viakb_idsin the gRPCRAGRequest, but the public REST endpoint binds a single KB from the URL. - By rerank model —
filters.rerank = "cohere"on chat completions.
What is planned but not wired:
- Filter by tag, source type, or
created_atwindow. Thechunkstable carrieschunk_type,source_id, andcreated_at, but the SQL ininternal/repository/search.godoes not yet expose them as filter predicates. If you need this today, post-filter the response bysource_id/chunk_typeclient-side. - Free-form metadata key/value filters. The data model supports it (each chunk has a
metadata jsonbcolumn), but neither retriever pushes a predicate through.
Reranking
The chat endpoint optionally runs a cross-encoder rerank on top of the RRF-fused candidates. Opt in via the filters map:
{ "query": "...", "filters": { "rerank": "cohere" } }The implementation lives in ai-worker/raven_worker/retrieval/reranker.py. Defaults:
- Model:
rerank-english-v3.0. - SDK:
cohere.AsyncClientV2. - Output size: top 5 chunks (
top_n=5).
Reranking is BYOK. The worker pulls the org's Cohere API key from llm_provider_configs. If no key is configured, or the cohere package is missing, or the API call fails, the worker logs the failure and falls back to the RRF-fused list — the request never errors because of a rerank failure.
Reranking is skipped entirely when:
filters.rerankis unset or not"cohere".- No Cohere key is registered for the org.
- The candidate list is empty.
The synchronous Go HybridSearch service never reranks — see the TODO: Rerank placeholder comment at the bottom of internal/service/search.go.
Chat (RAG over search)
The chat endpoint is the public face of the full RAG pipeline. Source: internal/handler/chat.go, mounted twice in cmd/api/main.go:
| Auth | Route | Audience |
|---|---|---|
| Session (SuperTokens) | POST /api/v1/orgs/{org_id}/workspaces/{ws_id}/knowledge-bases/{kb_id}/completions | Dashboard users |
| API key | POST /api/v1/chat/{kb_id}/completions | Embeddable widgets / 3rd-party apps |
Both call the same handler and return text/event-stream. The pipeline itself runs in the Python worker (ai-worker/raven_worker/services/rag.py) and is the canonical 8-step flow:
- Exact-match response cache lookup in Valkey (per-KB).
- Embed the query with the org's BYOK embedding provider.
- Vector cosine search + BM25 full-text search in parallel (
asyncio.gather). - Merge with Reciprocal Rank Fusion (k = 60).
- Optional Cohere rerank (see above).
- Build a numbered context block from the top chunks.
- Stream LLM tokens back as
RAGChunkproto messages. - Store the completed answer in the response cache.
Example call against the public API-key route, consuming SSE with curl:
curl -N \
-H "Authorization: Bearer $RAVEN_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"query": "What is our refund window for enterprise customers?",
"session_id": "01J3PZS...",
"filters": { "rerank": "cohere" }
}' \
"https://api.example.com/api/v1/chat/$KB/completions"The stream emits SSE frames in the form:
event: token
data: {"text":"Refunds "}
event: token
data: {"text":"are processed "}
event: sources
data: {"sources":[{"document_id":"01J...","document_name":"Billing Policy","chunk_text":"Refunds are processed within 14 days…","score":0.0312}]}
event: done
data: {}To pull message history for a previous chat session:
curl -s -H "Authorization: Bearer $RAVEN_API_KEY" \
"https://api.example.com/api/v1/chat/$KB/sessions/$SESSION_ID/history?limit=50"For cross-channel conversation memory (chat + voice + WebRTC fused into one history), the dashboard route is GET /api/v1/orgs/{org_id}/kbs/{kb_id}/conversations — see internal/handler/conversation.go.
Debugging a bad search
There is no debug: true flag on the HTTP endpoints today — the retrievers themselves emit the trace, and the chat handler forwards it to your structured-logs sink (OpenObserve in the default deployment).
Work down this checklist:
- Is BM25 finding the right chunks? Hit
/searchwith the same query — that handler is the pure BM25 leg, same SQL asSearchRepository.BM25Search. If the rawrankranks the wrong chunk first, the problem is in ingestion (tokenisation, missing heading, wrong source attribution), not retrieval. - Read the structured logs. The worker emits one line per stage:
vector_search_start/done,bm25_search_start/done,chunks_fetched,rerank_applied,cache_hit/miss. Each is tagged withorg_id,kb_ids,session_id,query_length. - Compare ranks across legs.
HybridSearchResultcarries bothvector_rankandbm25_rank. A chunk withvector_rank = 1andbm25_rank = 0is matching purely on semantics; the opposite is keyword-only. A chunk in neither leg cannot be fused into existence. - Inspect the fused score. RRF is bounded above by
2 / (k + 1) ≈ 0.0328for a chunk ranked #1 in both legs (withk = 60). Above ~0.025is a strong dual-leg hit; below ~0.008is mostly single-leg. The testinternal/integration/search_test.goasserts that dual-leg chunks outrank single-leg ones. - Sanity-check the embedding. If
vector_rankis consistently 0 for chunks you expect to match semantically, checkembedding_dimsin thevector_search_startlog — it should be 1536.
Hard limits
These are baked into the Go service and you cannot override them per request without a code change. From internal/service/search.go:
| Constant | Value | Effect |
|---|---|---|
defaultSearchLimit | 10 | Top-K applied when the caller omits limit. |
maxSearchLimit | 100 | Hard cap on limit. Larger values are clamped. |
rrfK | 60 | Smoothing constant in the RRF formula — value from the original Cormack et al. (2009) paper. |
candidateK | topK * 3 | Each leg fetches topK * 3 candidates, capped at maxSearchLimit, so RRF sees enough overlap. |
The rerank top_n (in services/rag.py) is hardcoded to 5. The response cache TTL (_CACHE_TTL_SECONDS) is 3600 (1 hour, exact-match per-KB).
Tuning
The constants above are deliberately not runtime knobs. RRF is parameterless by design, and candidateK was picked to give RRF enough overlap signal without paying query latency no one benefits from.
In practice, "tuning retrieval" on Raven means tuning what is exposed:
- Chunk size and overlap. Bad chunks dominate every other knob. Look at chunk boundaries first — see Guides → Ingestion.
- Source quality. A keyword in 80% of your chunks contributes almost no BM25 signal. Tighten the source set or split generic documents into smaller, topic-scoped sources.
- Headings. BM25 searches
coalesce(heading, '') || ' ' || content. Descriptive headings rank dramatically better — make sure ingestion preserves them. - Embedding provider. Switching to a domain-specific model moves the needle more than any RRF parameter could. See Guides → LLM providers.
- Reranking. For end-user chat, set
filters.rerank = "cohere". The latency cost (~100–400 ms) is usually worth the top-5 precision.
If you find yourself wanting to tune rrfK or candidateK, open an issue first — the real fix is usually upstream.
See also
- Concepts → Hybrid Retrieval — the algorithm, the SQL, the benchmarks.
- Guides → Ingestion — chunking, sources, what makes retrieval work or not work.
- API Overview — the canonical OpenAPI surface.