Skip to content

Services

The application services orchestrate the use cases over the ports. They are framework-free — no FastAPI, no SQL — so the same objects drive the HTTP routes, the MCP tools, and the eval CLI. The composition root constructs them in build_container.

The module inventory below is discovered from refindery/application/services at build time. The conceptual guides explain the main workflows: search, ingest, models, clustering, deletion, and evaluation.

admin_eval

Durable administration workflow for live eval replay.

AdminEvalService

AdminEvalService(*, path, store, queue, compare, clock)

Enqueue and execute eval replay jobs with durable results.

enqueue async

enqueue(*, payload)

Submit one replay and return its durable job id.

handle_job async

handle_job(job)

Execute both replay arms and persist success or failure.

backfill

Model backfill: embed the existing corpus for a newly registered model.

Resumable via a page-granular cursor (a crash re-embeds at most one page; vector upserts are idempotent). Rate-limited by a Clock-driven token bucket. Cost estimates are exact — chunk token counts are already stored — and USD appears only when the user configured a price map (prices drift; never hardcode them).

logger module-attribute

logger = logging.getLogger(__name__)

RateLimiter

RateLimiter(*, clock, rpm=300, tpm=1000000)

Requests/minute + tokens/minute budget over the injectable clock.

wait_seconds

wait_seconds(*, tokens)

Seconds to wait before the next request fits the budget.

record

record(*, tokens)

Account one request.

acquire async

acquire(*, tokens)

Wait until the request fits, then account it.

BackfillEstimate dataclass

BackfillEstimate(
    model_id,
    n_chunks,
    total_tokens,
    est_cost_usd,
    est_duration_s,
)

Dry-run response for POST /v1/models/{id}/backfill.

model_id instance-attribute

model_id

n_chunks instance-attribute

n_chunks

total_tokens instance-attribute

total_tokens

est_cost_usd instance-attribute

est_cost_usd

est_duration_s instance-attribute

est_duration_s

BackfillService

BackfillService(
    *,
    store,
    vector_store,
    registry,
    queue,
    clock,
    pooling=MEAN,
    price_per_mtok_usd=None,
    rate_limiter=None,
)

Registers, estimates, and runs backfills.

estimate async

estimate(model_id)

Exact cost/duration estimate from stored chunk stats.

start async

start(model_id)

Mark backfilling and enqueue the durable job.

handle_backfill_job async

handle_backfill_job(job)

Resume from the cursor; embed page by page; finish -> ready.

canonicalization

Corpus-internal entity canonicalization (no external knowledge base).

Incremental (per page at ingest): exact normalized-alias match, then blocking on (type, first token) with edit-distance-first matching (free) and cosine fallback over surface-form embeddings.

Periodic (with each cluster run): within each block holding several entities, group by threshold-graph connected components (single-linkage — simpler than agglomerative average-linkage and dependency-free; flagged heuristic) and merge each group into its highest-mention member. Merges are snapshot-logged first and undoable (LIFO).

logger module-attribute

logger = logging.getLogger(__name__)

CanonicalizationService

CanonicalizationService(
    *,
    store,
    surface_embedder,
    clock,
    cosine_threshold=0.85,
    edit_threshold=0.15,
)

Links mentions to canonical entities; merges near-duplicates.

link_mentions(*, page_id, mentions)

Link each mention to an entity (matching or new); record mentions.

periodic_recanonicalize async

periodic_recanonicalize()

Merge near-duplicate entities within blocks; refresh idf.

Returns the number of merges performed.

chapter_chunking

Chapter-aware chunking: split within section boundaries, never across them.

Wraps any :class:Chunker so podcast chapters (or any titled section spans) become hard chunk boundaries: each section is chunked independently, chunks carry their section title/timestamp, and the chapter title is prepended into the chunk text so it is searchable without a schema change. The underlying Chunker port is deliberately untouched — this is pure orchestration.

chunk_with_sections

chunk_with_sections(
    chunker, *, page_id, text, sections, prepend_titles=True
)

Chunk text within each section span, preserving a global ordinal.

