Back to flin
flin

Search Analytics and Result Caching

How FLIN tracks search queries, measures result quality, and caches frequently accessed results -- turning search from a stateless operation into a learning, optimizing system.

Thales & Claude | March 25, 2026 8 min flin
flinanalyticscachingsearchperformance

A search system without analytics is a search system you cannot improve. You do not know what users search for, whether they find what they need, or which queries return empty results. You cannot identify gaps in your content, tune your ranking weights, or measure the impact of changes.

FLIN's search system includes built-in analytics that track every query, every result set, and every user interaction with results. Combined with an intelligent caching layer that eliminates redundant computation, the search system becomes faster and smarter over time.

Search Analytics

Every search operation in FLIN -- whether search, ask, or hybrid_search -- can optionally log analytics data:

flinresults = hybrid_search(query.q, {
    entity: DocumentChunk,
    text_field: "content",
    semantic_field: "content",
    limit: 10,
    analytics: true          // Enable analytics logging
})

When analytics: true is set, FLIN automatically logs:

flinentity SearchLog {
    query: text              // The search query
    method: text             // "search", "ask", "hybrid"
    result_count: int        // Number of results returned
    latency_ms: int          // Time to execute
    user_id: int?            // Authenticated user (if any)
    session_id: text         // Session identifier
    clicked_results: [int]   // Which results the user clicked
    timestamp: time
}

This entity is automatically created by the runtime. No developer configuration needed.

Tracking Click-Through

Analytics become valuable when you know which results users actually click:

flin// app/api/search.flin

route GET {
    q = query.q || ""
    results = hybrid_search(q, {
        entity: DocumentChunk,
        text_field: "content",
        semantic_field: "content",
        limit: 10,
        analytics: true
    })

    // Return results with search_id for click tracking
    {
        search_id: results.search_id,
        query: q,
        results: results.map(r => {
            id: r.id,
            title: r.title,
            preview: r.content.slice(0, 200)
        })
    }
}

// Click tracking endpoint
route POST "/track-click" {
    validate {
        search_id: text @required
        result_id: int @required
        position: int @required
    }

    log = SearchLog.where(id == body.search_id).first
    if log != none {
        log.clicked_results = log.clicked_results + [body.result_id]
        save log
    }

    { success: true }
}

The frontend sends a click event when a user clicks on a search result. The position in the result list is included, enabling analysis of ranking quality.

Analytics Dashboard

With search logs accumulating, you can build an analytics dashboard:

flin// app/admin/search-analytics.flin

guard auth
guard role("admin")

// Top queries
top_queries = ask "most frequent search queries this week"

// Zero-result queries (content gaps)
zero_results = SearchLog
    .where(result_count == 0 && timestamp >= last_week)
    .group_by("query")
    .order(count, "desc")
    .limit(20)

// Average click position (ranking quality)
avg_position = SearchLog
    .where(clicked_results.len > 0 && timestamp >= last_week)
    .avg("first_click_position")

// Search volume over time
daily_volume = SearchLog
    .where(timestamp >= last_month)
    .group_by_day("timestamp")
    .count

<main>
    <h1>Search Analytics</h1>

    <section>
        <h2>Top Queries This Week</h2>
        {for q in top_queries}
            <div class="query-row">
                <span class="query">{q.query}</span>
                <span class="count">{q.count} searches</span>
            </div>
        {/for}
    </section>

    <section>
        <h2>Queries With No Results</h2>
        <p>These queries indicate content gaps in your knowledge base.</p>
        {for q in zero_results}
            <div class="query-row zero">
                <span class="query">{q.query}</span>
                <span class="count">{q.count} searches</span>
            </div>
        {/for}
    </section>

    <section>
        <h2>Ranking Quality</h2>
        <p>Average click position: {avg_position}</p>
        <p>Lower is better. 1.0 means users always click the first result.</p>
    </section>
</main>

Zero-Result Queries: Finding Content Gaps

The most actionable analytics metric is zero-result queries. When users search for something and get no results, it means either: 1. The content exists but is not indexed properly. 2. The content does not exist and should be created.

flin// Weekly report of content gaps
gaps = SearchLog
    .where(result_count == 0 && timestamp >= last_week)
    .group_by("query")
    .having(count >= 3)    // At least 3 people searched for it
    .order(count, "desc")

A query that 10 users searched for with no results is a strong signal that you need to create that content.

Result Caching

Search operations involve embedding generation (for semantic search), index lookups, and potentially AI calls (for ask queries). Caching eliminates redundant computation for repeated queries.

Query-Level Cache

FLIN caches search results keyed by the query string and search parameters:

rustpub struct SearchCache {
    entries: HashMap<u64, CacheEntry>,
    max_size: usize,
    default_ttl: Duration,
}

pub struct CacheEntry {
    results: Vec<SearchResult>,
    created_at: Instant,
    ttl: Duration,
    hit_count: u64,
}

impl SearchCache {
    pub fn get(&mut self, query: &str, params: &SearchParams) -> Option<&[SearchResult]> {
        let key = hash_search_key(query, params);

        if let Some(entry) = self.entries.get_mut(&key) {
            if entry.created_at.elapsed() < entry.ttl {
                entry.hit_count += 1;
                return Some(&entry.results);
            } else {
                self.entries.remove(&key);
            }
        }
        None
    }

