rosslabs.ai The Fact Refinery

Atomize turns broad intake into facts it can prove with a verbatim quote.

It starts with AI news and research from feeds, scraped pages, and arXiv, then keeps article-quality text and facts whose quote resolves back to that source.

Sample run paused. Starting it feeds this page's scale model only; it does not control the live Atomize service.

Atomize AI, pipeline unit 01, a working scale model

The quality path separates articles from sponsored material, link hubs, page chrome, and duplicate copies before facts are extracted. The grounding gate then keeps only claims with a verbatim quote in the source. Every number on this page was read from the Atomize codebase and carries a grade for how well it is actually known.

Four settings move this machine the most

Everything else on this page is real, but these four decide the most about what gets in, what gets kept, and what a reader sees.

0.50 quality floor Raise it and fewer, cleaner articles get in; lower it and more get in, noise included.
800 / 100 chunk size / overlap tokens Smaller chunks point a search at a tighter passage; larger chunks carry more surrounding context into every match.
0.25 similarity floor Raise it and only close semantic matches count as evidence; lower it and a looser match can still qualify.
50 articles, evidence window A wider window gives an answer more to draw on; a narrower one answers faster from less.
section 01

From broad intake to grounded facts

Follow the positive path: feeds Atomize subscribes to, pages it scrapes because they publish no feed, and arXiv papers enter together. The content integrity gate keeps article-quality text and identifies sponsored material, link hubs, page chrome, and duplicates as diagnostics. Extraction then has to bind each fact to a verbatim quote in that source before it reaches the graph. Social signals and benchmark refresh are sidecars: they enrich articles already inside rather than bringing new ones. The controls below drive this scale model directly.

Controls: 3 of 3 article inputs open, gate at 0.50
PLANT CONTROLS

These settings are real. The production system genuinely exposes each one as a configuration value.

quality floor

Raising this floor raises the gate bar in the machine below. Text that scores under it gets rejected before any paid step ever sees it.

What it is. One number per article, content_quality, a float between 0 and 1 stored on the article row. The content integrity gate compares it against quality floor, which defaults to 0.50, and returns one verdict for the article: pending, valid, suspect or rejected. It is a threshold on a single score, so it is neither a top-k cut nor a cluster. A rejected or quarantined verdict is terminal, and isDownstreamAiBlocked() stops embedding and extraction from ever running on it. It lives in lib/ingestion/integrity/content-integrity.ts.

It drives quality-cleared articles per minute.

Gate Power gate power

Switching this off lifts the gate arm clear of the flow. Everything passes, including text that should have been rejected. The shipped sample environment sets this flag to false, so treat this switch as a real code path rather than a proven production setting.

What it is. One boolean, gate power, read by the same integrity module as the floor. Set to false it disables the gate, so no verdict blocks anything downstream and every article reaches the embedding and extraction steps that cost money. The gate takes {url, text, quality, contentHash, html} and its reason codes are quality_below_floor, known_link_hub, page_chrome_signature, handle_directory_signature and sponsored_content_quarantine.

It drives quality-cleared articles per minute. Turning it off marks the flow as bypassed, not quality-cleared.

Article inputs feeding the plant

Open any combination of the three normal article inputs. They run at the same time, not one at a time. Closing one seals that hopper until you open it again.

3 of 3 article inputs open

The three article inputs drive intake rate, each by its own weight. Sidecars do not enter the particle path.

Counts below are active sources of that type seeded in atomize-ai at commit 7af24fb0a, across its five seed files, counted once per feed. A bigger, more frequent particle means more sources of that type, on a square-root scale so a quiet input still drops something you can see.

57 seeded sources, the largest share by far.

7 seeded sources, including three Anthropic pages scraped because they publish no feed.

7 seeded sources, all arXiv categories.

Social signals sidecar

A two-hour job collects Hacker News and Reddit engagement for existing releases. It does not feed article particles.

Benchmark refresh sidecar

A daily job refreshes leaderboard and benchmark data. It does not feed article particles.

Feed drops one article now. Auto-feed runs the hoppers continuously instead.

It drives articles fed.

Sends one query up through the graph vat to the nearest facts.

It drives queries served.

Empties the vat, the chute and every run counter, and starts the model over. Your settings above stay as you left them.

It resets every run gauge.

WHAT-IF LEVERS SIM ONLY

These thresholds are real numbers from the codebase, but they are fixed in code today. Nobody can tune them from a production dashboard.

Mode Real settings mode. Every lever below is locked to the value the code actually holds today.
Five more settings, with their evidence.

Real settings mode: every control below is locked.

SIM ONLY

Loosening this lets facts into the vat even when their quote cannot be verified. They are drawn as hollow rings, and graph trust falls as they build up.

It drives graph trust.

SIM ONLY

Lowering this lets weaker connections into the graph. They are drawn as dashed lines, and graph trust falls as they build up.

It drives graph trust.

Search Orchestrator V2 (real RRF fusion) search orchestrator v2 SIM ONLY

Turning this on replaces weighted-sum fusion with true Reciprocal Rank Fusion at k=60 plus a quality guard that can reformulate one weak query.

spaCy direction check SIM ONLY

Turning this on adds a verification hop before any relationship enters the graph, catching reversed direction such as who acquired whom.

Roll back HNSW to IVFFlat SIM ONLY

Turning this on swaps the article-vector index back to the older type.