With no sections, delegates to the flat chunker.chunk. Otherwise every section is chunked on its own slice (so no chunk crosses a boundary), char offsets are shifted back onto text, and each chunk is labelled with its section title/timestamp. When prepend_titles is set and the section has a title, the title is prepended to the chunk text (so chunk.text is no longer byte-equal to text[char_start:char_end] — by design; no consumer relies on that equality).

cluster_triggers

Cluster run triggers: idle detection (cron and manual live elsewhere).

Idle rule (spec §8.3, run-duration-derived): re-cluster only when ingest has been quiet for clamp(median(last 5 run durations) * 3, 5min, 60min) — the system never re-clusters more often than clustering costs — AND at least min_new_pages were indexed since the last run.

logger module-attribute

logger = logging.getLogger(__name__)

IdleDetector

IdleDetector(*, store, clock, settings)

Pure decision logic over store reads and the injectable clock.

idle_threshold async

idle_threshold()

Run-duration-derived threshold with a default before history exists.

should_run async

should_run()

Idle long enough AND enough new pages since the last run.

clustering_run

Cluster run orchestration: fit -> stable-id match -> persist -> label.

Always a full refit. Noise (-1) never becomes a cluster. Stable ids come from the Jaccard/Hungarian matching layer; tombstoned clusters remain resolvable by id so stale agent references degrade gracefully.

logger module-attribute

logger = logging.getLogger(__name__)

ClusterRunInFlightError

ClusterRunInFlightError()

Bases: RefinderyError

A run is already executing.

ClusterRunService

ClusterRunService(
    *,
    store,
    engine,
    queue,
    clock,
    canonicalization,
    settings,
    labeler=None,
)

Runs clustering end to end (the cluster job handler).

request_run async

request_run(*, trigger)

Enqueue a run job; False when below the corpus minimum.

handle_cluster_job async

handle_cluster_job(job)

Run clustering as the durable cluster job.

run async

run(*, trigger)

Full refit; returns the run record (None when skipped).

handle_canonicalize_job async

handle_canonicalize_job(job)

Periodic re-canonicalization chained after each run.

close async

close()

Release optional cluster resources.

compare_service

A/B model comparison: the delta isolates the embedder.

The full pipeline runs once per model. The sparse arm is fetched once (chunk ids are canonical across model spaces) and the reranker instance is identical across arms. Each arm is logged as a compare_arm query-log row sharing a compare_id.

ModelNotComparableError

ModelNotComparableError(model_id, status)

Bases: RefinderyError

Only ready models can be compared (partial indexes corrupt stats).

CompareArm dataclass

CompareArm(model_id, pages)

One model's ranked pages.

model_id instance-attribute

model_id

pages instance-attribute

pages

PairAgreement dataclass

PairAgreement(
    model_a,
    model_b,
    jaccard_at_k,
    rbo,
    kendall_tau,
    intersection_size,
)

Agreement statistics for one model pair.

model_a instance-attribute

model_a

model_b instance-attribute

model_b

jaccard_at_k instance-attribute

jaccard_at_k

rbo instance-attribute

rbo

kendall_tau instance-attribute

kendall_tau

intersection_size instance-attribute

intersection_size

CompareOutcome dataclass

CompareOutcome(compare_id, arms, agreement)

Everything /v1/compare returns.

compare_id instance-attribute

compare_id

arms instance-attribute

arms

agreement instance-attribute

agreement

CompareService

CompareService(
    *,
    store,
    vector_store,
    registry,
    query_log,
    clock,
    reranker=None,
)

Runs the pipeline per model and computes agreement.

compare async

compare(
    *, query, model_ids, k=10, candidates=100, rerank=True
)

Run each arm sequentially (respects embedding rate limits).

replay_arm async

replay_arm(
    *, model_id, query, k=10, candidates=100, rerank=True
)

Run one arm without logging.

Offline eval replays queries read from the query log; logging the replays would pollute the very substrate being scored.

entity_ingest

Entity extraction job: extract entities from an indexed page and link them.

logger module-attribute

logger = logging.getLogger(__name__)

EntityIngestService

EntityIngestService(*, store, extractor, canonicalization)

Steps 4+5 of the indexing pipeline (extract + incremental canon).

handle_extract_entities_job async

handle_extract_entities_job(job)

Extract and link entities for an already-indexed page.

close async

close()

Release extractor resources when present.

eval_service

Offline retrieval eval: score the query log, replay regression diffs.

