Notes vs Warn, Tavily-only min_score, post-merge domain filter, per-query RRF, sequential extra queries, canonical cache key, Tavily body constraints, SearXNG param vs bang, log/error strings.
18 KiB
Sift P3.1 — search filters and multi-query merge
Drafted 2026-09-29 by talos from Kyle's ask to steal useful Tavily-cookbook patterns for sift (thread in Mattermost). Reviewer: Claude. Implementer: titan.
Amended 2026-09-29 after Claude's review of 7f4f0d5 (R1–R10). Not ready for
titan until Claude re-approves this SHA.
P3 shipped /v1/search + /v2/search with SearXNG / Brave LLM Context / Tavily
fan-in, canonical-URL dedupe, RRF, cache, and a daily Tavily cap. The Tavily
client hardcodes search_depth: "basic" and sends no filters
(internal/search/tavily.go). Native search accepts only query, k,
rerank, digest.
This phase adds the cookbook's high-leverage search knobs without taking crawl,
map, research, or include_answer.
1. Scope
In:
- Native filters on
POST /v1/searchandsift search. - Passing those filters through to Tavily when that source is enabled.
- Honest SearXNG approximations where the engine can do it; notes (not source failure) where it cannot.
- Post-merge domain enforcement in sift for restrict-mode include and exclude, for every source.
- Multi-query search: several focused queries, one URL-deduped result list, fused with RRF across per-query lists.
- Optional
min_scorecut on Tavily's own score, per source, before merge. published_dateon a hit when a source supplies it (Tavily news; SearXNGpublishedDatewhen present).- Skill recipe: search → score/domain filter →
sift batch, do not fetch every hit.
Out:
/v2/searchstays the Firecrawl contract (query,limitonly). Unknown fields ignored. No filters on v2. v2 builds its own output map and must not leakpublished_date.- Crawl, map, Tavily
/extract/crawl/map,include_answer,include_raw_content,include_images— P3 §3.3 still holds. - HTML lexical chunks, same-host map, Research/SSE/JSON-schema — later phases.
- Changing
internal/guard/orinternal/auth/. - Fetching result URLs inside search.
Guard and contract: filters only change query text, SearXNG query params, and the Tavily JSON body. Endpoints stay config-built. Nothing from the request reaches a dialer. No guard or auth change.
2. Native request (POST /v1/search)
Existing fields unchanged. Additive:
{
"query": "…",
"queries": ["…", "…"],
"k": 8,
"rerank": true,
"digest": false,
"include_domains": ["openai.com"],
"exclude_domains": ["pinterest.com"],
"include_domains_mode": "restrict",
"time_range": "month",
"start_date": "2026-09-01",
"end_date": "2026-09-29",
"topic": "news",
"exact_match": false,
"min_score": 0
}
time_range and start_date/end_date are mutually exclusive (Tavily 400s if
both are sent). The example above shows the fields, not a legal combination.
2.1 Error strings (tests compile against these)
- Query problems → 400,
"error": "invalid query", empty hint. - Filter problems → 400,
"error": "invalid search","hint"names the field (include_domains,exclude_domains,include_domains_mode,time_range,start_date,end_date,topic,min_score,exact_match).
2.2 Validation
- Exactly one of
query(non-empty) orqueries(1–4 non-empty strings). Both set, orquerieslonger than 4, or any empty element →invalid query. Each string still has P3's 1500-rune cap (invalid query). exact_match: truewhen the query (or any element ofqueries) already contains"→invalid query(do not nest quotes for SearXNG).include_domains/exclude_domains: hostnames only. Max 20 include, 20 exclude after normalise+dedupe. Empty list = omitted. Bad value →invalid searchhintinclude_domainsorexclude_domains.- Domain normalisation (apply before validation and before any
site:rewrite): lowercase; strip a trailing dot; ASCII letters/digits/hyphens (punycodexn--labels allowed); no scheme, port, path, wildcard, space, quote, orOR; at most 253 characters. Strict validation also stops operator injection into the SearXNG query. include_domains_mode: omitted/restrict/prefer. Set withoutinclude_domains→invalid searchhintinclude_domains_mode. Omitted withinclude_domainspresent meansrestrict.time_range: omitted orday/week/month/year(and Tavily aliasesd/w/m/y, normalised to the long form). Elseinvalid searchhinttime_range.start_date/end_date:YYYY-MM-DDor omitted.endbeforestart→invalid searchhintend_date. Either date together withtime_range→invalid searchhinttime_range.topic: omitted/general/news/finance. Elseinvalid searchhinttopic.min_score: omitted or0= off; otherwise0 < x ≤ 1. Elseinvalid searchhintmin_score.digest: truestill warnsdigest ignored(P3). That warning is API-level, not a source failure.
/v2/search does not grow. Hermes never sends these fields.
3. Response and source notes
/v1/search results gain optional "published_date" (RFC 1123 or ISO date
string as the vendor sent it; omit the key when unknown). Do not invent dates.
Hit grows PublishedDate string (json:"published_date,omitempty"). When
Dedupe or RRF replaces a hit's text with a longer snippet, keep the first
non-empty published_date. Map SearXNG publishedDate when the engine
supplies it.
sizes unchanged. cached unchanged.
3.1 SourceOut.Notes (R1 — do not reuse Warn)
Today a non-empty SourceOut.Warn means failure (engine.go Run loop): the
source's hits are discarded, it lands in sources_failed, it is counted in
search_errors, and caching is disabled. Filter honesty must not take that path.
type SourceOut struct {
Hits []Hit
Warn string // failure only (timeout, HTTP error, cap skip, pin)
Notes []string // honesty: filters not applied, recall loss
Credits int
}
Warnkeeps today's meaning. Hits discarded; source failed; no cache.Notesare appended to the responsewarnings(deduplicated across queries). Hits are kept; the source is insources_ok; the result may be cached.- Test: hits plus a note → hits kept, searxng in
sources_ok, result cached.
4. Tavily client
Still POST the configured URL only. Still include_answer: false,
include_raw_content: false, include_images: false, search_depth: "basic".
Still drop inbound answer. Still never call /extract, /crawl, /map.
Always send auto_parameters: false. Auto mode can choose advanced, which
costs 2 credits and breaks "credit cost is fixed".
When the request has filters, add only the fields that are set:
include_domains,exclude_domainsinclude_domains_modeonly wheninclude_domainsis settime_rangeorstart_date/end_date, never bothtopicwhen notgeneral(omittedtopicisgeneral)exact_matchwhen trueinclude_published_date: truewhentopicisnewsor a date filter is set
Leave filter_by_published_date unset. Do not guess whether date filters use
publish date without it; document that it is unset.
Leave include_usage unset. usage.credits only comes back when requested;
today's count is the code's fallback of 1, which is correct for basic.
Map results[].published_date onto Hit. Count usage.credits as today
(fallback 1).
A fixture test must still prove include_answer is false, auto_parameters is
false, search_depth is basic, and no extract/crawl path exists.
5. SearXNG and Brave
Do not pretend they support Tavily's full filter set. Vendor gaps are notes,
never Warn. Domain restrict/exclude are enforced in sift after merge (§6.3);
site: is a recall optimisation only.
SearXNG (GET {url}/search, same client/dialer as P3):
- One
include_domainsentry: prefix the query withsite:<host>. - Several:
site:a OR site:b OR …up to 3, then notesearxng: extra include_domains dropped. exclude_domains: no query rewrite (post-filter enforces). No note unless recall is otherwise lost.include_domains_mode=prefer: nosite:prefix, no post-filter; notesearxng: include_domains_mode=prefer not applied.time_range: send SearXNG query paramtime_range=(day/week/month/year). Never a bang in the query text.topic=news: sendcategories=news. Never!news.topic=finance: notesearxng: topic ignored(no finance category).exact_match: wrap the query in ASCII double quotes (rejected earlier if the query already contains").start_date/end_date: ignored + notesearxng: start_date/end_date not applied.min_score > 0: notesearxng: min_score not applied; keep hits.
Live instance: straylight probes orion's SearXNG once before titan starts
and posts whether time_range= and categories=news return 200. If either
400s, this spec is amended to the note path for that param and titan must not
send it. Tests are offline fixtures and cannot discover this.
Brave LLM Context: no documented domain/date/topic/exact filters. Do not add a second Brave HTTP API. Post-filter still enforces restrict include and exclude. Notes when the corresponding filter was set:
brave_llm: time_range not appliedbrave_llm: topic not appliedbrave_llm: exact_match not appliedbrave_llm: include_domains_mode=prefer not appliedbrave_llm: min_score not applied
Request URL unchanged aside from q (and exact-match quotes if we ever wrap;
we do not — exact_match is Tavily + SearXNG only). Deduplicate identical notes
across queries.
6. Pipeline
Source.Search grows a Filter argument (zero value = today's behaviour).
6.1 Extra queries run sequentially per source (R5)
Sources still run in parallel within one query, as today. Queries run
sequentially. Up to 4 queries means up to 4 Brave and 4 Tavily paid calls;
four parallel Brave calls will hit the plan's rate limit, and a 429 marks
brave failed (Warn) and disables caching.
A Tavily call still counts toward the daily cap. If the cap trips mid-batch,
later queries skip Tavily with the same tavily: daily cap warning (skip path,
not a failed attempted source). The queries then ran against different source
sets. Do not cache that result (the key's sources would be wrong). Test
this.
6.2 min_score (R2)
After merge, Hit.Score is the RRF value Σ 1/(60+rank). With 3 sources the
maximum is about 0.05, so a post-merge --min-score 0.5 would drop everything.
With rerank:false scores are vendor-native (Tavily 0–1, SearXNG unbounded,
Brave 0). With Tavily no_rerank, Tavily scores mix with RRF.
Apply min_score per source, before the merge, to Tavily's own relevance
score only. That is the cookbook's usage. Other sources get a note
(searxng: min_score not applied, brave_llm: min_score not applied) and keep
their hits. Do not drop score-less Brave/SearXNG hits.
6.3 Domain post-filter (R3)
After each query's merge, for every source, drop hits that violate:
include_domainswhen mode isrestrict(omitted mode = restrict)exclude_domains(always)
Match is exact on the result host: host == d or host has suffix "." + d
(host lowercased, trailing dot stripped). Cost is negligible.
prefermode: no post-filter. Tavily honoursinclude_domains_mode; other sources get the prefer note.site:is a recall optimisation only. Notes are for recall loss, not for "filter ignored" when the post-filter already enforced it.- As written without this, Brave ignores the filter entirely and SearXNG
engines that do not understand
site:return off-domain hits.
6.4 Multi-query merge (R4)
Do not concatenate-then-truncate: if q1 returns k or more hits, q2–q4
contribute nothing.
For queries: [q1, q2, …] (max 4), or a single query treated as a list of one:
- For each query, sequentially: existing fan-in (parallel sources, same
filters, same per-source timeout) → Tavily
min_score→ today'smergeSearch(RRF / no-rerank path) → domain post-filter. - RRF across the per-query fused lists (each query's list is one ranked list). URLs several queries found rise.
- Dedupe (P3: first source wins attribution; later longer snippet replaces
text; keep first non-empty
published_date). - Truncate to
k.
Test: q1 returns k or more unique hits, and q2's single distinct top hit
still appears.
Do not fetch any result URL.
7. Cache
P3 key is sha256(norm(query) | sources | k | hour_bucket | caller_class).
Replace the payload with a canonical JSON object, keys sorted, then sha256. It must hold:
caller_class, hour bucket (2006-01-02T15UTC),k, attemptedsources(sorted) — exactly as P3 has themqueries: normalised, ordered list (a singlequeryis a one-element list)include_domains/exclude_domains: normalised, sorted, deduplicatedinclude_domains_mode(restrictorprefer; omit the key when no include list)time_rangein long form (d→day); dates asYYYY-MM-DDor omittopic, with omitted meaninggeneralso the two share a keyexact_match(bool)min_scorein one fixed number format (e.g.'%g'is not stable — usestrconv.FormatFloat(x, 'f', -1, 64)or a documented equivalent, same for 0 and omitted)
Same query without filters must not hit a filtered entry.
Still: do not cache if any attempted source failed (Warn). Still do not cache
if the Tavily cap tripped partway through a multi-query run. Still no page
bodies, no Tavily answers.
8. Logs
P3 §8 already bans hosts in the search log for every caller, and filter values are hosts or query content.
- Never log domain values, query text, dates, or topic values.
- Restricted callers keep exactly P3's fields:
caller,q_len,sources,n,cache. - Fleet callers may add
n_queriesand the filter kinds (filters=domains,time,topic), never values.rerank_msstays as today. q_lenfor several queries: sum of their rune lengths.
Test: restricted and fleet requests with include_domains: ["secret.example"]
and 3 queries → no log record contains the host or any query.
9. CLI
sift search "<query>" [--k n] [--json]
[--include-domain host]... [--exclude-domain host]...
[--domains-mode restrict|prefer]
[--time-range day|week|month|year]
[--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
[--topic general|news|finance]
[--exact-match]
[--min-score 0.5]
[--queries-file path]
--min-score 0.5 is Tavily's 0–1 scale (applied only to Tavily, §6.2).
--queries-file is a JSON array of 1–4 strings. It is mutually exclusive with
the positional query. Repeatable --include-domain / --exclude-domain.
Default text output: if published_date is present, print it as a fourth line
under the snippet. --json is the native envelope.
10. Skill
skill/SKILL.md:
- Document the new flags with one example matching the cookbook Product News
Tracker: official domain search +
topic=news+time_range=month. - Recipe: after
sift search --json, do notsift fetchevery URL. Drop hits below a Tavily score you care about (or take top N), thensift batch submit. web_searchremains the fallback only when sift is unreachable. Hermesweb_searchstill hits/v2/searchand does not get filters — say so.
11. Tests (minimum)
Owner-given invariants (these are where a free-form implementation drifts):
- R1 notes: hits plus a note → hits kept, source in
sources_ok, result cached. AWarnstill fails the source and disables cache. - R2 min_score: Tavily hit at 0.4 dropped when
min_score=0.5; SearXNG / Brave hits kept; notes present. Post-merge RRF scores are not compared tomin_score. - R3 domain post-filter: Brave (and SearXNG) hit whose host is not in
restrict-include, or is in exclude, is dropped.
preferkeeps it. Invalid domain with a space orOR→ 400invalid search. - R4 multi-query RRF: q1 returns k unique hits; q2's distinct top hit still appears.
- R5 cap mid-batch: Tavily cap trips after query 1 → later queries skip Tavily; result is not cached.
- R6 cache key: same query different
include_domains→ miss; same filters → hit; omittedtopicandtopic=generalshare a key;danddayshare a key. - R9 logs: restricted and fleet with
include_domains:["secret.example"]and 3 queries → no log record contains the host or any query. - Tavily outbound JSON: filters present when set;
include_answeralways false;auto_parametersfalse;search_depthbasic; no extract/crawl;include_domains_modeabsent unless include list set; never bothtime_rangeand dates. - Tavily inbound:
answerdropped;published_datemapped; missing date omitted. - Validation table for §2 (
invalid queryvsinvalid search+ hint). - SearXNG:
site:prefix for one domain; exact_match quotes; existing"→ 400invalid query;time_range/categoriesas query params not bangs. - Brave: request URL unchanged (aside from
q); notes as §5; post-filter still drops off-domain hits. /v2/searchgolden unchanged (no new keys required, nopublished_date).- Restricted caller: same sources; logs still have no query/host/URL.
make gate (and contract if P3's contract harness is still in the tree).
12. Code layout
Touch, do not explode:
internal/search—Filtertype;SourceOut.Notes; extendSource.Search; Tavily/SearXNG/Brave; engine loop; domain post-filter; multi-query RRFinternal/api/search.go— native decode + validation + log fieldscmd/sift/searchcmd.go— flagsinternal/dcachesearch keyskill/SKILL.md,README.md
No new dependency. No internal/api/api.go growth if a new file is cleaner
(internal/api/search_filter.go). Guard and auth stay closed.
13. Rollout
- Claude re-reviews this amended doc (R1–R10 in, guard/contract still closed).
- straylight probes orion SearXNG
time_range=andcategories=newsonce and posts the status codes in ~agent. If either 400s, amend §5 to the note path before titan starts. - titan implements on
git.wntrmute.dev:kyle/siftafter Claude's re-approval and the SearXNG probe. Split commits if it helps; one phase doc, not a P6-style task-file plan (invariants are in §11). titan does not editinternal/guard/,internal/auth/, or~/src/nixos. - Claude reviews the code.
- straylight bumps the orion pin.
14. Explicit non-goals (do not sneak in)
Map, crawl, HTML BM25 chunks, Research, include_answer, social-platform
crawlers, changing /v2/search. Those were ranked later in the cookbook
write-up on purpose.