    pub fn set(&mut self, query: &str, params: &SearchParams, results: Vec<SearchResult>) {
        // Evict oldest entries if cache is full
        if self.entries.len() >= self.max_size {
            self.evict_lru();
        }

        let key = hash_search_key(query, params);
        self.entries.insert(key, CacheEntry {
            results,
            created_at: Instant::now(),
            ttl: self.default_ttl,
            hit_count: 0,
        });
    }
}

The cache key is the hash of the query string plus all search parameters (entity, field, limit, weights). Different parameters produce different cache entries.

Embedding Cache

For semantic search, the query embedding is cached separately from the search results. This means that if the query "office chair" produces different results after new documents are indexed, the embedding is still cached:

rustpub struct EmbeddingCache {
    embeddings: HashMap<u64, Vec<f32>>,
    max_size: usize,
}

impl EmbeddingCache {
    pub fn get_or_compute(
        &mut self,
        text: &str,
        provider: &dyn EmbeddingProvider,
    ) -> Result<Vec<f32>, EmbeddingError> {
        let key = hash_text(text);

        if let Some(embedding) = self.embeddings.get(&key) {
            return Ok(embedding.clone());
        }

        let embedding = provider.embed(text)?;
        self.embeddings.insert(key, embedding.clone());
        Ok(embedding)
    }
}

Embedding caching is especially valuable for applications with search-as-you-type interfaces, where the same prefix might be searched dozens of times as the user types.

Intent Cache

For ask queries, the AI translation (the query plan) is cached:

flin// First call: AI translation (200-500ms) + query execution (5ms)
users = ask "active users who signed up this week"

// Second call: cached translation (0ms) + query execution (5ms)
users = ask "active users who signed up this week"

The intent cache is invalidated when the entity schema changes (new fields, new entities) because the translation might need to reference new schema elements.

Cache Invalidation

FLIN uses a combination of TTL-based and event-based cache invalidation:

TTL-based: Every cache entry has a time-to-live (default: 5 minutes for search results, 1 hour for embeddings, schema-change for intent translations). Expired entries are evicted on access.

Event-based: When an entity with semantic text fields is saved, updated, or deleted, the search result cache for that entity type is invalidated. This ensures that new or modified documents appear in search results within one save operation.

rustpub fn on_entity_change(entity_type: &str, cache: &mut SearchCache) {
    cache.invalidate_by_entity(entity_type);
}

The embedding cache and intent cache are NOT invalidated by entity changes because embeddings are computed from query text (which does not change) and intent translations are computed from schema (which rarely changes).

Performance Impact of Caching

OperationWithout CacheWith CacheImprovement
Semantic search15 ms3 ms5x faster
Hybrid search20 ms5 ms4x faster
Ask query (first)500 ms500 msNo change
Ask query (repeat)500 ms5 ms100x faster
Embedding generation12 ms< 1 ms12x faster

The most dramatic improvement is for repeated ask queries, where the 500 ms AI translation is eliminated entirely.

Adaptive Search Quality

Over time, search analytics can be used to improve search quality automatically:

Query Suggestion

Popular queries can be suggested to users as they type:

flinfn suggest_queries(prefix) {
    SearchLog
        .where(query.starts_with(prefix) && result_count > 0)
        .group_by("query")
        .order(count, "desc")
        .limit(5)
        .map(s => s.query)
}

Synonym Discovery

Queries that lead users to the same documents are likely synonyms:

flin// "office chair" and "desk seating" both lead to product #42
// -> These are functional synonyms

Weight Tuning

Click-through data reveals whether BM25 or semantic results are more useful:

flin// If users consistently click semantic results over BM25 results,
// increase the semantic weight
avg_click_source = SearchLog
    .where(clicked_results.len > 0)
    .map(s => s.click_source) // "bm25" or "semantic"
    .count_by_value

The Complete Search Stack

FLIN's search system, built over the last ten articles, comprises:

  1. Semantic text type -- automatic embedding generation on save.
  2. HNSW vector index -- fast approximate nearest neighbor search.
  3. BM25 inverted index -- precise keyword matching.
  4. Hybrid search -- merged ranking with Reciprocal Rank Fusion.
  5. Cross-encoder reranking -- precision scoring for RAG.
  6. Document parsing -- PDF, DOCX, CSV, JSON, YAML extraction.
  7. Code-aware chunking -- intelligent document splitting.
  8. FastEmbed -- local, private embedding generation.
  9. AI Gateway -- multi-provider LLM access.
  10. Analytics and caching -- learning and optimization.

All of this is built into the FLIN runtime. No external services. No infrastructure to manage. No configuration to maintain. A developer writes search "query" in Entity by field and the entire stack activates.

This concludes Arc 11 -- FLIN's AI and Intent Engine. Thirty articles covering the web server, security, and AI systems that make FLIN a language not just for building web applications, but for building intelligent web applications. The next arc will continue the journey into FLIN's standard library and ecosystem tools.


This is Part 125 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [124] AI-First Language Design - [125] Search Analytics and Result Caching (you are here) - Next arc: FLIN Standard Library and Ecosystem

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles