I learn best when I have a real problem in front of me. Documentation is useful, but I only start to understand a technology when I need to make decisions, fix weak results, and keep the whole flow working.

That is why I started Lexio.

Lexio is my own product and a place where I can learn how AI works inside a real application. It answers questions with context retrieved from selected Slovak legal documents. The trial version has a limited scope by design. Its answers are informational and should always be checked against the official text or with a qualified professional.

I wanted more than another AI demo

My goal was not to put a chat window around a model API. I wanted to understand what RAG — retrieval-augmented generation — looks like when it becomes part of a product.

The simple version is: find relevant text, send it to the model as context, and show the answer. A real application needs much more. It needs document indexing, support for normal human language, several search methods, result scoring, fallbacks, streaming, and observability.

These layers are the most interesting part of Lexio for me.

What happens before the first question

Lexio can read PDF and HTML versions of legal documents. Splitting them every few thousand characters is not enough. A random split can separate a section title from its text or cut one legal paragraph into two unrelated parts.

Lexio therefore has its own LegalTextChunker. It removes page headers and broken words first. Then it finds legal sections and appendices. Only long sections are split again, preferably at a paragraph or sentence boundary.

01 / IndexingHow a law becomes searchable context
keep § with its textslang + short forms256 dimensions

split at § boundaries, not at random characters

The core of the chunker is small, but it has a large effect on retrieval quality:

private static final Pattern PARAGRAPH_HEADING =
        Pattern.compile("^§\\s*(\\d+[a-zA-Z]*)$");

List<Section> sections = parseSections(cleanLines(rawText));
for (Section section : sections) {
    chunks.addAll(chunks(section, Math.max(1000, maxCharacters)));
}

Each chunk keeps its document, section number, title, body, and a stable chunkKey. Before creating an embedding, another step can enrich the chunk with legal terms, abbreviations, synonyms, slang, and typical user questions. The model must return a strict JSON schema. If enrichment fails, the system safely falls back to the original text.

The current embeddings have 256 dimensions. They are stored in PostgreSQL with pgvector and an HNSW index. Documents and chunks also have content hashes, so I can track which version was indexed.

One question starts more than one search

Users do not write like lawyers. They use short forms, slang, or describe a full situation in normal language. A direct embedding can find related text but still miss the exact legal section.

Query planning creates several views of the same question. It adds known abbreviations and domain concepts. For situation-based questions, it can use an AI query rewrite. It can also reuse useful plans from a small vector memory. The output is not an answer. It is a list of short retrieval queries.

02 / One questionFrom a normal sentence to an answer with a source
slang → legal concept40 candidateslow score? search again

↖ retrieval trace · eval · admin review

The search itself is hybrid. Each query runs through vector search, while a lexical search runs beside it. The candidates are merged, deduplicated, and ranked again:

for (float[] embedding : embeddings) {
    repository.search(embedding, CANDIDATE_LIMIT)
            .forEach(chunk -> candidates.add(new RetrievalCandidate(chunk, "vector")));
}

repository.searchText(searchTerms(plan), CANDIDATE_LIMIT)
        .forEach(chunk -> candidates.add(new RetrievalCandidate(chunk, "lexical")));

List<RetrievalCandidate> ranked = rerank(candidates, plan);

The ranking uses similarity and domain signals. Lexio also calculates a confidence score from the best result, concept coverage, source diversity, and candidate strength. A low score can recommend a second search instead of letting the system pretend that it is certain.

Every search creates a retrieval trace. It contains the original question, rewritten queries, recognised concepts, chunk order, score, distance, and whether a result came from vector or text search. When an answer is weak, I can look one step back and see whether the model failed or retrieval was already wrong.

Spring AI was the start, not the final shape

When I started this project, I wanted to learn Spring AI and understand what a framework can simplify in an AI application. During development, I decided to work one level lower in several places.

The current implementation runs on Java 17 and Spring Boot. It calls the OpenAI Responses API and embeddings through a thin provider layer built with RestClient and HttpClient. The AiProvider interface keeps the rest of the application separate from one model or provider.

This approach taught me details that a wrapper can hide: SSE streaming events, token usage, prompt cache keys, timeouts, error responses, and the estimated cost of each type of call. The answer is streamed to the browser as NDJSON. The user sees real tokens as they arrive, not an animation of finished text.

The admin interface closes the loop

The admin area is more than a table of questions. For each answer, I can see the model, answer source, status, token count, estimated cost, and total latency. The application also stores timing for separate stages such as conversation history, query planning, retrieval, model execution, and persistence.

Stored questions and generated answers are not public. I use them to find repeating problems:

  • retrieval found a related but incorrect section,
  • the answer missed an important limitation,
  • the language was too technical,
  • or the model received an unclear instruction.

I can find the related trace, repeat the question, and compare the result after a change. Lexio can also compare the older “whole document in context” approach with the new RAG result without showing the experiment to the user.

Evals instead of feelings

The repository contains a small dataset of questions with expected sources. An eval runner executes retrieval for every question and fails if the expected section or document is missing from the results.

{
  "id": "weapon-license-abbreviation",
  "question": "Co znamena ZP?",
  "expectedSources": ["zbrojny preukaz"]
}

This is not a complete answer-quality test. It is a simple guard against silently breaking cases that already worked when I change chunking, query rewrite, or ranking. There are also unit tests for section boundaries, long legal sections, abbreviations, out-of-scope questions, and hiding sources when the answer says that nothing was found.

What Lexio is teaching me

The biggest lesson is that RAG is not one feature and it is not magic. The whole chain controls answer quality: source documents, chunking, enrichment, query planning, retrieval, prompt, model, and evaluation.

I am also learning to treat prompts more like code. They need a clear purpose, observable behaviour, real test cases, and small changes that I can compare.

Lexio is still a small trial product, and that is intentional. Its limited scope makes it easier to see what works and what fails. The next goal is not to make the chat look smarter. I want to grow the eval dataset, improve confidence handling, and make source checking easier.

You can try the current version at lexio.donit.sk/lexio.

This article is based on my real code and experience building Lexio. AI helped me edit and translate the text.