Two modes share the golden-set substrate:

  • score_log reads logged runs read-only and computes nDCG@k / MRR / recall@k against feedback labels — no retrieval re-run, no container.
  • replay re-runs golden queries live under two arm configurations (model and/or rerank toggled) via CompareService.replay_arm and diffs the aggregate metrics. Replays are never logged.

Reranker lift in score_log is exact only for rollup=max rows without exact-match pins or recency decay: first-occurrence page order of the fused candidate set is then precisely the rerank-off ranking. Rows with other rollups are excluded from lift.

GoldenQuery dataclass

GoldenQuery(query_text, relevant, source_query_ids)

One replayable query: normalized text with its union of positives.

query_text instance-attribute

query_text

relevant instance-attribute

relevant

source_query_ids instance-attribute

source_query_ids

QueryScore dataclass

QueryScore(
    query_id,
    query_text,
    model,
    ndcg,
    reciprocal_rank,
    recall,
    recall_candidates,
    rerank_lift,
)

Metrics for one logged run.

query_id instance-attribute

query_id

query_text instance-attribute

query_text

model instance-attribute

model

ndcg instance-attribute

ndcg

reciprocal_rank instance-attribute

reciprocal_rank

recall instance-attribute

recall

recall_candidates instance-attribute

recall_candidates

rerank_lift instance-attribute

rerank_lift

ModelReport dataclass

ModelReport(
    model,
    queries,
    ndcg,
    reciprocal_rank,
    recall,
    recall_candidates,
    rerank_lift,
)

Mean metrics over one model's scored runs.

model instance-attribute

model

queries instance-attribute

queries

ndcg instance-attribute

ndcg

reciprocal_rank instance-attribute

reciprocal_rank

recall instance-attribute

recall

recall_candidates instance-attribute

recall_candidates

rerank_lift instance-attribute

rerank_lift

ScoreReport dataclass

ScoreReport(
    k,
    logged,
    labeled,
    scored,
    skipped_no_positive,
    models,
    queries,
)

Everything eval score emits.

k instance-attribute

k

logged instance-attribute

logged

labeled instance-attribute

labeled

scored instance-attribute

scored

skipped_no_positive instance-attribute

skipped_no_positive

models instance-attribute

models

queries instance-attribute

queries

ArmSpec dataclass

ArmSpec(model_id=None, rerank=True)

One replay configuration; model None means the active model.

model_id class-attribute instance-attribute

model_id = None

rerank class-attribute instance-attribute

rerank = True

label

label(*, active_model_id)

Human-readable arm name for reports.

ArmReport dataclass

ArmReport(label, ndcg, reciprocal_rank, recall)

Mean metrics for one replayed arm.

label instance-attribute

label

ndcg instance-attribute

ndcg

reciprocal_rank instance-attribute

reciprocal_rank

recall instance-attribute

recall

PairedScore dataclass

PairedScore(query_text, ndcg_a, ndcg_b)

Per-query nDCG under both arms.

query_text instance-attribute

query_text

ndcg_a instance-attribute

ndcg_a

ndcg_b instance-attribute

ndcg_b

ReplayReport dataclass

ReplayReport(
    k, golden_queries, arm_a, arm_b, deltas, queries
)

Everything eval replay emits.

k instance-attribute

k

golden_queries instance-attribute

golden_queries

arm_a instance-attribute

arm_a

arm_b instance-attribute

arm_b

deltas instance-attribute

deltas

queries instance-attribute

queries

EvalService

EvalService(*, reader)

Scores and replays the query log.

score_log

score_log(*, k=10, since=None, model=None)

Score every labeled logged run against its own feedback.

replay async

replay(
    *,
    compare,
    active_model_id,
    arm_a,
    arm_b,
    k=10,
    candidates=100,
    limit=None,
)

Re-run golden queries under two arms and diff the aggregates.

build_golden_set

build_golden_set(runs, labels)

Assemble golden queries: dedupe by text, union positives.

Runs without feedback are dropped; so are queries whose labels are all negative (no positives means every metric is undefined).

extraction_router

Routes fetched content to the extractor registered for its content type.

ExtractionRouter

ExtractionRouter(extractors)

Content-type -> ContentExtractor dispatch; extensible by registration.

