Repository navigation
Home
A high-performance, dictionary-driven text scanning layer built on the
Apache Lucene 9.10 Finite State Transducer (FST).
Drop CSV files in, start the server, and tag millions of tokens per second
no ML model, no GPU, no Solr.
- What it does
- How the FST works
- Quick start
- Run modes
- Tag response format
- Loading CSV dictionaries
- Bundled dictionaries
- Docker
- Source layout
- Analyzer pipeline
- Requirements & build
Java FST Guard Rails scans free-form text against one or more phrase dictionaries and returns every match with its character offsets, a type label, a canonical id, and a normalized output token.
Use it as a guard rails layer in front of any LLM or search pipeline to catch, classify, and canonicalize:
| Category | Example phrase matched | Output token |
|---|---|---|
| User intent | "track my order" |
STATUS |
| Offensive language | "bullshit" |
BULLSHIT |
| Equipment family | "backhoe loader" |
BACKHOELOADER |
| Product name | "Apache Lucene" |
APACHELUCENE |
| Part number | "6I-6454" |
6I6454 |
| Your custom entity | anything in a CSV | anything you define |
Every match is returned in a consistent JSON envelope so downstream code never needs to inspect raw text:
{
"totaltime": 1,
"text": "I want to track my order and check availability",
"docs": [
{"start":10, "end":24, "surface":"track my order", "type":"intent", "output":"STATUS"},
{"start":29, "end":47, "surface":"check availability", "type":"intent", "output":"AVAILABILITY"}
]
}The Lucene FST is a Finite State Transducer a compact, read-only automaton that maps input byte sequences to output values. The tagger builds it in three explicit steps at startup:
Step 1 createIndex() build a ByteBuffersDirectory (in-memory Lucene index)
Step 2 addData() / loadCsv() write phrase documents via IndexWriter
Step 3 TextTagger.fromIndex() read all phrases via DirectoryReader → compile FST
At query time, tagger.tag(text) runs the analyzer pipeline over the input
(tokenizing, lowercasing, ASCII-folding, hyphen-stripping) and then walks the
FST arc-by-arc, one token at a time, using a forward maximum-match
algorithm. There is no regex backtracking and no per-candidate hash lookup
the entire dictionary is traversed in a single linear pass regardless of how
many phrases it contains.
Why this matters for guard rails:
- A 168-entry intent dictionary and a 1.2 M-row part-number dictionary both produce sub-millisecond response times on the same warm JVM.
- The dictionary lives entirely in heap RAM (~33 KB for 168 phrases); there is no disk I/O at query time.
- Longest-match-wins is baked in:
"New York City"is returned instead of the shorter"New York".
# 1. Build
cd java-tagger-demo
mvn package -q
# → target/tagger.jar (~12 MB fat jar)
# 2. Start the HTTP server with bundled sample data
DATA=data java -jar target/tagger.jar serve 8080
# 3. Tag something
curl "http://localhost:8080/tag?text=I+want+to+track+my+order"{
"totaltime": 1,
"text": "I want to track my order",
"docs": [
{"start":10,"end":24,"surface":"track my order","id":"...","type":"intent","output":"STATUS"}
]
}java -jar target/tagger.jar serve [port]
# PORT env var is also respected (Replit / Docker convention)
DATA=data java -jar target/tagger.jar serve 8080| Method | Endpoint | Input | Response |
|---|---|---|---|
GET |
/tag?text=... |
URL-encoded text | JSON envelope |
POST |
/tag |
{"text":"..."} |
JSON envelope |
GET |
/health |
{"status":"ok"} |
java -jar target/tagger.jar mcpExposes the tagger as a single tag tool over the stdio transport
(newline-delimited JSON-RPC 2.0). All startup diagnostics go to stderr so
the protocol channel (stdout) carries only JSON-RPC frames.
Claude Desktop config (claude_desktop_config.json):
{
"mcpServers": {
"lucene-tagger": {
"command": "java",
"args": ["-jar", "/absolute/path/to/tagger.jar", "mcp"],
"env": { "DATA": "/absolute/path/to/data" }
}
}
}Once connected, Claude can call the tool directly:
"Tag this sentence and tell me if there is any offensive language:
that was total bullshit and I want to track my order"
MCP protocol handled:
| JSON-RPC method | Server response |
|---|---|
initialize |
Capabilities handshake (protocolVersion: 2024-11-05) |
notifications/initialized |
(none notification, no reply) |
tools/list |
tag tool schema with JSON Schema for text argument |
tools/call |
Tag envelope embedded in content[].text
|
| anything else |
-32601 method-not-found error |
Manual test:
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test"}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tag","arguments":{"text":"Ada Lovelace uses Apache Lucene"}}}'
} | java -jar target/tagger.jar mcp 2>/dev/nulljava -jar target/tagger.jar repl
DATA=data java -jar target/tagger.jar replType sentences at the > prompt. Commands: dict (list all phrases), help, quit.
java -jar target/tagger.jarRuns all nine built-in demo sections in one pass and exits useful as a sanity check after building.
| # | Section |
|---|---|
| 1 | Building the dictionary index |
| 2 | Basic tagging (forward maximum match) |
| 3 | Longest-match-wins "New York City" beats "New York"
|
| 4 | ASCII folding "Zürich" matches "Zurich"
|
| 5 | Case-sensitive analyzer "US" ≠ "us"
|
| 6 | Incremental updates add entries without full rebuild |
| 7 | Concurrent tagging 4 threads, read-write lock |
| 8 | Common pitfalls analyzer mismatch, stemming, reset(), stop words |
| 9 | No-match case |
Every /tag response (HTTP and MCP) uses the same envelope:
{
"totaltime": 1,
"text": "the original input string",
"docs": [
{
"start": 0,
"end": 12,
"surface": "Ada Lovelace",
"id": "per:ada",
"type": "PERSON",
"output": "ADALOVELACE"
}
]
}| Field | Type | Description |
|---|---|---|
totaltime |
integer | Query processing time in milliseconds |
text |
string | The input text echoed back verbatim |
docs |
array | One object per matched span, empty array if no matches |
docs[].start |
integer | Start character offset (inclusive) |
docs[].end |
integer | End character offset (exclusive) |
docs[].surface |
string | Matched substring from the original text |
docs[].id |
string | Canonical identifier from the dictionary |
docs[].type |
string | Category label (CSV filename without .csv, or hard-coded type) |
docs[].output |
string | Normalized output token for downstream routing |
Set the DATA environment variable to a directory containing .csv files
before starting any run mode. No code changes needed.
DATA=/path/to/my/dictionaries java -jar target/tagger.jar serve 8080Processing rules:
- Files are loaded in alphabetical order.
- The first row is the header; every subsequent row becomes one Lucene document.
- The first column is the phrase indexed as
F_TEXT(blank rows are skipped). - The
actioncolumn (if present) is stored asoutput; otherwiseoutputis derived by uppercasing the phrase and stripping non-alphanumeric characters. -
typeis set to the filename without the.csvextension. - A random UUID is assigned as
idfor each row.
CSV schema:
| Lucene field | Source |
|---|---|
phrase (F_TEXT) |
First column value |
id (F_ID) |
Random UUID |
type (F_TYPE) |
Filename minus .csv
|
output (F_OUTPUT) |
action column value, or deriveOutput(phrase)
|
Example intent with explicit action mapping:
intent,action,response
track my order,STATUS,Checking order status
check availability,AVAILABILITY,Checking stock
find a dealer,DEALER_LOCATOR,Finding dealersExample entity list with no action column:
machine_family
ARTICULATED TRUCK
BACKHOE LOADER
MOTOR GRADERError behaviour:
| Situation | Behaviour |
|---|---|
DATA not set |
Skip silently; built-in phrases only |
| Path does not exist |
IllegalArgumentException at startup |
Empty directory / no .csv files |
Skip with one log line |
| Blank first column | Row skipped |
The data/ directory ships with six CSV files:
| File | Rows | Description |
|---|---|---|
intent.csv |
168 | CAT parts app user intent phrases → action tokens |
machine_families.csv |
273 | Equipment family names derived from model_numbers.csv
|
model_numbers.csv |
4,009 | Model code → machine family mapping (source file) |
offensive_en.csv |
40 | English offensive language terms |
part_numbers.csv |
1,204,314 | CAT part numbers |
serial_numbers.csv |
10,426 | Equipment serial number prefixes |
Note:
model_numbers.csvandpart_numbers.csvare reference/source files. PointDATAat a subdirectory containing only the files you need to keep startup time fast.
Loading all six at once:
DATA=data java -jar target/tagger.jar serve 8080Loading only intent and offensive language:
mkdir -p data/guardrails
cp data/intent.csv data/offensive_en.csv data/guardrails/
DATA=data/guardrails java -jar target/tagger.jar serve 8080# Build image (includes data/ directory)
cd java-tagger-demo
docker build -t lucene-tagger .
# Run with bundled data
docker run --rm -p 8080:8080 lucene-tagger
# Run with your own CSV files
docker run --rm -p 8080:8080 \
-v /path/to/my/csvs:/data \
-e DATA=/data \
lucene-tagger
# One-command start with Docker Compose
docker compose upEnvironment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
DATA |
/app/data |
Directory of .csv files to load |
Everything lives in a single source file: src/main/java/com/harcor/App.java.
| Symbol | Kind | Role |
|---|---|---|
Tag |
record | Immutable value object: start, end, surface, id, type, output
|
buildAnalyzer() |
static method | Constructs the analyzer pipeline (see §10) |
createIndex() |
static method | Creates an in-memory ByteBuffersDirectory
|
addData() |
static method | Writes hard-coded phrase documents via IndexWriter
|
loadCsvData() |
static method | Appends CSV documents; reads DATA env var |
buildPhrases() |
static method | Returns the built-in List<String[]> dictionary |
buildTagger() |
static method | Orchestrates Steps 1–3 for server/MCP mode |
jsonEscape() |
static method | Escapes a string for embedding in JSON |
toJson() |
static method | Builds the response envelope (totaltime, text, docs) |
TextTagger |
static class | FST-backed phrase dictionary; forward-maximum-match arc walk |
TaggerServer |
static class |
com.sun.net.httpserver HTTP wrapper; GET+POST /tag, /health
|
McpServer |
static class | MCP stdio server (JSON-RPC 2.0); exposes tag tool to Claude Desktop |
App.main() |
entry point | Dispatches mcp, serve, repl, or demo mode |
The same analyzer is used at both index time and query time this is critical. A mismatch causes phrases to never match.
Input text
│
▼
PatternReplaceCharFilter strips hyphens and Unicode dashes
│ so "xx-yy" and "xxyy" are identical
▼
StandardTokenizer splits on whitespace / punctuation
│
▼
LowerCaseFilter "Apache" → "apache"
│
▼
ASCIIFoldingFilter "Zürich" → "zurich"
│
▼
Analyzed token stream used for both indexing phrases and tagging input
Hyphen variants stripped: ASCII hyphen (-), non-breaking hyphen,
en-dash, em-dash, minus sign (and two additional Unicode dash characters).
| Requirement | Version |
|---|---|
| Java | 17+ |
| Maven | 3.6+ |
| Apache Lucene | 9.10.0 (bundled in fat jar) |
# Clone and build
git clone https://github.com/YOUR_ORG/fstguardrails.git
cd fstguardrails/java-tagger-demo
mvn package -q
# Verify
java -jar target/tagger.jarThe fat jar is ~12 MB and has zero runtime dependencies outside the JDK.
Part of the Java FST Guard Rails project.