Tutorial Examples LLM Structured Extraction Incremental Processing Postgres ~4 min read

What HackerNews is talking about: trending topics with an LLM

Rank trending HackerNews topics in Postgres with an incremental CocoIndex v1 pipeline: scrape threads and comments, extract topics with an LLM.


Updated Jul 9, 2026

The tech community’s attention is hiding in plain sight, scattered across story titles and thousands of comments. This post builds a pipeline that reads recent HackerNews threads and their comments, asks an LLM to pull the topics out of every message, and ranks what’s trending in Postgres, rewritten for CocoIndex v1.

Two pieces do the work: the LLM extraction that turns free-form text into normalized topics, and a SQL score that turns those topics into a leaderboard. This post focuses on both. The source here is a custom one (two async functions over the Algolia HN API); the custom-source walkthrough covers that side in detail, so we won’t repeat it here.

The full example is in the CocoIndex v1 repo.

How it works

Each recent story becomes its own processing component. The component fetches the story and its comments, extracts topics from each message with an LLM, and declares rows into two Postgres tables:

  • hn_messages: one row per message (the story and each comment), for full-text search
  • hn_topics: one row per topic mention, keyed on (topic, message_id)

Because the same topic mentioned in many messages becomes many hn_topics rows, ranking becomes a simple SQL aggregate. Re-running catches up on new stories, and because per-message extraction is memoized, only content CocoIndex hasn’t seen hits the LLM.

The topic extraction is the product

The interesting part is not calling an LLM; it’s getting topics back in a shape you can rank. Raw model output would give you “postgres” in one comment and “PostgreSQL” in another, or a whole phrase like “books for autistic kids” as a single blob. Those never aggregate.

The fix lives entirely in the prompt, expressed as the description of a Pydantic field. The model normalizes three ways: it splits combined phrases into parts, keeps proper nouns canonical, and emits aliases for the same thing:

Topic extraction: an LLM reads a HackerNews message and returns a normalized list of topics. It splits combined phrases into parts (books for autistic kids becomes book, autistic, autistic kids), keeps proper nouns canonical (postgres becomes PostgreSQL), and emits aliases for the same thing (JFK and John Kennedy).

python
class TopicsResponse(BaseModel):
    """Response containing a list of extracted topics."""

    topics: list[str] = Field(
        description="""List of extracted topics.

Each topic can be a product name, technology, model, people, company name, business domain, etc.
Capitalize for proper nouns and acronyms only.
Use the form that is clear alone.

For topics that are a phrase combining multiple things, normalize into multiple topics. Examples:
- "books for autistic kids" -> "book", "autistic", "autistic kids"

When there're multiple common ways to refer to the same thing, use multiple topics. Examples:
- "John Kennedy", "JFK"
"""
    )

That normalization is what makes the trending ranking meaningful later. Get it wrong and “PostgreSQL”, “postgres”, and “psql” split their votes three ways.

Extraction itself is one CocoIndex function: text in, a list of topics out, through litellm so any provider works. memo=True caches the result, so an unchanged message is never sent to the model twice:

python
@coco.fn(memo=True)
async def extract_topics(text: str | None) -> list[str]:
    if not text or not text.strip():
        return []

    response = await acompletion(
        model=LLM_MODEL,
        messages=[
            {"role": "user", "content": f"Extract topics from the following text:\n\n{text[:4000]}"}
        ],
        response_format=TopicsResponse,
    )
    content = response.choices[0].message.content
    return TopicsResponse.model_validate_json(content).topics

Declare a row per message and per topic

The two target tables are plain dataclasses. A thread and each of its comments both become an HnMessage (distinguished by content_type), and every extracted topic becomes an HnTopic keyed on (topic, message_id):

python
@dataclass
class HnMessage:
    id: str
    thread_id: str
    content_type: str  # "thread" or "comment"
    author: str | None
    text: str | None
    url: str | None
    created_at: datetime | None


@dataclass
class HnTopic:
    topic: str
    message_id: str
    thread_id: str
    content_type: str
    created_at: datetime | None

process_thread fetches one story, extracts topics for the story and every comment, and declares the rows. You declare what should exist; CocoIndex works out the inserts, updates, and deletes:

python
@coco.fn
async def process_thread(thread_id: str, targets: TableTargets) -> None:
    async with aiohttp.ClientSession() as session:
        thread = await fetch_thread(session, thread_id)
    thread_topics = await extract_topics(thread.text)

    targets.messages.declare_row(row=HnMessage(
        id=thread.id, thread_id=thread.id, content_type="thread",
        author=thread.author, text=thread.text, url=thread.url, created_at=thread.created_at,
    ))
    for topic in thread_topics:
        targets.topics.declare_row(row=HnTopic(
            topic=topic, message_id=thread.id, thread_id=thread.id,
            content_type="thread", created_at=thread.created_at,
        ))

    for comment in thread.comments:
        comment_topics = await extract_topics(comment.text)
        targets.messages.declare_row(row=HnMessage(..., content_type="comment", ...))
        for topic in comment_topics:
            targets.topics.declare_row(row=HnTopic(..., content_type="comment", ...))