supports

supports(content_type)

Whether any extractor handles this content type.

extract async

extract(*, content_type, raw, charset)

Extract body text using the matching extractor.

close

close()

Close extractor resources that expose a close method.

feedback_service

Relevance feedback: append-only, joined to the query log at eval time.

Unknown query_ids are accepted by design — validating them would route reads through the single DuckDB writer; orphans are dropped by the eval-time join.

FeedbackService

FeedbackService(*, query_log, clock)

Records POST /v1/feedback.

record

record(*, query_id, page_id, relevant)

Buffer one feedback row.

forget_service

Forget: purge a URL or domain and blacklist it, atomically.

Metadata deletion is authoritative and immediate (purged pages can never surface in results — hydration drops them); vector deletion is asynchronous via tombstones with a verification sweep.

logger module-attribute

logger = logging.getLogger(__name__)

ForgetOutcome dataclass

ForgetOutcome(rule, pages_purged, vector_deletes_queued)

What POST /v1/forget returns.

rule instance-attribute

rule

pages_purged instance-attribute

pages_purged

vector_deletes_queued instance-attribute

vector_deletes_queued

ForgetService

ForgetService(
    *, store, vector_store, queue, clock, rules=None
)

Purge + blacklist + vector tombstone lifecycle.

forget async

forget(*, url=None, domain=None, reason=None)

Purge and blacklist one URL or one domain (exactly one given).

handle_purge_vectors async

handle_purge_vectors(job)

Delete purged pages' vectors; tombstones advance to deleted.

verify_tombstones async

verify_tombstones()

Periodic sweep: verify deletions, re-drive stragglers, GC old rows.

indexed_pages

Retrieval surfaces hydrate INDEXED pages only; one filter enforces it.

Search, suggestions, similar, compare, and clustering all hydrate page ids that may point at queued/failed/purged pages. Metadata is authoritative: anything not currently INDEXED is dropped here, in one place.

indexed_pages_by_id async

indexed_pages_by_id(store, page_ids)

Hydrate page_ids and keep only pages currently INDEXED.

indexed_page_ids async

indexed_page_ids(store, page_ids)

Return the subset of page_ids whose pages are currently INDEXED.

indexing

Indexing pipeline.

Chunk -> embed (every indexable model) -> upsert -> page-vector rollup -> status transitions.

Chunk ids are deterministic — uuid5(page_id : content_hash : ordinal) — so retries and re-index runs upsert the same vector-store points instead of orphaning old ones.

Entity extraction is a separate durable job, so retrieval artifacts can be visible even when entity enrichment needs a retry.

logger module-attribute

logger = logging.getLogger(__name__)

IndexingService

IndexingService(
    *,
    store,
    vector_store,
    chunker,
    registry,
    clock,
    fetcher,
    router,
    queue=None,
    pooling=MEAN,
    podcast_producer=None,
)

Executes index_page and fetch_and_index jobs.

set_queue

set_queue(queue)

Attach the durable queue after construction (breaks wiring cycles).

handle_index_page async

handle_index_page(job)

index_page job: run the pipeline for an already-resolved body.

handle_fetch_and_index async

handle_fetch_and_index(job)

fetch_and_index job: resolve the body by fetching, then index.

mark_page_dead async

mark_page_dead(job, error)

Dead-job callback: the page is excluded from search.

mark_page_queued async

mark_page_queued(job)

Manual-retry callback: undo mark_page_dead; the page re-enters the queue.

reconcile_entity_jobs async

reconcile_entity_jobs()

Enqueue entity jobs lost in the indexed->enqueue crash window.

deterministic_chunk_id

deterministic_chunk_id(*, page_id, page_hash, ordinal)

Stable chunk id so retries upsert the same vector-store points.

ingest

Ingest service: the single entry point for new pages.

Flow: canonicalize -> blacklist check -> revisit detection -> body resolution -> insert + enqueue. POST returns immediately; when no body was supplied, a fetch_and_index job resolves it asynchronously.

IngestRequest dataclass

IngestRequest(
    url,
    title=None,
    body_extracted=None,
    body_html=None,
    fetched_at=None,
    source=None,
    metadata=None,
    fetch_route=AUTO,
)

