Finite State Transducers (FST)

Data Structures & Storage advanced Building with AI 18 min read

Overview

A Finite State Transducer (FST) is a compressed, immutable key-value map that stores sorted byte sequences and their associated output values in a structure that shares common prefixes and suffixes. Unlike a hash map that stores each key independently, an FST exploits the redundancy between similar keys to achieve compression ratios of 10-50x while still supporting O(k) lookups where k is the key length.

FSTs are the backbone of full-text search indexes. Lucene, Elasticsearch, and the BurntSushi/fst library (Vellum-compatible in Go) use FSTs to map terms to posting list offsets. As an example, a typical search engine implements an FST-based term dictionary for its search index, enabling memory-efficient storage of millions of terms with instant prefix, range, and exact-match lookups.

The power of FSTs comes from their dual compression: they share prefixes (like a trie) and also share suffixes (like a DAWG). This makes them dramatically smaller than tries for natural-language term dictionaries where keys like “running”, “runner”, “runs” share both a prefix “run” and common suffix patterns.

Core Concepts

Key Concept

An FST is a minimal acyclic deterministic finite automaton with outputs. “Minimal” means no two states are equivalent, “acyclic” means no loops (keys are finite), and “deterministic” means each state has at most one transition per input byte. This minimality is what gives FSTs their compression.

Key Concept

Because FSTs share both prefixes and suffixes, they are smaller than tries for the same key set. A trie with 10M English words might use 500MB; the equivalent FST can fit in 30-50MB. This difference is what makes FSTs practical for in-memory term dictionaries at scale.

How It Works

Construction Algorithm

FSTs are built incrementally from keys that must arrive in sorted order. The builder maintains a stack of “unfrozen” states representing the current path from root. When a new key diverges from the previous key, the builder “freezes” states that are no longer needed (compiling them into the output), checks if any frozen state is equivalent to an existing one (suffix sharing), and reuses it if so. This single-pass algorithm runs in O(n * k) time where n is the number of keys and k is the average key length.

Output Distribution

Output values are distributed across transitions such that the total output for any key equals the sum of outputs along its path. When two keys share a prefix, the common output is pushed as far toward the root as possible, and the remaining difference is placed on the diverging transitions. This ensures each key’s total output is correct while maximizing sharing.

Lookup Operation

To look up a key, start at the root state and follow transitions matching each byte of the key, accumulating the output values. If at any point no matching transition exists, the key is not in the FST. If the final state is an accepting state, the accumulated output is the associated value. This runs in O(k) time with excellent cache behavior since the FST data is accessed sequentially.

Prefix and Range Queries

FSTs natively support prefix queries: navigate to the state after the prefix bytes, then enumerate all reachable accepting states. Range queries work by starting the enumeration at the lower bound and stopping at the upper bound. Both operations are streaming — they produce results incrementally without materializing the full result set.

Key Concept

FST construction requires sorted input, which is why search engines build FSTs during the segment merge phase — the keys are already sorted from the merge sort. This constraint is not a limitation in practice; it’s a natural fit for the batch-oriented indexing pipeline.

Practical Applications

In a typical inverted index implementation, the FST serves as the term dictionary. Each segment’s term dictionary is an FST mapping term bytes to a pair of (posting list offset, term frequency), enabling both exact lookups and prefix-based autocomplete without separate data structures.

Implementation Patterns

Memory-Mapped Access

Since FSTs are immutable byte sequences, they are ideal candidates for memory mapping. The FST file is mmap’d into the process address space, and the OS page cache handles caching automatically. Queries access the FST through byte-level pointer arithmetic with zero deserialization cost.

Registry-Based Deduplication

During construction, the builder maintains a registry (hash map) of recently frozen states. When a new state is frozen, it checks the registry for an equivalent state (same transitions, same outputs, same finality). If found, the existing state is reused. The registry has a bounded size to limit memory during construction; evicted entries simply miss the deduplication opportunity.

Vellum-Compatible Format

The Vellum format (from the Go library) encodes each state as a variable-length byte sequence with transitions packed as (input byte, output delta, target state offset) tuples. Single-transition states use a compact one-byte encoding. The format is designed for sequential reading and random access via state offsets.

Code Examples

Building an FST from Sorted Keys in Go

package search

import (
    "bytes"
    "encoding/binary"
    "sort"
)

type FSTBuilder struct {
    // Simplified FST builder for illustration
    frozen   []fstState
    unfrozen []builderState
    registry map[uint64]int // hash → state index
    lastKey  []byte
}

type builderState struct {
    transitions []transition
    isFinal     bool
    finalOutput uint64
}

type transition struct {
    input  byte
    output uint64
    target int // index into frozen states, -1 if unfrozen
}

type fstState struct {
    transitions []transition
    isFinal     bool
    finalOutput uint64
}