Chunk sizing, 800 target tokens, 100 overlap, 1000 hard max, is a locked design decision with written logic behind it.
Scale model, synthetic counts, real gates
Quality-check diagnostics: real reason codes, synthetic counts

    section 02

    This is the best evidence on the page, and it still is not shipping.

    A three-tier classifier can route a search query more accurately than the code running today. The team measured it carefully on real production traffic, then decided not to turn it on. This panel is that decision, with the numbers behind it.

    Intent Cascade routing cascade routing SIM ONLY

    Turning this on lets a three-tier classifier change which retrieval strategy runs for a query.

    section 03

    Run through the 24 hour Atomize AI news cycle

    Sixteen Vercel cron routes fire through the UTC day on the dial below, each one a real route in the codebase. Six Railway workers stay lit the whole time instead, waiting on queue events. Drag the needle, or press Run the day, and watch the difference.

    09:00 UTC. Drag the needle or press Run the day.

    Vercel routes are clock-driven. They fire on a schedule, then stop. Railway workers are queue-driven instead: they stay always-on and pulse only when work arrives. That contrast is the one idea this dial teaches. A shaded band marks the 02:00 to 04:30 UTC maintenance window, where cleanup, benchmark refresh and model classification cluster.

    section 04

    Every part of this toy has a real counterpart in production.

      Loading blueprints…

      Atomize AI, source-backed system map

      The Engine maps the source-to-response flow and the controls that change it

      The Engine identifies the hosts, queues, model calls, and controls that form the Atomize runtime. Every lever carries its current value, the file that defines it, and the same evidence grade Blueprints uses.

      The database contains both the vector index and the knowledge graph as layers. The queue broker connects the host that schedules work to the host that performs it.

      Select any runtime component to inspect its controls and the steps it supports.

      section 01

      Six steps turn a source into a grounded answer

      Read the flow in order. Every step names its input, output, and model use.

      “No LLM” means ordinary code or a database performs the step. The label describes the mechanism; it does not rank the step’s importance.

      Each step below opens with what a builder must decide, not with this deployment’s current number. The number, the alternative models this system tried or still keeps on hand, and the exact source line follow underneath.

        before you tune anything, 1 of 3

        A vector database turns meaning into distance you can search

        Text becomes a list of numbers that places similar ideas near each other. “Find things about this” becomes “find the nearest numbers,” and a search can then match an article on export limits on chips even when the query never uses those words.

        What runs here: pgvector, a Postgres extension, not a separate vector database. It adds a native vector column type and a similarity index, so every vector lives beside the articles it describes in one database. Each vector holds 1,536 numbers, and distance between two of them is measured as cosine distance.

        The decision a builder faces: run vectors inside the database already in use, or stand up a dedicated vector store. Keeping vectors in Postgres removes a second system to operate and keeps a search join a single SQL query; a dedicated vector store can scale and tune independently of the relational workload sharing its instance.

        before you tune anything, 2 of 3

        An index trades a small chance of missing a match for a much faster search

        Without an index, a similarity search compares the query against every stored row: correct, but slower as the corpus grows. HNSW instead walks a layered graph of neighbors and checks only a small candidate set, so it stays fast as the corpus grows. The catch: it is approximate, and can miss the true nearest match.

        This system replaced an older IVFFlat index with HNSW on the article-vector column in October 2025. The migration’s own comment states the reason: “HNSW provides 5-20x faster queries with similar recall.”

        Query cost: Walks a layered neighbor graph and checks only a small candidate set.

        Build cost: Above the library default; m = 24, ef_construction = 100 versus defaults of 16 and 64 (see below).

        What you give up: Still approximate; ef_search buys back recall at the cost of latency.

        This system runs HNSW’s build parameters above the library default (m = 24, ef_construction = 100 versus defaults of 16 and 64), a deliberate trade of build time and memory for accuracy, per a quoted project requirement for “super accurate research.”

        before you tune anything, 3 of 3

        A knowledge graph answers who did what, not just what a passage is about

        The vector side answers “what is this about.” The graph side answers “who did what to whom,” a typed, directional link between two named things, such as one company acquiring another. The two are complementary, not competing.

        What runs here: plain relational Postgres rows, an adjacency list, not a dedicated graph database or extension. Relationships are drawn from a closed vocabulary of 26 types across business and research families.

        The join back to evidence: extraction requires a resolvable verbatim quote before a fact, entity, or relationship is kept; the knowledge-graph record carries both token and character offsets for that span, so a graph claim traces back to an exact range in the original article.

        The decision a builder faces: a dedicated graph database versus relational tables in the database already in use; an open versus closed relationship vocabulary; and whether to require quote-grounding at all, which trades recall (facts stated indirectly get skipped) for near-zero hallucination on what is kept.

        section 02

        Hosts, queues, and stores form one connected runtime

        section 03

        Each control has a source, scope, and effect

        Every control below comes from the codebase. The grid groups controls by pipeline stage and by the place where an operator changes them.

        Colour identifies the change location because that location determines whether a setting takes effect immediately, after a restart, or after a migration.

          section 04

          Three runs move work through different paths

          Three runs move work through this system. Select one to follow each handoff from its trigger to its result.

          A “not reachable” label identifies real code without a current caller.

            section 05

            Queues set the system’s execution limits

            Every queue value comes from its definition, job options, and producer call sites. Local concurrency limits one process.

            Global concurrency applies a separate Redis-backed cap across every replica of the same queue.

            QueueRuns onConcurrencyRetryState

            One start command runs on every Railway service and the service name selects the branch. An unrecognised name falls through to the default worker path and logs a warning, because a misnamed service leaves the content extraction queue with zero consumers and nothing else would say so.

            section 06

            Prompt contracts constrain model output

            A builder has three dials per stage, not two. The model decides which weights answer; the setting decides how that model is called; the prompt decides what it is asked to do and in what shape. Section 01 above marks each stage’s prompt dial next to its model chip; this section is where every prompt actually lives.

            Each prompt contract identifies its decision, output shape, and storage location.

            The page excludes instruction text. Publishing it would expose which untrusted article fields reach each model and which output shape passes validation.

            Where a prompt lives

              What the contracts say

                Reference · Atomize AI · commit 071aaa8

                Atomize Pipeline Settings

                Thirty-four tunable values control this ingest-to-answer pipeline, and each one buys a named property by spending another, never both. Set them in stage order, because four of them cap what every later stage can achieve no matter how it is tuned: the ingest quality floor caps corpus quality, chunk size caps retrieval precision, the similarity floor caps recall, and the evidence window caps answer coverage.

                Settings34
                Stages6
                Embedding1,536 dim
                Chunk800 tok / 100 lap
                IndexHNSW cosine

                Stage 1 · Ingest & qualify

                The ingest gate evaluates content quality and decides what enters the corpus

                Every downstream metric inherits this stage's output, because nothing rejected here re-enters the pipeline. A weak gate lets navigation menus and boilerplate compete for the top rank on a real query, and no reranker weight downstream can undo that.

                Extraction tier chain

                tiers 1–6, full chain per-tier cost and coverage in the deep dive

                Six extraction methods tried in cost order, stopping at the first candidate that passes the article gate.

                The chain is a failure-triggered escalation ladder, not a six-stage pipeline. It tries the least expensive method first. It stops as soon as a candidate passes the article gate.

                json-ld  readability  cheerio  browser  docparse  llm

                What runs when

                • The cheap tiers. json-ld, readability, and cheerio run against the HTML returned in the initial request. They are fast, typically sub-100 ms, and carry near-zero marginal cost.
                • The fallback tiers. browser, docparse, and llm run only after every preceding tier has failed the article gate for that page.

                This design preserves coverage without making browser rendering or model inference a universal cost.

                Decision rule Extend the chain only to close a measured, named coverage gap: a publisher class that is client-rendered, a known PDF-heavy source, a recurring malformed-page pattern. Don't add tiers speculatively.

                Show the technical deep diveHide the technical deep dive

                How escalation works

                A page enters at json-ld.

                1. JSON-LD extracts publisher-provided structured article data: the schema.org Article.articleBody field, when the publisher includes one. It is the cleanest source when present: no heuristics, no boilerplate to strip.
                2. Readability applies Mozilla's heuristic content-extraction algorithm to the raw HTML. It is the same engine behind Firefox's reader view.
                3. Cheerio makes a selector-based structural attempt against that same HTML, using a fixed list of likely content selectors. It is the fallback for pages where Readability's heuristics find nothing.
                4. Browser launches headless Chrome and retries extraction against the rendered DOM. It exists specifically for sources gated on browser fingerprint or client-side rendering. A plain fetch either gets blocked as a bot, or returns a shell whose article body JavaScript injects afterward. It runs only on the Railway worker; Vercel never launches it. Known limit: this recovers client-rendered pages, not every blocked page. Sources gated by an anti-bot wall or a paywall are out of scope for this tier. They stay out of scope after it runs.
                5. Docparse hands the page to a Railway-hosted document-conversion service for formats HTML extraction cannot parse, principally PDFs. Its primary use case is arXiv papers whose HTML rendering path produced nothing. Most often the paper is too new, or its LaTeX too unusual to render.
                6. LLM is the final fallback: a paid model call that attempts extraction when every structural and heuristic approach has failed.

                At each tier, the candidate must pass the article gate. If it passes, extraction stops. If it fails, the page escalates to the next tier.

                for extractor in [json_ld, readability, cheerio, browser, docparse, llm]:
                            candidate = extractor.extract(page)
                            if article_gate.passes(candidate):
                                return candidate
                        return ExtractionFailure(page.url)

                The gate is the economic control point It limits expensive repair mechanisms to pages that actually need them.

                What the design trades off

                DimensionEffect of adding tiers
                CoverageEach tier reaches a class of source the earlier tiers cannot: JavaScript-rendered pages, PDFs, malformed markup, or pages without usable structure.
                Latency and costLater tiers are materially more expensive: browser rendering requires a headless Chrome process; the LLM tier requires a paid model call.
                Cost distributionCosts concentrate on the problematic tail of the source list, rather than applying to every page.
                Operational complexityMore tiers require more dependencies, observability, failure handling, and per-tier evaluation.

                Chain cost is driven less by total corpus size than by the escalation rate:

                extraction cost = Σi=1..n (pages reaching tier i × cost of tier i)

                A clean, well-formed source list should terminate mostly in the cheap tiers. A source list with many client-rendered, malformed, or non-HTML pages will escalate more often. That shifts latency and cost toward browser, document parsing, and LLM extraction.

                Deployment levels

                Cost and latency: adds document parsing and, as a last resort, a paid model call. Capability: extends coverage to non-HTML documents and pages too malformed or unusual for deterministic extractors. Risk: LLM spend scales with the rate of pages that defeat every earlier tier. A noisy or adversarial source list can make this tier the dominant extraction cost. Quality: the LLM tier increases recovery, but its output still requires validation. A model-produced extraction is not automatically trustworthy merely because deterministic extraction failed.

                Why gate escalation

                The cheap tiers handle most well-formed news pages. Launching a browser or calling an LLM for every page would turn a rare recovery path into a universal tax.

                Gating changes the role of expensive tiers:

                Without a gate: expensive processing is the default.
                        With a gate:    expensive processing is targeted repair.

                The architecture follows a general systems principle: defer advanced, expensive behavior until the specific case requires it, rather than exposing all complexity on every request. This is the same reasoning Nielsen Norman Group documents for interface design as progressive disclosure.

                What a new tier has to bring

                Every tier beyond cheerio should have:

                • a defined page class it recovers
                • a measurable success rate after escalation
                • a bounded latency and cost profile
                • per-source instrumentation showing why pages reach it
                • a validation rule for its output

                If LLM-tier spend rises, inspect which sources and failure modes are causing escalation first. The likely fix is a cleaner source list, a source-specific extractor, better document classification, or a stronger article gate, not simply a cheaper model.

                Selection score

                quality × log1p(textLength) ranks gate survivors only

                The formula that picks a winner among the extraction candidates a page produced.

                A gate-passing candidate always beats a higher-scoring one that failed. The score only ranks survivors.

                A page can produce multiple extraction candidates, one per tier that attempted and returned something. Picking a winner runs in two layers. First, the article gate, the six hard-reject thresholds, filters candidates into pass and fail. Second, among candidates that passed, the selection score picks the highest-scoring one.

                A failing candidate never wins regardless of its score. A passing candidate never loses to a failing one, regardless of how much longer or how much higher-scoring the failing candidate looks. This two-stage design exists because length and quality are not the same signal, and neither alone is trustworthy.

                What the split decides

                • Which tier's output gets stored. If readability and cheerio both produce passing candidates for the same page, the score decides between them.
                • Resistance to a specific failure mode. A candidate that is merely long, full of boilerplate, navigation text, or repeated content, should not automatically outrank a shorter, cleaner one.
                • What "winning" means. Winning is never about beating the gate; the gate is a precondition. Winning is about being the best candidate among the ones that already cleared it.

                Trust the score only among survivors. If you find yourself wanting to change the score to fix a bad pick, check first whether the actual problem is a gate that let a low-quality candidate through.

                Decision rule If the wrong candidate is winning for a page, diagnose which stage is responsible before changing either. A bad candidate that is winning passed the gate, so the gate is the thing to tighten. A good candidate losing to a worse one means both passed, and the score is discriminating incorrectly.

                Show the technical deep diveHide the technical deep dive

                log1p compresses the reward for length so quality still decides close calls

                The formula multiplies a quality signal by log1p(textLength) rather than by raw length. A 4,000-word page scores roughly 1.2× a 1,000-word page under log1p, not 4×.

                Length still matters, since a longer article generally does carry more signal. But its influence flattens out fast. It functions as a modest tiebreaker rather than the dominant term.

                The counterfactual makes the design choice legible. Suppose the formula used raw textLength instead of log1p(textLength). A candidate padded with boilerplate would then systematically outscore a shorter, cleanly extracted candidate, purely because it has more characters.

                That padding is real: repeated navigation text, related-article link lists, comment sections dragged in by an imprecise extractor.

                Raw length would reward exactly the failure mode the tier chain and article gate are trying to filter out. That failure mode is extractors that grab everything on the page rather than the article itself. log1p keeps length as a real but bounded input, so it can't overwhelm the quality term.

                The gate is the filter; the score is the tiebreaker

                Selection runs in two passes over the candidates for a page:

                1. Gate pass: every candidate is checked against the six hard-reject thresholds (title length, word count, paragraph count, list:paragraph ratio, caps ratio, keyword stuffing). Any candidate failing even one threshold is dropped, independent of what its score would have been.
                2. Score pass: among the candidates that survived the gate, quality × log1p(textLength) ranks them, and the highest scorer is stored as the article.

                This ordering makes gate-pass precedence a real guarantee rather than a tendency. A failing candidate cannot win, because failing candidates are removed before scoring ever happens.

                Take a page that produces one candidate failing the gate that would have scored very high, and another passing the gate with a modest score. The modest passer wins outright. There is no threshold at which a high enough score overrides a gate failure.

                Selection score design comparison
                Why not score everything and take the top result
                A single ranking without a hard gate would let quality issues that the gate is specifically designed to catch, navigation-menu candidates, keyword-stuffed pages, compete purely on score. A sufficiently long or superficially well-formed bad candidate could still win. Splitting filtering from ranking removes that possibility structurally.
                Why not gate everything and pick arbitrarily among survivors
                Multiple tiers can all produce gate-passing candidates for the same page with real quality differences between them. readability's output and cheerio's output for the same article are rarely identical. An arbitrary pick (e.g. "first tier that passed") would leave quality on the table among candidates the gate has already approved.

                When a good candidate loses to a worse one, both passed the gate. That is when the quality or length weighting is the right thing to touch.

                Article gate

                six values, listed in the deep dive

                Six structural checks a page must pass before anything scores it.

                One failure rejects the page. The gate does not average, and it awards no partial credit, so an excellent word count cannot rescue a bad list-to-paragraph ratio. The six checks are title length, word count, paragraph count, list-to-paragraph ratio, caps ratio, and keyword-stuffing frequency.

                The gate runs before scoring, not alongside it. That ordering is the point: each threshold catches a structurally different failure, and a blended score would let any one of them through whenever strength elsewhere offset it.

                What the gate decides

                • What never gets scored. The gate is a precondition for the selection score. Nothing that fails it is ever scored or compared.
                • What the corpus is made of. Before any quality signal runs, the gate has already removed headline fragments, stub pages, link hubs, promo pages, shouting press releases, and SEO doorway pages.
                • Six separate tradeoffs, not one. Loosening a threshold admits one kind of junk. Tightening it drops one kind of real article. The six do not share a curve.

                Decision rule Move one threshold at a time, against one observed failure. Each check owns a specific junk pattern and a specific legitimate pattern, so "the gate feels too strict" names no threshold and cannot be acted on.

                Show the technical deep diveHide the technical deep dive

                Each threshold targets one publisher pathology

                No single quality theory produced these six. Each maps onto one way a page can look like an article without being one, or fail to look like one while being one.

                Each threshold decides which pages enter the corpus at all, and nothing rejected here re-enters the pipeline. Admitting more lets that kind of page compete for the top rank on a real query, which no reranker weight downstream can undo. Admitting fewer removes that kind of real article from every later stage permanently. Reach for more when a real article was wrongly rejected, and for fewer when that junk is reaching readers, always against a failure you have actually seen rather than a general feeling.

                ThresholdLive valueLoosening it lets inTightening it drops
                Title length≥ 20 charsHeadline fragments and section labelsReal short headlines, "OpenAI Ships GPT-6"
                Word count≥ 200 wordsarXiv abstracts, news briefs, stub pagesLegitimate short-form wire copy
                Paragraph count≥ 3Promo pages with 1 to 2 paragraphsQ&A and interview formats
                List:paragraph ratio≤ 10:1Navigation menus and link hubsLegitimate listicles
                Caps ratio≤ 30%Press-release shoutingTicker-heavy financial copy
                Keyword stuffing>5% freq. AND >10 occurrencesSEO doorway pagesSingle-entity profiles that legitimately repeat one name

                The list-to-paragraph ratio carries the most weight

                Losing this one threshold would admit the highest-volume category of obvious junk the gate exists to stop.

                • Navigation menus, category pages, and link hubs are the most common non-article shapes a crawler meets.
                • They share one signature: many list items, few real paragraphs. A ratio ceiling reads that signature directly.
                • No other threshold sees it. Word count and title length cannot tell a long nav menu from a long article, and caps ratio and keyword stuffing judge quality inside paragraphs rather than the shape of the page.

                Independent filters compose differently than a score

                The six thresholds are ANDed, not summed. A page must clear every one rather than accumulate enough combined strength across several.

                That changes how a publisher fares. Take a publisher whose pages sit right at the word-count floor and right at the caps-ratio ceiling. Both checks have to clear on the same page, so its rejection rate runs meaningfully higher than the two individual pass-rates would predict.

                An additive score behaves the opposite way. It lets strength on one signal pay for weakness on another, which is why the content quality floor is scored and this gate is not.

                Content quality floor

                0.5 CONTENT_QUALITY_FLOOR · enforced range 0–1

                A single additive score built from seven metadata-completeness signals, layered on top of the article gate's structural thresholds.

                The floor sums seven metadata signals into one score, so strength on one signal can offset a missing one. The article gate works the other way. It is six independent pass/fail checks, and failing any one threshold is fatal regardless of the rest.

                Both extremes are broken, in opposite directions. A floor near 0 lets in real articles alongside metadata-poor noise. A floor near 1 excludes most independent publishers along with the noise it's trying to filter.

                What the floor decides

                • Corpus size and metadata completeness. A low floor maximizes coverage at the cost of attribution quality. A high floor maximizes attribution quality at the cost of publisher coverage.
                • What entity extraction has to work with downstream. A page that passes the gate but carries no author or date metadata still produces entities and relations, just ones with no attribution to anchor them.
                • Which publisher classes are reachable at all. Independent blogs and academic sites that don't emit Open Graph tags are excluded outright once the floor demands that signal.

                Decision rule Move the floor in response to a named failure mode, not a general sense that quality is off. Before moving it either direction, run the new value in log-only mode and check the rejection set against real traffic. Never tune this floor blind.

                Show the technical deep diveHide the technical deep dive

                The formula rewards metadata completeness, not article quality directly

                Seven signals contribute to the score:

                • 300+ words contributes +0.3
                • a title over 10 characters contributes +0.2
                • an excerpt over 50 characters contributes +0.1
                • author presence contributes +0.1
                • published-date presence contributes +0.1
                • Open Graph metadata contributes +0.1
                • an Open Graph image contributes +0.1

                The maximum achievable score is 1.0, and reaching it requires every single signal to be present simultaneously.

                None of the seven components measures whether the article is well-written, accurate, or even coherent. The score is a proxy for "this page has the shape of professionally published content." It is built entirely from signals a real content-management system tends to emit and a scraped aggregator repost tends to drop.

                Length and title strength carry the largest weights, +0.3 and +0.2, because those are the hardest signals for a low-effort page to fake. The four remaining signals are each worth only +0.1 and are individually replaceable. Together they distinguish a page with real publishing infrastructure behind it from one without.

                0.0 – 0.3structural only
                0.4 – 0.6live · 0.5
                0.7 – 0.9metadata-strict
                1.0empty corpus
                0.0 – 0.3
                Only the structural article-gate thresholds apply; the quality score adds essentially no additional filtering. Capability: maximum source coverage, since almost any page that clears the gate is admitted regardless of metadata. Quality: metadata-poor aggregator reposts dominate the corpus. Downstream entity extraction produces attribution with no authors and no dates to anchor it, which weakens every claim's traceability.
                0.4 – 0.6 Live 0.5
                Requires roughly "long enough, plus a real title, plus one further metadata signal." This is the balance point: a page has to look like a real article by more than a single measure. A page can't pass purely on length with zero attribution, or purely on having a title with almost no content.
                0.7 – 0.9
                Demands nearly complete metadata, most of the seven signals present simultaneously. Quality: resulting corpus is very high in metadata completeness. Capability: excludes most independent blogs and any publisher not emitting Open Graph tags. That is a large fraction of technical primary sources, including personal blogs and some academic sites that publish real, citable content without a modern CMS's metadata conventions.
                1.0
                Unsatisfiable in practice: the maximum achievable score is exactly 1.0, and reaching it requires every one of the seven signals present on the same page with no slack. Effect: the corpus goes empty, because no realistic floor should ever be set here.

                Measure a proposed floor before committing it

                Raising or lowering CONTENT_QUALITY_FLOOR in production changes what gets rejected immediately and irreversibly for that page. A rejected page isn't retried later at a different floor unless it's re-crawled.

                ENFORCE_CONTENT_QUALITY_GATE=false exists specifically to break that irreversibility. It runs the same scoring logic and logs the score for every page, but rejects nothing.

                That makes it possible to compute what a candidate floor value would have done to real, current traffic: how many pages would be rejected, and which publishers they'd come from, before that floor is ever live.

                Operational pattern Test a new floor value in log-only mode first, review which pages and publishers it would have rejected, and only then move the enforced value. This converts a "did that break coverage" question, answerable only after the fact, into a "will that break coverage" question, answerable before the change ships.

                When to raise and when to lower

                • Raise it when metadata-poor aggregator noise is visibly diluting the corpus.
                • Lower it when a specific class of legitimate source is being excluded: an independent blog, an academic site without Open Graph tags.

                Set the floor to the point where a page has to be a real article by more than one measure, not just one.

                LLM block cap

                80,000 characters tier 6 only, after every cheaper tier fails

                The most text the LLM extraction tier sends to the model in a single block.

                Raising the block cap mainly raises the cost of processing junk, because a real article already fits inside 80,000 characters.

                The LLM extraction tier is tier 6 in the chain, the last resort. It runs only when tiers 1–5 have all failed the article gate for a page. The model behind it is configured via GROQ_EXTRACTION_MODEL, currently openai/gpt-oss-20b, a small and fast open-weight model.

                This tier is the most expensive one in the chain, and it runs only on pages every cheaper tier already rejected. So the block cap is really a statement about how much a single junk page is allowed to cost in the worst case. The governing tradeoff is context preservation versus worst-case spend.

                What the cap decides

                • Whether a long article gets processed as one coherent piece or split. A cap too small forces long pieces into multiple blocks, and the model loses the ability to connect facts across block boundaries.
                • Worst-case cost per page. The cap is the maximum amount of text a junk page can force the model to process in a single call. Such a page has already failed every cheap tier, and even the gate.
                • Whether the cap even matters for real content. Real news articles are essentially never long enough to approach the upper end of the practical range. The cap's main effect in practice is on junk-page cost, not on genuine article coverage.

                Decision rule Size the cap to the longest real article you need to extract in one pass, and no further. Raise it only if you can point to actual investigative or long-form pieces being split and losing context at the current value. Don't raise it defensively "just in case."

                Show the technical deep diveHide the technical deep dive

                Splitting breaks cross-section context; the cap exists to avoid needing to split

                When a long investigative piece exceeds the block cap, it gets split into multiple blocks and processed separately. Below roughly 20,000 characters, this becomes a real problem: the model extracting facts from a later block has lost the context established earlier in the piece.

                A fact like "the company reported the delay" can end up in a block that never introduced which company. The antecedent is in an earlier block the model isn't seeing. The extraction can still run, but individual facts start losing their subject.

                < ~20kcontext loss
                80,000live
                > ~120kno gain
                Below ~20,000 chars
                Long investigative pieces get split into multiple blocks, and the model loses cross-section context between them. Quality: mid-article facts lose their subject, since the antecedent that would resolve a reference like "the company" can fall in a different block than the reference itself.
                80,000 chars Live
                Covers essentially every real news article in a single pass, so cross-section context is preserved for the pieces this tier is actually meant to rescue. Cost: the worst case is spending a full 80,000-character prompt on a junk page that every cheap tier has already failed on. Because this tier only runs after the cheap tiers have already rejected the page, that worst case is not hypothetical. It is what happens on every page that reaches tier 6 and still fails to extract cleanly.
                Above ~120,000 chars
                Approaches context-window limits on smaller models like the one this tier uses, and raises tail latency, without recovering meaningfully more real articles. Real news articles are essentially never this long, so the extra headroom has no corresponding upside in coverage.

                Why headroom above the cap buys nothing

                Tier 6 only ever runs on pages that every cheaper tier has already rejected. A larger cap mainly increases the worst-case cost of processing a junk page, rather than the coverage of real articles.

                Extra headroom above the longest real article buys no additional coverage, only additional worst-case cost.

                Stage 2 · Chunk & embed

                Chunk geometry decides whether retrieval finds the right passage or only the right document

                The chunk is the retrieval unit. Its size is not a performance setting; it is a statement about what question the system answers well.

                Target chunk size

                800 tokens cl100k_base BPE · practical 128–2,048

                The token count the chunker aims for when cutting an article into retrieval units.

                Chunk size sets what the retriever can return. A small chunk points at almost the exact sentence that answers a query. A large chunk points at almost the whole story. The system cannot do both at once.

                The chunk boundary is also the boundary of what one embedding vector represents. That makes this setting a choice about which kind of question gets answered well, not a performance dial.

                Decision rule Smaller chunks retrieve the right sentence and lose the context; larger chunks retrieve the right document and lose the sentence. Move down the ladder only if factoid-style queries are measurably under-precise at 800. Move up only if the workload shifts toward topic summarization.

                Show the technical deep diveHide the technical deep dive

                Why the boundary sits at 800 tokens (roughly 3–5 paragraphs)

                128 – 256sentence
                400 – 600paragraph
                800live · 3–5 ¶
                1,200 – 2,048section
                Roughly 3–5 paragraphs. One chunk usually contains a claim plus its attribution and its qualifier, which is the minimum unit a grounded answer needs. Accuracy: slightly diluted on narrow factoid queries versus 256.

                Chunk overlap

                100 tokens · 12.5% practical 0–25% of target

                The stretch of text each chunk repeats from the one before it.

                Overlap keeps a claim that spans a chunk boundary retrievable in at least one chunk. The price is storage and embedding cost proportional to the duplication.

                Chunking always cuts somewhere. Without overlap, a cut through the middle of a sentence that connects a claim to its source or its date leaves that claim unretrievable by either chunk.

                Decision rule Raise overlap only if boundary-spanning claims are observed going unretrieved. Lower it only if top-K results are visibly dominated by near-duplicate passages. 25%+ has no recorded upside. It only adds cost.

                Show the technical deep diveHide the technical deep dive

                How much duplication is worth paying for

                0%boundary loss
                10 – 15%live · 12.5%
                25%+near-dupe crowding
                0%
                No duplication. Quality: any claim spanning a cut is unretrievable by either chunk, because neither contains the whole statement. This is the most common silent RAG defect.
                10 – 15% Live 12.5%
                A boundary-spanning sentence survives in at least one chunk. Cost ~12% more storage and embedding spend.
                25%+
                Accuracy: near-duplicate chunks crowd the top-K, so a 10-result page returns 4 distinct passages. Cost rises proportionally with no recall gain.

                Critical companion setting Overlap tokens are excluded from the pooling weight (coreTokenCount), so duplicated text cannot double-count when chunk vectors pool into the article vector. Without that exclusion, raising overlap silently biases the article-level embedding toward whatever text sits on chunk boundaries.

                Hard ceiling, boundary safety

                1,000 tokens · 0.55 snap three checks, listed in the deep dive

                Three checks that hold every chunk under the embedding model's input limit without corrupting the text at the cut.

                A failure in any one of the three stays invisible until an embedding call rejects a payload, or a stored chunk turns out corrupted. That is why they function as a single safety contract rather than three independent settings.

                Decision rule These three are not independent tuning knobs. They are a single boundary-safety contract. hardMaxTokens tracks the embedding model's actual input limit whenever that model changes. The 0.55 floor and the UTF-8 check should not move without evidence that chunk-size variance or stored-text corruption has actually appeared.

                Show the technical deep diveHide the technical deep dive

                What each safeguard actually enforces

                SafeguardWhat it changesTradeoff
                hardMaxTokens = 1000 Re-cuts backward until the provider text (prefix + body) fits under the embedding model's input limit. Bounds the prompt, not the body. The Title: …\n\nContent:\n prefix is counted, so the effective body budget is 800 minus the prefix, not 800.
                Paragraph-snap floor = 0.55 × targetBodyTokens Takes the cut at the last paragraph boundary past 55% of the window instead of the raw token limit. Below 0.55, chunks vary too widely in size; above roughly 0.8 most cuts fall back to the raw window edge and paragraph awareness stops doing anything.
                Lossless UTF-8 boundary check Confirms a BPE token boundary is also a character boundary before cutting. Skipping it corrupts a small fraction of chunks in a way no metric surfaces and no reindex repairs, because the damage, a stray U+FFFD replacement character, is written into the stored text.

                Embedding model dimensions

                text-embedding-3-small · 1,536-d the cheapest per-token call in the pipeline

                The length of the vector the embedding model produces for each chunk.

                More embedding dimensions buy accuracy on fine semantic distinctions, at a direct cost in storage, index memory, and search latency. For entity- and topic-shaped news queries, that extra room buys less than it costs.

                Every extra dimension gives the vector space more room to separate meanings that are close but not identical. Every step of an HNSW traversal pays for that room.

                Decision rule Move to 3,072 only when a workload demonstrably needs fine semantic distinction the 1,536-d index is missing. The switch costs a full rebuild, so it should follow evidence, not precede it.

                Show the technical deep diveHide the technical deep dive

                What dimensionality changes at each tier

                384 – 768small open
                1,536live · 3-small
                3,0723-large
                384 – 768 dims
                Speed 2–4× faster search, 2–4× less storage. Accuracy measurably lower on nuanced semantic distinctions.
                1,536 Live
                Cost roughly 1/6th of text-embedding-3-large. Sufficient for topical news retrieval, where queries are entity- and topic-shaped rather than subtle.
                3,072
                Accuracy higher on fine distinctions. Cost 2× storage, 2× index RAM, ~6.5× embedding spend, and slower HNSW traversal at every layer.

                Capability boundary The dimension is fixed at table creation. Changing it is a full table rebuild plus a full re-embed of the corpus, so treat it as an architecture decision, not a tuning knob.

                Embedding identity stamp

                no tuning range

                The model name and version recorded on every embedded row.

                The identity stamp is what makes an embedding-model swap visible. Nothing about a vector's shape reveals which model produced it.

                Two different embedding models can output vectors of the identical shape while placing them in entirely different geometric spaces. A 1,536-d vector from one model is not comparable to a 1,536-d vector from another.

                Decision rule The stamp has no tuning range. It is a correctness requirement for any system that might ever change embedding models. Its absence is only acceptable if the embedding model is guaranteed never to change, which is not a guarantee most systems can make.

                Show the technical deep diveHide the technical deep dive

                What happens with and without the stamp

                1. Without a stamp a model swap goes untracked. Every health metric stays green because the new vectors are the right shape. Retrieval quality degrades anyway, because they occupy the wrong space. There is no automatic signal that anything changed.
                2. With a stamp a version bump marks every existing row stale. The backfill script reads that stale flag and re-embeds those rows automatically. Capability: this is what makes an embedding-model migration a routine operation rather than a corpus-wide outage.

                Chunk-to-article pooling

                core-token weighted three approaches compared in the deep dive

                The rule that sets how much each chunk's vector counts when chunk vectors combine into one article vector.

                Pooling weight determines whether the article-level vector represents the article's actual content, or just whichever chunk happened to be shaped a certain way.

                An article-level vector is built by combining its chunk vectors into one. How much each chunk counts in that combination controls what the merged vector ends up representing.

                Decision rule Keep core-token weighting as the default for any product surface that shows results as articles. Switch to chunk-only retrieval only when the interface is built to display and deduplicate passages directly. Otherwise, the missing article vector has to be reconstructed somewhere else.

                Show the technical deep diveHide the technical deep dive

                How the pooled vector is built

                article_vector = normalize( Σ (chunk_vectori × core_token_counti) )

                Chunk vectors are L2-normalized, weighted by core-token count, summed, then re-normalized into one article vector.

                ApproachWhat it changesTradeoff
                Uniform weighting Every chunk counts equally regardless of size. A 40-token tail chunk counts as much as a 780-token body chunk. Accuracy: article vectors drift toward whatever the article ends with.
                Core-token weighting Live Each chunk contributes in proportion to the unique text it covers. Requires excluding overlap tokens from the weight (see Chunk overlap) so duplicated text doesn't double-count.
                Skip pooling entirely Retrieval returns chunks directly; no article-level vector exists. Accuracy higher precision. UX: results are passages, not articles, so the interface must reconstruct article identity for display and deduplication.

                Stage 3 · Extract & ground

                Extraction settings decide whether a fact is checkable or merely plausible

                The difference between a knowledge graph and a hallucination store is one enforced constraint: every persisted claim resolves to a verbatim span in the original text.

                Extraction block size

                8,000 / 256 / 10,000 same chunker, ~10× the retrieval config

                The span of article text the extraction model reads in a single call.

                Extraction and retrieval cut the same article with the same chunker, then size the window differently. They optimize for opposite things.

                Retrieval wants a narrow window, so a search matches precisely. Extraction wants a wide one. The model has to see two facts that belong to the same relationship at once, such as "Company A acquired Company B" and "the deal closed Tuesday."

                Decision rule Keep the extraction block near whole-article size (8,000) as long as one model call reliably covers a full news article. Shrink it only if cost per article becomes the binding constraint. Grow it only if articles routinely exceed the window, and watch for the attention-decay symptom, missed mid-document entities, before pushing past 10,000.

                Show the technical deep diveHide the technical deep dive

                Why 800 tokens (the retrieval size) breaks extraction

                1,000 – 4,000relations break
                8,000live · whole article
                10,000+attention decay
                Why not reuse 800
                Extraction needs to see "Company A acquired Company B" and "the deal closed Tuesday" in the same window to emit one relationship with a date. At 800 tokens those land in different blocks and the relation is never formed.
                1,000 – 4,000
                Quality cross-paragraph relations degrade. Cost more model calls per article.
                8,000 Live
                One call covers essentially any news article, so the model sees the whole story.
                10,000+
                Approaches model context limits, and attention over long inputs degrades before the limit is reached. Accuracy mid-document entities get missed.

                Design principle worth copying One chunker, two configurations. Retrieval optimizes for precision, extraction optimizes for context, and neither compromises for the other.

                Extraction model

                openai/gpt-oss-120b KG_EXTRACTION_MODEL · temp 0 · max_tokens 1500 · strict json_schema

                The model that reads each extraction block and emits the entities and relations the graph stores.

                Relation typing, not entity spotting, is where model capability actually matters. Finding an entity in text is comparatively easy. Deciding how two entities relate, and typing that relationship correctly, is hard. That is why extraction runs at the 120B tier.

                The settings around the model choice exist to make extraction deterministic and reliable, not just accurate. Three of them do that work: temperature, the token cap, and the output-validation mode.

                Decision rule Stay at 120B while relation typing is the accuracy bottleneck. Drop to a smaller model only if the workload becomes entity-only. Keep temperature at 0 unless re-extraction is deliberately being used to sample alternative graphs. Any nonzero value makes drift and real corpus change indistinguishable.

                Show the technical deep diveHide the technical deep dive

                Model size: what the extra capacity buys

                TierWhat it changesUse whenTradeoff
                Small (~8–20B) 80–200 ms latency, roughly half the input cost of the 120B tier. Entity spotting only. Accuracy misses implicit relations and mis-types entities; weak for relationships.
                120B Live 200–500 ms latency, roughly twice the input cost of the small tier. Relation extraction, the harder task. Accuracy materially better on relation typing.

                Determinism and output-validity settings

                temperature: 0
                Deterministic. The same article always yields the same graph, so a re-run is a repair rather than a second opinion. At 0.2+, re-extraction produces drift indistinguishable from real corpus change.
                max_tokens: 1500
                Caps entities-plus-relations per block. Raising it recovers long-tail entities from dense articles; lowering it truncates JSON mid-object and fails the schema.
                Strict json_schema vs. json_object
                Strict mode rejects malformed output at the provider rather than at your parser, which eliminates the retry-on-parse-failure loop entirely for models that support it.

                Confidence thresholds

                entity 0.70 · relation 0.60 defaults 0.8 / 0.7 when the model omits a value

                The minimum confidence an extracted entity or relation needs before the graph keeps it.

                The two thresholds don't move together. An entity and a relation fail differently when they're wrong: a bad entity pollutes the graph permanently, a bad relation is cheap to drop. So the relation bar sits below the entity bar.

                Both directions have a failure mode: set both too low and the graph fills with noise. Set both too high and the graph goes sparse enough that real queries return nothing.

                Decision rule Raise the entity threshold, not the relation threshold, if the entity namespace is accumulating fragments. Entity errors are permanent. Lower the relation threshold if multi-hop queries are returning empty; that failure mode means the graph went too sparse.

                Show the technical deep diveHide the technical deep dive

                Why the two thresholds are asymmetric

                0.3 – 0.5noisy graph
                0.6 – 0.7live
                0.85+sparse graph
                The relationship bar sits lower than the entity bar deliberately. An orphan entity pollutes the entity namespace permanently, while a weak relation is cheaper to drop later.

                Quote grounding

                exact | whitespace-normalized

                The requirement that every stored claim resolve to a verbatim slice of the source text.

                A claim is only as trustworthy as the mechanism that connects it back to the article it came from. Grounding is that mechanism.

                Every claim's quote has to be a verbatim slice of the source text. The slice is taken from the article itself, rather than on the model's word.

                Decision rule Grounding is a correctness floor, not a tuning knob. The only adjustable part is how much formatting variance the normalization step tolerates before falling back to a failed match. Any request to loosen matching toward "semantic" or "fuzzy" is a request to make claims unfalsifiable again.

                Show the technical deep diveHide the technical deep dive

                How a quote gets verified against the source

                1. No grounding requirement. The model asserts, you store. Unfalsifiable claims accumulate with no way to audit them.
                2. Fuzzy or semantic matching (rejected). Quality worse than no grounding, because a paraphrase "resolves" and the resulting span looks verified while pointing at text that does not say it.
                3. Exact match, then whitespace-normalized fallback Live. Whitespace normalization handles real formatting variance (newlines, non-breaking spaces) without admitting paraphrase.
                4. Sliced from the original text, never the model's echo. Closes the remaining gap: a model can otherwise return a subtly altered quote that still passes a match against its own output.

                What grounding buys Capability every claim carries block_id, char_start, char_end, so any answer is traceable to a character offset in the source.

                Retry policy

                3 attempts · 30s backoff cap honors Retry-After

                How many times extraction re-calls the provider after a failed request, and how long it may wait between attempts.

                Extraction calls a provider that occasionally rate-limits or hiccups. Retry policy decides what a transient error becomes: an invisible gap in the graph, a brief delay, or an unbounded backlog.

                The live policy bounds worst-case per-article latency while surviving normal rate-limit turbulence.

                Decision rule Keep the 3-attempt/30s cap as the default. It's the one policy tested against both failure modes: silent drops at zero retries, unbounded backlog at infinite retries. Only revisit the cap if a sustained outage is observed to still produce dropped articles within 3 attempts.

                Show the technical deep diveHide the technical deep dive

                What each retry policy actually does under provider failure

                0no retry
                3 attemptslive · 30s cap
                unboundedno cap
                No retry
                A provider rate-limit spike silently drops articles from the graph, and the gap is invisible because nothing errored.
                3 attempts / 30s cap Live
                Survives normal rate-limit turbulence. Worst-case per-article latency is bounded, so a backlog drains predictably. Honors the provider's Retry-After header.
                Unbounded retry
                A sustained provider outage converts into an unbounded queue backlog.

                Stage 4 · Store & index

                Index parameters buy recall with build time and memory, and only one is changeable after the index exists

                Two of the three HNSW parameters are baked in at CREATE INDEX. Choose them knowing that changing your mind means a rebuild.

                Distance operator

                vector_cosine_ops must match the operator used in the query

                The operator class that decides which notion of similarity the index computes.

                The operator class never gets rechecked at query time. Get it wrong, or let it drift out of sync with the operator used in the query, and Postgres doesn't error. It silently falls back to a sequential scan, and the index stops doing anything.

                vector_cosine_ops is the operator class configured here, chosen to match how the embedding model normalizes its vectors.

                Decision rule Pick the operator class from what the embedding model documents about normalization, not from a benchmark. Then verify the query-side operator matches before trusting any recall measurement, since a mismatch doesn't throw an error. Recall numbers from a mismatched query are measuring a sequential scan, not the index.

                Show the technical deep diveHide the technical deep dive

                Matching the operator to how the embedding model normalizes vectors

                OpenAI documents text-embedding-3-* as cosine-normalized, which is why vector_cosine_ops is correct here. It compares angle only and ignores magnitude, so it doesn't matter that normalization already stripped magnitude information out. The other two operator classes each assume something about magnitude that may not hold.

                Operator classWhat it comparesUse whenTradeoff
                vector_cosine_ops Live Angle only, magnitude-independent The embedding model documents cosine-normalized vectors, like text-embedding-3-* None for normalized embeddings; this is the matching choice
                vector_ip_ops Raw inner product Vectors are already unit-length and the extra speed matters Wrong result the moment any vector isn't unit-length
                vector_l2_ops Euclidean distance Magnitude itself carries meaning for the comparison Document length leaks into the similarity score

                m: connections per node

                16 on the chunk index, 24 on the article index

                The number of edges each node gets in the HNSW graph.

                Those edges are the graph's connectivity, and connectivity is what recall depends on.

                The live values differ by index for a reason. Chunks run the pgvector default of 16. The 1,536-dimension article-embedding index runs 24, traded up deliberately for better recall at that dimensionality.

                Push m too low and the graph becomes too sparse to navigate reliably. Push it too high and the index can outgrow RAM.

                Decision rule Raise m only after ef_search is already high and recall is still short. Raising ef_search is free and reversible; raising m means a rebuild.

                Show the technical deep diveHide the technical deep dive

                Why higher-dimensional vectors get a higher m

                Index memory grows roughly linearly with m, and so does build time and insert latency. m is a cost paid continuously, not once.

                At 1,536 dimensions the article index needs more edges per node to keep the graph navigable than the chunk index does. That is why it runs 24 against the chunk index's default of 16.

                2 – 8recall collapse
                16 – 32live · 16 / 24
                48 – 100RAM overflow
                The documented balance point. Index roughly 1.2–1.5× the raw vector size.

                ef_construction

                64 on the chunk index, 100 on the article index

                The size of the candidate list the build process searches while inserting each node into the graph.

                A graph built with too small a queue stays permanently degraded. No later query-time tuning repairs it, because the missing edges were never created. Unlike ef_search, there is no way to fix ef_construction after the fact.

                Chunks build at the pgvector default of 64. The article index builds at 100, traded up for a better graph at the cost of slower inserts.

                Decision rule Size ef_construction for the graph you want permanently, not for today's build-time budget. Underspending here is the expensive mistake, because it can't be recovered without a full rebuild.

                Show the technical deep diveHide the technical deep dive

                Why a bad build can't be tuned away later

                Every future query pays for whatever graph quality ef_construction bought at build time. That makes it the one parameter here where the mistake to avoid is going too low, not too high.

                Going too high has a real cost too: a large corpus can take hours to build with the ingest pipeline stalled underneath it. That cost is bounded and known in advance, unlike a permanently damaged graph.

                4 – 32permanent damage
                64 – 200live · 64 / 100
                400 – 1000hours to build
                Adequate for corpora in the tens of thousands.

                Clustering similarity

                0.75 CLUSTERING_SIMILARITY_THRESHOLD · validated 0–1 at load

                How similar two articles must be before the system treats them as coverage of the same story rather than two different stories.

                0.75 sits just above the near-duplicate band. A cluster therefore means "same story, different outlets" rather than "literally the same article."

                Move the threshold and you're trading cluster size against how much you can trust the "related coverage" panel.

                Decision rule Move the threshold down only if the related-coverage panel is missing stories it should catch, and up only if it's merging stories that aren't actually the same. Either direction changes what a cluster means, not just how many form.

                Show the technical deep diveHide the technical deep dive

                Sitting just above the near-duplicate band on purpose

                0.75 is deliberately close to the near-duplicate threshold used elsewhere in the pipeline: 0.7, the dedup constant. A cluster therefore reads as genuinely related coverage rather than a loose topical grouping.

                Moving the threshold in either direction changes what a cluster means, not just how many articles land in one.

                ThresholdWhat it changesTradeoff
                0.5 – 0.65 Capability large clusters Quality unrelated stories merge and the "related coverage" panel becomes untrustworthy
                0.75 Live Sits just above the near-duplicate band A cluster means "same story, different outlets," not "same article"
                0.85+ Quality very tight clusters Capability most articles become singletons and the clustering feature stops earning its cost

                Stage 5 · Retrieve & rank

                Retrieval weights decide which failure mode the user sees when a query is hard

                No weighting is correct for all queries. This system picks a strategy per query, then blends it against live index health.

                Similarity floor

                0.25 cosine bands describe this corpus, not general guidance

                The lowest score a stored chunk may have before search drops it from the results.

                Ranking always produces a first place. It has no way to say that first place is still wrong. The floor is the part that can: it drops a chunk that scored too low even when that chunk ranked first. Ask a corpus of AI news how to rotate database credentials and it will hand back its five nearest chunks, because five chunks are always nearest. The floor is what makes an empty answer possible.

                Decision rule Set the floor just above the top score of queries you know the corpus cannot answer. Measure that ceiling on the corpus you are running, because cosine scores do not carry across embedding models or corpora.

                Show the technical deep diveHide the technical deep dive

                What the floor changes

                Hold the corpus and the query fixed and move only the floor. The corpus never changes. What changes is which question the index can answer without misleading the reader.

                QueryFloor 0.40Floor 0.25 (live)Floor 0.10
                Broad, answerableNothingRelated articlesRelated articles plus weak matches
                Off topicNothingNothingConfident, irrelevant results
                What the reader concludesSearch is brokenSearch found what existsSearch cannot be trusted

                Score bands for this model on this corpus

                These bands describe OpenAI text-embedding-3-small over news articles. They describe one system.

                BandWhat a score there meansPutting the floor here
                0.70+Near duplicate: same story, another outletReturns almost nothing. This band is for dedup
                0.40 to 0.70Highly related: same story, different angleHigh precision, and empty results on fair questions
                0.25 to 0.40Clearly related: same topic, overlapping entitiesThe live floor sits at the bottom of this band
                0.10 to 0.25Loosely related: shared domain, weak overlapHigh recall, and readers meet plausible wrong answers
                under 0.10NoiseNo filtering. Every query returns something wrong

                Why the floor sits at the bottom of its band

                One bound is recorded and the other is not, which is worth saying plainly on a page about how well numbers are known.

                Off-topic queries top out around 0.08 to 0.09 on this corpus. That sets the lowest defensible floor. Nothing records where fair questions begin coming back empty, so there is no measured upper bound: 0.40 is the label on the next band, not an observed cutoff.

                What can be said is that 0.25 is the lowest value clearing the noise. Raising a floor trades recall for precision, because a reader who gets "no results" for a fair question decides the search is broken.

                How well this is known The source comment says empirical tests returned 0.08 to 0.09, so it claims a run. No fixture, eval or log in the repository carries that number. No sample size, corpus snapshot or date sits behind it. Treat it as the author's calibration note, and re-measure before relying on it.

                The constant kept deliberately separate

                SEMANTIC_SIMILARITY_NEAR_DUPLICATE = 0.7 belongs to the dedup pipeline alone. Holding it apart from the search floor prevents one specific failure: someone tightens duplicate detection, a single shared number moves, and search quietly returns nothing while every component still reports healthy.

                What carries to another system

                The principle carries. A retrieval system needs an absolute relevance gate, because ranking alone cannot say that every candidate is wrong.

                The method carries. Derive the gate from the top score of queries you know should fail, and record that ceiling next to the floor so the number stays re-derivable.

                The band does not carry either, and the reason matters. The source comment credits OpenAI's embeddings guidance with putting useful relatedness near 0.2 to 0.3. That guidance says no such thing. It recommends cosine similarity and adds that the choice of distance function rarely matters, and it publishes no threshold at all. No vendor number stands behind this band. Checked against OpenAI's embeddings guide on 2026-08-24.

                The exact value does not carry. 0.25 describes this chunking and this corpus. Cosine scores are not comparable across embedding models. Across distance measures the difference is scale, not ranking: these embeddings are unit length, so cosine, dot product and Euclidean distance order results identically. A floor of 0.25 in cosine is simply not the number 0.25 in another measure.

                Strategy weights

                six strategies, router-selected; the weights are listed in the deep dive

                The per-query balance between vector similarity and keyword match.

                The router sends each query to one of six weighting strategies. Each balances vector similarity against keyword match differently, and the choice decides which failure the user meets.

                High-vector weighting answers "what is this about." High-keyword weighting answers "where does this exact string appear." Those two questions fail in opposite ways: a vector-heavy miss returns related-but-wrong results, a keyword-heavy miss returns nothing.

                Decision rule Choose a strategy by the failure mode a given query type can tolerate, not by expected score. The table's "Fails when" column, not any single weight, is what predicts whether a strategy fits.

                Show the technical deep diveHide the technical deep dive

                Why keyword-leaning defaults are the safer failure mode early on

                Users tolerate empty results better than confidently wrong ones. That is the real argument for keyword-leaning defaults early in a system's life.

                The live default, hybrid-weighted, sits at 0.6 vector / 0.4 keyword for that reason. It rarely fails badly and rarely wins decisively, a safer place to sit than either extreme.

                Strategyvec / kwentitytemporaltimeoutFails when
                conceptual-deep0.9 / 0.10.050.0515sQuery contains an exact term (a model name, a ticker) that keyword would have nailed
                vector-primary0.8 / 0.20.100.1010sRare proper nouns absent from embedding training data
                hybrid-weighted default0.6 / 0.40.150.208sRarely fails badly; rarely wins decisively
                entity-boosted0.4 / 0.60.400.107sEntity extraction missed the entity in the query
                temporal-focused0.3 / 0.70.100.506sRecency swamps relevance, today's weak match beats last week's strong one
                keyword-primary0.2 / 0.80.200.305sParaphrased queries return nothing
                Timeout scaling
                Timeouts track expected work: conceptual-deep gets 15s because deep vector traversal is slow; keyword-primary gets 5s because full-text search that hasn't returned in 5s won't return usefully.
                entityBoost · 0.05 – 0.40
                Additive bonus for query-entity matches. Above ~0.5 it dominates semantic relevance entirely and the system becomes an entity lookup.
                temporalBoost · 0.05 – 0.50
                Recency weight. At 0.5 a two-day-old article beats a materially better week-old one: correct for "what happened today," wrong for "how did X evolve."

                Degradation blend

                kw 0.5→0.8 · vec 0.5→0.0 interpolated by live vector-quality score

                An interpolation of the keyword/vector weight, driven by a live vector-quality score.

                The blend keeps search alive during an embedding backfill. As vector quality falls, keyword picks up the load, so the system stays usable instead of going dark.

                Without it, a sparse or partially-backfilled index would let a vector-heavy strategy starve recall completely. Conceptual-deep's 0.9 weight is the clearest case, and every component still reports healthy while it happens.

                Decision rule Treat this as required infrastructure whenever a live vector-quality signal exists to interpolate against, not as a nice-to-have. The alternative isn't slower search: it's search that goes silently dark while everything reports healthy.

                Show the technical deep diveHide the technical deep dive

                Why a healthy-looking system can still return nothing without this

                A partially backfilled index doesn't fail any health check. The vectors that exist are fine, there just aren't enough of them yet.

                That is precisely the condition conceptual-deep's 0.9 vector weight is blind to: it has no fallback of its own. The degradation blend supplies one, interpolating toward keyword-primary weighting as the live vector-quality score falls.

                Without it
                A sparse or partially-backfilled index makes conceptual-deep's 0.9 vector weight starve recall completely, and the system returns nothing while every component reports healthy.
                With it Live
                Degradation is graceful: as vector quality falls, keyword carries more load. Capability the system stays usable during an embedding backfill instead of going dark.

                Result caps

                200 schema · 100 chunk

                The size of the retrieval pool the reranker gets to work with.

                The reranker, not the fetch, is what limits how high the caps can usefully go. Retrieval cost rises sub-linearly as the cap grows, so the reranker is the bottleneck.

                Set the caps too low and the reranker has no real pool to improve on, so reranking stops paying for itself. Set them too high and payload size plus reranker token cost grow with no measurable quality gain.

                Decision rule Size the cap to what the reranker can meaningfully use, not to what the database can cheaply return. If quality plateaus while cost keeps climbing, the cap is already past where it helps.

                Show the technical deep diveHide the technical deep dive

                Sizing the pool to what the reranker, not the fetch, can use

                The fetch scales cheaply as the cap rises, so the real cost curve belongs to the reranker, not the database. That is why 100–200 sits where it does. It is the smallest pool that gives the reranker genuine material to re-rank, not the largest pool retrieval can cheaply return.

                10 – 25
                Speed fastest. Quality the reranker has too small a pool to improve on, so reranking stops paying for itself.
                100 – 200 Live
                Gives the reranker real material. Retrieval cost rises sub-linearly; the reranker is the bottleneck, not the fetch.
                500+
                Payload size and reranker token cost dominate with no measurable quality gain.

                Reranker depth

                topK 40 · gpt-oss-20b · temp 0 skipped below 5 results · circuit breaker · ENABLE_GROQ_RERANKING

                topK sets how many candidates the reranker actually scores.

                topK trades LLM cost against catching a correct result that vector search ranked poorly. Go too shallow and the genuinely best result, often sitting around rank 25, never gets seen. Go too deep and cost and latency grow linearly for gains that flatten out, because vector rank below roughly 50 is mostly noise.

                Decision rule Treat the kill switch's parsing as part of its spec, not an implementation detail. It is read through a boolean parser rather than a strict === 'false' check, because a corrupted "false\n" environment value would otherwise fail to disable a paid call. A kill switch that can fail silently is a decoration, not a control.

                Show the technical deep diveHide the technical deep dive

                topK, plus four supporting decisions that keep the reranker honest

                40 is deep enough to give a badly-ranked-but-correct result a second chance, at the cost of 40 title-plus-snippet pairs scored per query.

                The rest of this setting is four smaller decisions that all serve the same goal. They make sure the highest-volume, highest-visibility LLM call in the system is fast, cheap, stable, and genuinely optional when it isn't needed.

                10 – 20misses rank 25
                40live
                80+flat gains
                topK 10 – 20
                Speed fast, cheap. Quality the genuinely best result frequently sits at rank 25 and is never seen.
                topK 40 Live
                Deep enough that a badly-ranked-but-correct result gets a second chance. Costs 40 title-plus-snippet pairs per query.
                topK 80+
                Prompt size and latency grow linearly; gains flatten because vector rank below ~50 is mostly noise.
                Skip-below-5 rule
                LLM overhead exceeds any possible reordering benefit on 4 items, so narrow queries return without a model call at all.
                Model choice · 20B not 120B
                Relevance scoring is bounded and well-anchored, so the 20B model matches the 120B here. Cost this is the highest-volume LLM call in the system, so right-sizing it matters more than anywhere else.
                temperature: 0
                The same result set always ranks the same way. UX refreshing a page does not reshuffle results, which users read as instability.

                Cluster blend, query cache

                two independent settings, each with its own live value below

                Two knobs bundled in one setting: how much weight cluster membership gets against pure vector relevance, and how long a query's embedding stays cached.

                The blend adds a "this story is being widely covered" signal on top of relevance. It is live at 0.7 / 0.3.

                The cache exploits the fact that a fixed embedding model always returns the same vector for the same query, so caching it is free correctness-wise. It is live at 30d TTL · 10k cap.

                Decision rule Clear this cache on every EMBEDDING_VERSION bump without exception. A TTL this long is only safe because embeddings are deterministic for a fixed model version. Break that assumption and stale vectors from the old model keep serving for up to 30 days.

                Show the technical deep diveHide the technical deep dive

                Two knobs, two different failure modes if pushed too far

                Push the blend toward cluster weight and heavily-covered stories start dominating regardless of query fit, trading "relevant" for "popular."

                The cache has a different failure mode entirely. It isn't about the ratio, it's about staleness. A fixed TTL is safe only as long as nothing changes underneath it, which is why an embedding-model version bump has to invalidate it.

                Blend 1.0 / 0.0
                Pure semantic ranking; loses the "this story is being widely covered" signal.
                Blend 0.7 / 0.3 Live
                Cluster membership supplies mild corroboration weight.
                Blend 0.5 / 0.5
                Quality heavily-covered stories dominate regardless of query fit, pushing the system toward "popular" and away from "relevant."
                Query-embedding cache · 30 days, 10,000 entries
                Embeddings for a fixed model are deterministic, so a long TTL is free correctness-wise and removes 100–200 ms from every repeat query. The 10,000 cap is what makes the cache safe under a novel-query flood.

                Stage 6 · Form the answer

                The evidence window sets the hard ceiling on answer coverage

                Everything upstream feeds this stage. If the window is too small, a perfect retrieval pipeline still produces an incomplete answer.

                Evidence window

                50 articles × 800 chars ≈ 40,000 chars ≈ 10,000 tokens of evidence

                The hard ceiling on how much source text ever reaches the synthesis model.

                The window holds roughly 40,000 characters, about 10,000 tokens, which is 50 articles truncated to 800 characters each, so a fact sitting outside it cannot reach the answer however well retrieval and ranking placed it.

                The governing tradeoff is breadth against attention. Adding more evidence helps only up to the point where the model can still weigh all of it.

                Decision rule When an answer is missing a fact from a source already in the window, raise the per-article character cap before raising the article count. Raise the article count only when the missing fact belongs to a source the window never retrieved at all.

                Show the technical deep diveHide the technical deep dive

                Truncation depth constrains coverage more than article count

                Raising the per-article character limit recovers more real coverage than raising the article count does. Most articles put their most specific fact past the lede: a statistic, a quote, or a caveat that sits in paragraph four or six.

                At an 800-character cap, that fact is cut before the model ever sees it. Adding more 800-character articles instead adds more truncated ledes, which mostly repeats the same shallow view from additional sources rather than deepening any one of them.

                10 × 500single-source
                50 × 800live
                100 × 2,000attention decay
                10 articles × 500 chars
                Speed fastest synthesis, lowest cost. Quality single-source answers that miss contradicting coverage. Users spot the omission immediately when they know the story.
                50 × 800 Live
                Enough breadth to represent disagreement across outlets. Prompt assembly plus generation dominates total response time.
                100 × 2,000
                Quality better coverage on broad questions. Speed latency and cost roughly 5×. Accuracy long-context attention degrades, so mid-list articles get under-weighted. More evidence does not monotonically improve answers.

                The sharper constraint is the 800-character truncation rather than the 50-article count, because an article whose key fact sits in paragraph six contributes only its lede, so raising per-article characters buys depth from sources already retrieved while raising the count buys more truncated ledes.

                Model tier by complexity

                <0.25 → 20b · ≥0.25 → 120b

                The complexity score that decides which model answers a query.

                The score has only two real outcomes today, even though its configuration names three tiers. A score below 0.25 routes the query to the small model. A score at or above 0.25 routes it to the large one, because the middle and top tiers currently point at the same underlying model.

                Once that's fixed, the governing tradeoff is response quality against latency and cost. The large model handles genuine comparisons and multi-step analysis better, at a real speed and spend penalty.

                Decision rule Fix the tier collapse before tuning the threshold. Once every named tier resolves to a distinct model, move the 0.25 threshold only in response to a named failure. That means queries visibly too complex for the small model, or queries that didn't need the large one.

                Show the technical deep diveHide the technical deep dive

                Only the 0.25 threshold changes what actually runs

                Where the 0.25 threshold sits is the only score value that changes behavior right now. A simple query like "latest AI news" scores near 0 and stays on the cheap model.

                Lowering the threshold to roughly 0.1 sends nearly everything to the expensive tier, which buys little quality on queries that were never complex.

                Raising it to roughly 0.4 sends genuine comparisons to the weak tier instead. That's the failure users actually notice: the model gets a question it can't answer well.

                Seven signals add into the complexity score, and 0.25 is the line between the two models. Four of them clear that line on a single occurrence, so one strong signal is often the entire routing decision.

                • At or above 0.25 on one occurrence: analyze +0.40, compare +0.35, trend +0.30, rank +0.25.
                • Below 0.25 on one occurrence: velocity +0.20, entities +0.10 each to a 0.25 cap, domains +0.05 each to a 0.15 cap.
                Threshold at 0.25 Live
                A simple "latest AI news" query scores near 0 and routes to the cheap model. Lowering the threshold to roughly 0.1 sends nearly everything to the expensive tier. Raising it to roughly 0.4 sends genuine comparisons to the weak tier.

                Defect in the live config The middle and top tiers resolve to the same model today, so the three-way split is a routing signal with only two real destinations. A from-scratch build should either wire a genuinely larger top tier or collapse to two tiers and stop paying the complexity-scoring cost for a distinction that doesn't exist.

                Synthesis temperature

                0.2 prose · 0.1 charts · 0 structured

                The temperature used for each kind of model output.

                Temperature stays at 0 wherever output is parsed downstream. It rises slightly only where a person reads the output directly. That split exists because deviating from the model's single most likely token trades readability for factual risk.

                Structured output such as extraction, reranking, and routing runs at exactly 0, since a parser needs identical input to produce identical output every time. Prose synthesis runs a little higher, enough to read naturally without inventing detail.

                The governing tradeoff is readability against fabrication. Past a certain point, temperature stops the model from reliably staying inside the facts it was given.

                Decision rule Set temperature to 0 for anything parsed downstream, and to the lowest non-zero value that still reads naturally for anything a person reads directly. Treat any request to raise it further as a request to trade accuracy for style.

                Show the technical deep diveHide the technical deep dive

                Fabrication rate rises once temperature passes the 0.1 – 0.2 band

                Any temperature meaningfully above the 0.1 – 0.2 band measurably raises how often the model states a detail the source material doesn't support. Grounded prompts constrain the model less effectively as temperature rises.

                The same "only use the supplied evidence" instruction that reliably holds at 0.1 stops holding at 0.4. Two users asking the same question at that higher setting can get different facts back, not just different phrasing.

                0live · parsed
                0.1 – 0.2live · prose
                0.4+fabrication
                0 Live · extraction, rerank, routing
                Fully deterministic. Correct for anything structured: the same input must produce the same JSON.
                0.1 – 0.2 Live · prose
                Enough variation for readable narrative without inventing detail. The practical ceiling for grounded summarization.
                0.4+
                Accuracy fabrication rate rises measurably and grounded prompts stop reliably constraining output. UX two users asking the same question get different facts.

                Grounding, cache, rate limit

                three separate controls, values and keys in the deep dive

                Three controls carried in one setting: the grounding constraint, versioned cache keys, and the rate limit.

                Each closes a separate failure path that stays invisible until something triggers it.

                • The grounding constraint. It stops the model from inventing data when the requested output demands a shape the evidence can't fill.
                • Versioned cache keys. They make a prompt change take effect immediately instead of sitting behind stale cached answers.
                • The rate limit. It caps how much uncacheable, novel-query traffic can spend on paid model calls in a single spike.

                Decision rule Three independent controls, not one setting. Weaken the grounding constraint only if a specific output shape genuinely has no evidence to fill it. Bump a cache version only when a prompt change must reach users immediately. Raise the rate limit only after confirming the added spend is legitimate novel-query traffic, not a bug generating repeat cache misses.

                Show the technical deep diveHide the technical deep dive

                A named escape value for missing data prevents fabrication that phrasing can't

                A named escape value for missing data prevents fabrication that instruction phrasing alone cannot catch. The prompt hard-bounds the model: every numeric value must come from the supplied articles. Missing data sets dataQuality: "insufficient" rather than filling the gap.

                Without that explicit escape, the model fabricates to satisfy the requested output shape, because "return a chart" is a stronger instruction than "be accurate." This single affordance does more for grounding than any amount of "do not hallucinate" phrasing.

                ControlWithout itWith it (live)
                Grounding constraintModel fills missing data to satisfy the requested shapeMissing data sets dataQuality: "insufficient" instead
                Cache invalidationA synthesis prompt fix has no visible effect until the old cached answer expires naturallyGROUNDING_CACHE_VERSION and EVIDENCE_PRIORITIZATION_VERSION are part of the cache key, so a version bump is a deploy, not a wait
                Rate limitingUncacheable novel-query traffic spends uncapped on paid LLM callsINTELLIGENT_SEARCH_LIMIT enforced in Redis caps it

                Cache invalidation and rate limiting

                Cache invalidation works because GROUNDING_CACHE_VERSION and EVIDENCE_PRIORITIZATION_VERSION are part of the cache key. Capability prompt iteration becomes a deployable change rather than a wait, instead of something that quietly does nothing until old answers expire on their own.

                Rate limiting runs on INTELLIGENT_SEARCH_LIMIT enforced in Redis rather than in-process, because in-process limiting doesn't survive serverless scale-out. Novel-query traffic can't be cached by definition, so without a limit it spends uncapped on paid LLM calls. That's the specific failure mode that turns a traffic spike into a bill.

                Cross-cutting

                Four settings must move together, and moving one alone breaks the system quietly

                The levers are not independent. These couplings are the ones that fail without erroring.

                • Embedding model ↔ EMBEDDING_VERSION ↔ query cache Changing the model without bumping the version leaves old vectors indistinguishable from new ones, and a warm query cache keeps serving vectors from the old model's space. All three move together or retrieval degrades with every metric green.
                • Chunk size ↔ similarity floor The floor is calibrated against the score distribution a specific chunk size produces. Halving chunk size shifts the whole distribution, and the old floor now cuts good results.
                • m ↔ ef_construction ↔ ef_search Raising ef_search cannot compensate for an index built at low ef_construction. Diagnose in order: raise ef_search first (free, reversible), then rebuild with higher ef_construction, then raise m last.
                • Overlap ↔ pooling weight Raising overlap without core-token weighting biases every article vector toward boundary text. The two are one decision.

                Provenance

                Evidence limits

                • Verified by reading source at commit 071aaa8 Every live setting value, every threshold, every model id, and the tier chain ordering.
                • Verified against pgvector official documentation m default 16 / range 2–100; ef_construction default 64 / range 4–1000; hnsw.ef_search default 40 / range 1–1000.
                • Measured on this corpus and stated in source The cosine similarity bands (0.70+ / 0.40–0.70 / 0.25–0.40 / 0.10–0.25 / <0.10) and the 0.08–0.09 off-topic ceiling.
                • Not measured, engineering judgment The directional effects attributed to bands other than the live value. No A/B test in this repo compares chunk sizes, ef_search values, evidence-window sizes, or strategy weights. Those bands describe expected behaviour, not observed results in this system.
                • Not verified That the deployed database actually runs hnsw.ef_search = 100. The value appears in a migration; confirming it requires SHOW hnsw.ef_search against production.
                • Not measured Live corpus size, embedding row counts, and index memory footprint. All three change which m and ef_search values are appropriate.