Validated ingest input (the API layer maps its pydantic model here).

url instance-attribute

url

title class-attribute instance-attribute

title = None

body_extracted class-attribute instance-attribute

body_extracted = None

body_html class-attribute instance-attribute

body_html = None

fetched_at class-attribute instance-attribute

fetched_at = None

source class-attribute instance-attribute

source = None

metadata class-attribute instance-attribute

metadata = None

fetch_route class-attribute instance-attribute

fetch_route = FetchRoute.AUTO

IngestService

IngestService(*, store, queue, clock, router, rules=None)

Handles POST /v1/pages semantics.

ingest async

ingest(request)

Ingest one page; returns Queued | Revisit | Blacklisted.

model_registry

Embedding model registry: registration rules and embedder lookup.

The invariant this service owns: no registered model may have a token budget below the canonical chunk hard max — accepting one would force a re-chunk, invalidating every other model's index and destroying A/B comparability.

INDEXABLE_STATUSES module-attribute

INDEXABLE_STATUSES = frozenset(
    {ModelStatus.READY, ModelStatus.BACKFILLING}
)

logger module-attribute

logger = logging.getLogger(__name__)

EmbedderFactory

EmbedderFactory = Callable[[EmbeddingModel], Embedder]

ModelRegistry

ModelRegistry(
    *,
    store,
    vector_store,
    clock,
    embedder_factory,
    chunk_hard_max,
)

Registration, activation, and embedder instances per model.

sync_from_settings async

sync_from_settings(model)

Startup: ensure the configured model is registered, ready, active.

register async

register(model)

Register a new model (status=registered, not active).

indexable_models async

indexable_models()

Models whose vector spaces receive new chunks (ready or backfilling).

embedder_for

embedder_for(model)

Return (and cache) the embedder instance for a model.

require_model async

require_model(model_id)

Fetch a model or raise ModelNotFoundError.

search_service

Hybrid search: embed -> dense+sparse -> RRF -> rerank -> rollup -> pages.

Includes the exact-match pre-pass: a query that parses as a URL or bare domain pins the matching page(s) at rank 1 — the cheap, high-value "I'm pasting the URL back" refind case.

logger module-attribute

logger = logging.getLogger(__name__)

EntityFilterTooBroadError

EntityFilterTooBroadError(*, entity, matches)

Bases: RefinderyError

The entity filter matches too many pages to push into the store.

entity instance-attribute

entity = entity

SearchFilters dataclass

SearchFilters(
    domain=None,
    after=None,
    before=None,
    cluster_id=None,
    entity=None,
)

First-class filters, pushed into the vector store.

domain class-attribute instance-attribute

domain = None

after class-attribute instance-attribute

after = None

before class-attribute instance-attribute

before = None

cluster_id class-attribute instance-attribute

cluster_id = None

entity class-attribute instance-attribute

entity = None

SearchQuery dataclass

SearchQuery(
    query,
    k=10,
    offset=0,
    candidates=100,
    rerank=True,
    chunks_per_page=2,
    rollup=MAX,
    rollup_m=3,
    rrf_k=60,
    suggest=3,
    mediation=VECTOR,
    recency_half_life_days=None,
    filters=None,
)

Validated search parameters (API defaults mirror the spec).

query instance-attribute

query

k class-attribute instance-attribute

k = 10

offset class-attribute instance-attribute

offset = 0

candidates class-attribute instance-attribute

candidates = 100

rerank class-attribute instance-attribute

rerank = True

chunks_per_page class-attribute instance-attribute

chunks_per_page = 2

rollup class-attribute instance-attribute

rollup_m class-attribute instance-attribute

rollup_m = 3

rrf_k class-attribute instance-attribute

rrf_k = 60

suggest class-attribute instance-attribute

suggest = 3

mediation class-attribute instance-attribute

mediation = Mediation.VECTOR

recency_half_life_days class-attribute instance-attribute

recency_half_life_days = None

filters class-attribute instance-attribute

filters = None

ClusterRef dataclass

ClusterRef(id, label)

Cluster membership shown on a result.

id instance-attribute

id

label instance-attribute

label

SearchResultPage dataclass

SearchResultPage(
    page, score, chunks, exact_match=False, cluster=None
)

One ranked page with its matched chunks (whole chunk text).