func NewFSTBuilder() *FSTBuilder {
    return &FSTBuilder{
        registry: make(map[uint64]int),
        unfrozen: []builderState{{}}, // root state
    }
}

func (b *FSTBuilder) Add(key []byte, value uint64) error {
    if bytes.Compare(key, b.lastKey) <= 0 && len(b.lastKey) > 0 {
        return fmt.Errorf("keys must be added in sorted order")
    }

    // Find common prefix length with previous key
    commonLen := 0
    for i := 0; i < len(key) && i < len(b.lastKey); i++ {
        if key[i] != b.lastKey[i] {
            break
        }
        commonLen++
    }

    // Freeze states beyond the common prefix
    for i := len(b.unfrozen) - 1; i > commonLen; i-- {
        b.freezeState(i)
    }

    // Add new unfrozen states for the remaining key bytes
    for i := commonLen; i < len(key); i++ {
        b.unfrozen = append(b.unfrozen, builderState{})
        parent := &b.unfrozen[len(b.unfrozen)-2]
        parent.transitions = append(parent.transitions, transition{
            input:  key[i],
            output: 0,
            target: -1, // points to next unfrozen state
        })
    }

    // Mark the final state and distribute the output value
    last := &b.unfrozen[len(b.unfrozen)-1]
    last.isFinal = true
    last.finalOutput = value

    b.lastKey = append(b.lastKey[:0], key...)
    return nil
}

// Lookup performs an exact key lookup in the compiled FST
func (fst *FST) Lookup(key []byte) (uint64, bool) {
    state := 0 // start at root
    var output uint64

    for _, b := range key {
        found := false
        for _, t := range fst.states[state].transitions {
            if t.input == b {
                output += t.output
                state = t.target
                found = true
                break
            }
        }
        if !found {
            return 0, false
        }
    }

    if fst.states[state].isFinal {
        return output + fst.states[state].finalOutput, true
    }
    return 0, false
}

FST-Based Term Dictionary Lookup in Zig

const std = @import("std");

pub const FSTReader = struct {
    data: []const u8,
    root_offset: u64,

    pub fn init(mmap_data: []const u8) FSTReader {
        // Last 8 bytes store the root state offset
        const len = mmap_data.len;
        const root = std.mem.readInt(u64, mmap_data[len - 8 ..][0..8], .little);
        return .{
            .data = mmap_data,
            .root_offset = root,
        };
    }

    /// Look up a term and return its posting list offset
    pub fn lookup(self: *const FSTReader, term: []const u8) ?u64 {
        var state_offset = self.root_offset;
        var output: u64 = 0;

        for (term) |byte| {
            const state = self.readState(state_offset);
            const trans = state.findTransition(byte) orelse return null;
            output += trans.output;
            state_offset = trans.target;
        }

        const final_state = self.readState(state_offset);
        if (final_state.is_final) {
            return output + final_state.final_output;
        }
        return null;
    }

    /// Enumerate all terms with a given prefix
    pub fn prefixIterator(
        self: *const FSTReader,
        prefix: []const u8,
    ) ?PrefixIterator {
        var state_offset = self.root_offset;
        var output: u64 = 0;

        // Navigate to the prefix endpoint
        for (prefix) |byte| {
            const state = self.readState(state_offset);
            const trans = state.findTransition(byte) orelse return null;
            output += trans.output;
            state_offset = trans.target;
        }

        return PrefixIterator{
            .fst = self,
            .stack = &.{.{ .offset = state_offset, .output = output }},
            .stack_len = 1,
        };
    }

    fn readState(self: *const FSTReader, offset: u64) State {
        // Decode variable-length state from the byte array
        const ptr = self.data[@intCast(offset)..];
        const header = ptr[0];
        const is_final = (header & 0x80) != 0;
        const num_transitions = header & 0x7F;

        var state = State{
            .is_final = is_final,
            .final_output = 0,
            .transitions = undefined,
            .num_transitions = num_transitions,
        };

        var pos: usize = 1;
        if (is_final) {
            state.final_output = readVarInt(ptr[pos..], &pos);
        }

        for (0..num_transitions) |i| {
            state.transitions[i] = .{
                .input = ptr[pos],
                .output = readVarInt(ptr[pos + 1 ..], &pos),
                .target = readVarInt(ptr[pos..], &pos),
            };
            pos += 1;
        }

        return state;
    }
};

Common Pitfalls

Key Takeaways

Key Concept

FSTs compress ordered key-value maps by sharing both prefixes and suffixes, achieving 10-50x compression over hash maps while maintaining O(key length) lookup time. They are the data structure of choice for search engine term dictionaries.

Key Concept

The combination of immutability, compact encoding, and sequential byte layout makes FSTs ideal for memory-mapped access. A 50MB FST file serving millions of terms requires zero deserialization — the OS page cache is your caching layer.

Further Reading