app_main mounts the two Postgres tables and fans out one process_thread per recent story with coco.mount_each. The two async fetch functions that make up the custom source are covered in the custom-source post.

Trending is a SQL query over hn_topics. Each row contributes a weight to its topic’s score: a thread-level mention counts for more than a comment-level one, because a topic in the story title is the subject, while a topic in a comment is supporting discussion. Group by topic, order by score:

Trending ranking: the hn_topics table holds one row per topic mention. A SQL score sums a weight per row, 5 for a thread-level mention and 1 for a comment-level mention, grouped by topic and ordered by score, producing a leaderboard where Rust scores 7, PostgreSQL 5, LLM 3, and Claude 1.

sql
SELECT
    topic,
    SUM(CASE WHEN content_type = 'thread' THEN 5 ELSE 1 END) AS score,
    MAX(created_at) AS latest_mention,
    COUNT(DISTINCT thread_id) AS thread_count
FROM coco_examples.hn_topics
GROUP BY topic
ORDER BY score DESC, latest_mention DESC
LIMIT 20

Weighting thread mentions 5 to 1 over comment mentions is what makes the ranking read as “what HackerNews is about” rather than “what people mentioned in passing.” The same score, filtered to one topic and grouped by thread_id, ranks which threads are driving a given topic.

Run it

Install the example and build the index:

sh
pip install -e .
cocoindex update main

This fetches the most recent stories, runs topic extraction on every message, and writes coco_examples.hn_messages and coco_examples.hn_topics. This source is a one-shot catch-up per run; re-run cocoindex update main anytime to pick up new stories, and only new messages hit the LLM.

Then explore from the command line, top trending topics or a single topic:

sh
python main.py            # top 20 trending topics + interactive search
python main.py "rust"     # search content for one topic

What you can build on top

Once topics are structured and ranked, the pipeline is a foundation:

  • Trending dashboards. Serve the leaderboard and per-topic threads to a live page that shows what’s hot and which discussions are driving it.
  • Sentiment-aware trends. Add a second extraction step for sentiment to track not just what’s trending, but how people feel about it.
  • Contributor tracking. Every message stores its author, so you can find who drives conversation around a given topic.
  • A knowledge graph. Feed the topics into a graph linking companies, products, and people mentioned across HackerNews.

Each of these is a new transform or target on the same declared source, not a rewrite. The extraction and the ranking, the two pieces this post focused on, stay exactly as they are.

Support us ❤️

If this was useful, a star on GitHub helps more developers find CocoIndex and keeps the project growing.

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

How do I extract structured topics from text with an LLM in CocoIndex v1?

Write one CocoIndex function: text in, a list of topics out. It calls the model through litellm with a Pydantic response_format (here TopicsResponse), so any provider works and the response is validated. Wrap it in @coco.fn(memo=True) so an unchanged message is never sent to the model twice.

See The topic extraction is the product.

Why normalize topics, and how does the prompt do it?

Raw model output gives you "postgres" in one comment and "PostgreSQL" in another, or a whole phrase as a single blob, and those never aggregate. The normalization lives entirely in the Pydantic field description (the prompt): it splits combined phrases into parts ("books for autistic kids" becomes "book", "autistic", "autistic kids"), keeps proper nouns canonical ("postgres" becomes "PostgreSQL"), and emits aliases for the same thing ("JFK", "John Kennedy"). That is what makes the ranking meaningful.

See The topic extraction is the product.

How are trending topics scored and ranked?

Trending is a SQL query over hn_topics. Each row contributes a weight to its topic's score: a thread-level mention counts 5, a comment-level mention counts 1, because a topic in the story title is the subject while a topic in a comment is supporting discussion. Group by topic, order by score. The same score filtered to one topic and grouped by thread_id ranks which threads drive it.

See Ranking what's trending.

Why is the hn_topics table keyed on (topic, message_id)?

Because the same topic mentioned in many messages should become many rows, one per mention. Keying on (topic, message_id) keeps each mention as its own row instead of collapsing them, which is exactly what lets a simple SUM ... GROUP BY topic turn mentions into a trending score.

See Declare a row per message and per topic.

Does CocoIndex re-run the LLM on every message each run?

No. Per-message extraction is memoized (@coco.fn(memo=True)), so the expensive LLM calls run only for content CocoIndex hasn't seen. The source is a one-shot catch-up per run: re-run cocoindex update main to pick up new stories, and only the new messages hit the model.

See Run it.

Where is the custom HackerNews source explained?

The source here is two small async functions over the Algolia HN API, handed to coco.mount_each as one processing component per story. This post focuses on the LLM extraction and the trending ranking; the custom-source side (fetching stories and flattening the comment tree) is covered in detail in the sibling walkthrough, Extract HackerNews into Postgres with a custom source.

See How it works.