page instance-attribute

page

score instance-attribute

score

chunks instance-attribute

chunks

exact_match class-attribute instance-attribute

exact_match = False

cluster class-attribute instance-attribute

cluster = None

SearchOutcome dataclass

SearchOutcome(
    query_id,
    results,
    suggestions,
    timing_ms=dict(),
    has_more=False,
)

Everything the API response needs.

query_id instance-attribute

query_id

results instance-attribute

results

suggestions instance-attribute

suggestions

timing_ms class-attribute instance-attribute

timing_ms = field(default_factory=dict)

has_more class-attribute instance-attribute

has_more = False

SearchService

SearchService(
    *,
    store,
    vector_store,
    registry,
    similarity,
    query_log,
    clock,
    reranker=None,
    rules=None,
    default_recency_half_life_days=None,
)

Orchestrates the retrieval pipeline.

search async

search(request)

Run the full pipeline and log the execution.

similarity_service

Page similarity: vector mediation now, cluster/entity mediations in M4.

Mediation

Bases: StrEnum

What mediates 'similar': vectors, cluster co-membership, or entities.

VECTOR class-attribute instance-attribute

VECTOR = 'vector'

CLUSTER class-attribute instance-attribute

CLUSTER = 'cluster'

ENTITY class-attribute instance-attribute

ENTITY = 'entity'

SimilarPage dataclass

SimilarPage(page_id, score, reason)

A similar page with its score and the mediation that produced it.

page_id instance-attribute

page_id

score instance-attribute

score

reason instance-attribute

reason

SimilarityService

SimilarityService(*, store)

similar_to(page_id) and the suggestions block on /search.

similar async

similar(
    *, page_id, mediation=VECTOR, k=10, exclude=frozenset()
)

Rank pages similar to page_id; excludes the source itself.

watch_service

Watch service: scheduled pull sources that fan out into ingest.

Scheduling invariants:

  • tick advances next_run_at at enqueue time, never the poll handler, so a permanently failing poll job cannot freeze a watch's schedule.
  • The poll job's idempotency key embeds the scheduled next_run_at, so a racing duplicate tick dedups to a no-op while later ticks still enqueue.
  • Discovered URLs go through IngestService.ingest, which dedups by canonical URL globally (across watches and manual adds).

logger module-attribute

logger = logging.getLogger(__name__)

UNSET module-attribute

UNSET = _UnsetValue()

WatchPatch dataclass

WatchPatch(
    enabled=None,
    interval_hours=None,
    title=UNSET,
    config=UNSET,
)

Partial watch update with explicit clearing for nullable fields.

enabled class-attribute instance-attribute

enabled = None

interval_hours class-attribute instance-attribute

interval_hours = None

title class-attribute instance-attribute

title = UNSET

config class-attribute instance-attribute

config = UNSET

FanOutTally dataclass

FanOutTally(queued=0, revisits=0, blacklisted=0, errors=0)

Per-poll ingest outcome counts.

queued class-attribute instance-attribute

queued = 0

revisits class-attribute instance-attribute

revisits = 0

blacklisted class-attribute instance-attribute

blacklisted = 0

errors class-attribute instance-attribute

errors = 0

WatchService

WatchService(
    *, store, queue, clock, ingest, sources, settings
)

CRUD, scheduling tick, and poll handler for watches.

supported_kinds property

supported_kinds

Watch kinds with a wired source (extras may disable some).

create async

create(
    *,
    kind,
    url,
    title=None,
    interval_hours=None,
    enabled=True,
    config=None,
)

Create a watch due immediately; None when (kind, url) exists.

get async

get(watch_id)

Fetch one watch.

list_all async

list_all()

All watches, newest first.

update async

update(watch_id, patch)

Apply a partial update; None when the watch does not exist.

delete async

delete(watch_id)

Delete a watch; False when it does not exist.

run_now async

run_now(watch_id)

Enqueue an immediate poll and advance the schedule.

Returns the poll job id (None only on a same-instant duplicate); raises WatchNotFoundError for an unknown watch.

tick async

tick()

Enqueue one poll job per due watch; returns how many were enqueued.

handle_poll_watch async

handle_poll_watch(job)

Discover a watch's items and fan them out into ingest.