Cosift exposes a lot of knobs intentionally — the right answer for a 100k narrow-domain index is different from the right answer for a 10M general-web index. This guide is the compass: when does flipping a given knob actually move retrieval quality.
The default config is a reasonable starting point for a mid-sized general-web corpus. If you've crawled docs.example.com or a single research site, several knobs below are worth flipping immediately.
When to enable: always for /answer and /research. Frequently for /search if your downstream consumer cares about top-1 / top-3 quality (chatbots, UI displays).
When to skip: when latency matters more than quality (autocomplete-style instant search) and your BM25 scores are already calibrated for your corpus.
Knobs:
cfg.Rerank.URL: point at Cohere/v1/rerank, Voyage, Jina, or a local TEI server. Default — auto viacfg.Chat.Modelif no URL.cfg.Rerank.CandidateK(default 20): how many candidates the reranker sees. Larger = better quality, more latency. 20 covers most regimes; raise to 50 for hard recall-bound tasks.
HyDE (expand=true or expand=hyde): 1 LLM call, appends a hypothetical answer passage to the query. Cheap recall win when query vocabulary diverges from corpus vocabulary ("how do I X" against docs that say "to X, you ...").
Paraphrase (expand=paraphrase): 1 LLM call generating N=3 paraphrases, runs BM25 against each, RRF-fuses the rankings. Higher quality than HyDE on ambiguous queries — different paraphrases surface different relevant docs.
Cost: paraphrase = N+1 BM25 calls per /search. /research with expand=paraphrase further multiplies by sub-queries. Caches (hydeCache, paraCache) absorb repeat queries; watch cosift_paraphrase_cache_hits_total on /metrics.
Composability: expand=hyde&rerank=true and expand=paraphrase&rerank=true both work — the reranker always scores against the original q, not the expanded version.
BM25 (default): lexical. Best on term-heavy, exact-match queries. No ML deps, sub-ms latency on the candidate pool.
Dense (retriever=dense): HNSW cosine over per-passage embeddings (pure-Go HNSW, no deps). Best on semantic / paraphrase-heavy queries where BM25 misses synonyms.
Hybrid (retriever=hybrid): BM25 + dense, fused via RRF (k=60). Generally the strongest default when vectors are available — gets BM25's lexical precision + dense's semantic recall. Each retriever returns fetchK candidates; the fused top-fetchK feeds the rest of the pipeline (filter → enrich → rerank).
Requirements for dense / hybrid:
COSIFT_LOAD_HNSW=trueat server start so the graph loads from the'v'family.- An embedder configured (
cfg.Embeddings.Model) so incoming queries can be embedded.
Missing either → request silently falls through to BM25 with a warning. No 5xx — clients see the warning and retriever: "bm25" label and know to fix server-side config.
Composability: retriever=hybrid&expand=paraphrase&rerank=true works — BM25 side picks up the paraphrase fan-out; dense side ignores it (HNSW search uses the embedded original query); rerank reorders the fused pool.
Cost: dense = 1 embed call + 1 HNSW search per /search. hybrid = 1 embed + BM25 + HNSW + RRF merge. For /research, multiply by sub-queries (each gets its own retrieval). HNSW search is O(log N) so the dense cost is dominated by the embed RPC.
Use when freshness matters: news, releases, "latest" research. Multiplies each hit's score by exp(-ln(2) · age_days / half_life). A doc from half_life days ago is worth half as much as a doc from today.
| Half-life | Behavior |
|---|---|
| 7 | Very fresh — week-old docs are already at 0.5x. Use for news / fast-moving topics. |
| 30 | Modest decay — month-old docs at 0.5x, quarter-old at 0.125x. Typical "prefer recent" setting. |
| 365 | Mild decay — year-old docs at 0.5x. Good for evergreen content where age is a soft signal. |
Hits without a known PublishedAt are left unchanged — no signal means no penalty. Date filters (since/until) compose: decay is a soft re-weight, since/until are hard gates.
Applies BEFORE rerank, so when over-fetching the rerank pool sees the freshest-AND-most-relevant top-N. Pool re-sort transparently re-aligns rerankTexts so decay + rerank compose correctly.
After retrieval (+rerank) the candidate pool is often dominated by near-duplicates — multiple chunks of the same paper, sibling pages on one site, paraphrases of the same concept. mmr reorders the pool with Maximal Marginal Relevance: at each step, pick the candidate maximizing
score = λ · sim(q, c) − (1−λ) · max_{s ∈ selected} sim(c, s)
λ lives in [0,1]:
- 1.0 — pure relevance, MMR is a no-op (same as not passing the flag).
- 0.7 — small diversification nudge; the top result is still always the most relevant hit, but later positions are reshuffled to break up duplicates.
- 0.5 — equal weight; useful when "give me a sampling of different angles" matters more than ranking precision.
- 0.0 — pure diversity, query relevance ignored.
Requirements: HNSW graph loaded (COSIFT_LOAD_HNSW=true) AND an embedder (to vectorize the query and look up per-hit vectors from the graph). Missing either → warning + silent skip (retriever label stays unchanged).
Cost: one embed RPC for the query + linear scans over the pool (O(K²) similarity comparisons where K = pool size ≤ rerankCandK). Negligible compared to LLM rerank.
Composability: retriever=hybrid&rerank=true&mmr=0.7 is the strongest end-to-end /search you can build — BM25+dense fused, LLM-reranked, then diversified.
Defaults: k1=1.2, b=0.75. Visible on /stats as bm25_k1 / bm25_b.
Tune b (length normalization) when your corpus has unusual document length distribution:
- Long-form docs (papers, full articles): try b ≈ 0.5. Less penalty for length.
- Short-form (titles, tweets, FAQs): try b ≈ 1.0. More penalty.
- Mixed: leave at 0.75.
Tune k1 (term-frequency saturation) when terms repeat in relevant docs:
- Technical / how-to corpora where keyword repetition is meaningful: try k1 ≈ 1.5–2.0.
- General prose: leave at 1.2.
Changes take effect at server start; no re-index needed (params are scoring-time only).
/search defaults to enrich=true (per-hit GetDocByURL for excerpt/published_at/author). Set enrich=false when you're already going to call /contents for each hit downstream — saves k extra Pebble Gets per search.
Off by default. Turn on when building a research pipeline that needs full doc text per source — saves an N+1 round trip to /contents. Cost: payload grows linearly with k × avg_doc_len.
/answer and /research both support SSE. Self-hosted vLLM / Ollama on commodity hardware run 5–30s on non-trivial answers; streaming the sources event upfront and chunk events progressively makes the UX usable. Add Accept: text/event-stream or ?stream=true.
See docs/PEBBLE.md for the full Pebble env-var reference. Headline:
- Tight VM (≤16 GB):
COSIFT_PEBBLE_SYNC=false,max_concurrent: 8,COSIFT_PEBBLE_CACHE_MB=128. - Bigger VM (32+ GB): raise
max_concurrentto 16,COSIFT_PEBBLE_CACHE_MB=256. - Latency-sensitive read path: leave
COSIFT_PEBBLE_SYNC=true(default). The throughput cost is most felt on the write path during crawl.
/metrics exposes everything you need to validate that a knob change actually helped:
rate(cosift_request_duration_seconds_sum{endpoint="/search"}) / rate(cosift_requests_total{endpoint="/search"})— mean /search latency. Compare before/after a rerank flip.rate(cosift_chat_duration_seconds_sum) / rate(cosift_chat_attempts_total)— mean chat latency, isolated from retrieval.rate(cosift_chat_failures_total) / rate(cosift_chat_attempts_total)— chat provider health.rate(cosift_hyde_cache_hits_total) / (rate(cosift_hyde_cache_hits_total) + rate(cosift_hyde_cache_misses_total))— HyDE cache hit rate. < 50% suggests raisingCOSIFT_HYDE_CACHE_SIZE(default 256). Same shape for paraphrase viaCOSIFT_PARA_CACHE_SIZE.rate(cosift_rerank_failures_total) / rate(cosift_rerank_attempts_total)— reranker provider health.
Crawling docs.example.com specifically? A short recipe:
cosift init -site https://docs.example.com— pre-populatesinclude_domainsso search defaults scope to that site.- Set
cfg.Crawler.MaxConcurrent: 4(be polite to one host). - Default crawl, default BM25. Run
cosift verifyafter the first 10k docs to confirm counter integrity. - Hand-eval 10 representative queries with
?rerank=trueand?rerank=false. If rerank wins clearly, set it as a default in your client. - If many docs are long (whitepapers, full pages), set
COSIFT_BM25_B=0.5and re-eval the same 10 queries.
The eval delta is what tells you. Single-anecdote tuning leads in circles.
Most of these used to be silent; every retrieval/synth response now carries a warnings[] field that names the specific issue. jq .warnings on a response is the fastest way to spot any of the no-op patterns below.
?expand=trueis silent withoutcfg.Chat.Model. The response'sexpandfield echoes the canonical strategy (hyde/paraphrase);effective_queryonly changes when the chat call actually fired. Without a chat client the responsewarnings[]carries"expand=... requested but no chat client configured". Setcfg.Chat.Model(andOPENAI_API_KEYor equivalent), or accept that the flag is a no-op for your deployment.?rerank=trueis silent without a reranker. Same shape — flag accepted, no rerank applied. Responsewarnings[]flags it./statsshowsrerankerwhen one is configured; absent means rerank is a no-op.- Unknown
?expand=/?sort=values silently use the default. The server now warns explicitly (expand=foo is not a known strategy,sort=newest is not a known mode). Pass valid values from{hyde, paraphrase}and{relevance, date_desc, date_asc}. ?include_domains=https://example.com/foomatches nothing. The dot-boundary matcher expects bare hostnames; passing a URL silently filters everything out. The server emits a warning when any entry contains/or://.include_text=truepayload size is k × avg_doc_len. A 500 KB doc at k=20 is 10 MB returned per call. Fine for backend pipelines, dangerous for browsers.- Pebble's single-writer lock blocks reads during crawl.
cosift stats --backend=pebblefrom a sidecar will fail. Usecosift status-file(lock-free) or curl/statsagainst a runningpebble-serveinstead. expand=paraphraseis N+1 LLM calls per /search, more for /research. Defaults are 3 paraphrases — so /search?expand=paraphrase is 1 chat (generate) + 4 BM25, /research is 1 chat (plan) + N×(1 chat + 4 BM25). The paraphrase cache absorbs repeats; watchcosift_paraphrase_cache_hits_total./researchis slow withoutstream=true. Total latency is 2-3 chat rounds + N×BM25 + 1 synth chat = 10-30s typical. Streaming makes the UX usable; sync is for backend pipelines that can wait.- Reranker failures fall back silently to BM25 order.
cosift_rerank_failures_total / cosift_rerank_attempts_totalis the alert signal — if it crosses ~5% sustained, your reranker provider is degraded. ?sort=date_descre-orders the top-k pool, not the candidate pool. Raisek(orcfg.Rerank.CandidateKif reranking) to widen before sort if you're seeing too few dated results.- Chat calls go through a sync
time.Now()boundary ondoChatStreamonly after the stream completes. Mean chat latency from/metricsincludes full SSE duration for the streamed paths — useful for capacity planning but expect numbers higher than a raw single-shot completion.