En este artículo les voy a explicar cómo podemos hacer uso de Apache Tika creando un API Rest con Quarkus y luego extenderlo por medio de Apache Lucene.

En muchos proyectos he observado que parte de los requerimientos que se exponen es la necesidad de almacenar archivos de diversos tipos; sean estos PDF, imágenes, documentos de Word, texto, csv, para nombrar unos cuantos.

Aunque resulta trivial implementar tal función, el hecho de únicamente almacenar el archivo deja de lado una fuente de datos que puede ser muy valiosa para los usuarios. El contenido de cada uno de esos archivos debería poder consultarse de una manera sencilla y práctica.

Existen muchas maneras de lograr ese objetivo, pero en este artículo trataremos sobre Apache Tika.

¿Qué es Apache Tika?

Es un proyecto de Apache que -citándolos- permite detectar, extraer metadata y texto de más de mil tipos de formatos de archivos, a través de una interfaz muy simple. Entonces, resulta un aliado indispensable para lograr nuestro objetivo, pues nos despreocupamos de tratar de manejar nosotros cada formato de archivo que pueda recibir nuestro sistema.

Para empezar, vamos a crear un API REST usando Quarkus; el cual ya posee una extensión para Apache Tika. Usaremos el siguiente comando.

mvn io.quarkus:quarkus-maven-plugin:1.4.2.Final:create \
    -DprojectGroupId=dev.gerardo \
    -DprojectArtifactId=tika-demo \
    -DclassName="dev.gerardo.tika.demo.TikaParserResource" \
    -Dpath="/api"

Y vamos a agregar las siguientes dependencias

  mvn quarkus:add-extension
     -Dextensions="quarkus-resteasy-jsonb, quarkus-tika"

Procedemos a crear un nuevo servicio para nuestra aplicación, en este vamos a disponer de la lógica para extraer los datos de los archivos que recibimos.

@ApplicationScoped
public class TikaService {

    @Inject
    TikaParser parser;

Primeramente, vamos a injectar un TikaParser (que es una clase de Quarkus) para trabajar con Tika. Para utilizarla, hacemos lo siguiente:

public Documento parsear(InputStream input) {
       TikaContent contenido = parser.parse(input);
       TikaMetadata meta = contenido.getMetadata();
       StringBuilder metadata = new StringBuilder();
       Set<String> names = meta.getNames();
       for (String name : names) {
           metadata.append(name + " = " +
              meta.getValues(name) + "\n");
       }

       return new Documento(metadata.toString(),
            contenido.getText());
   }

Observen que recibimos un InputStream que es nuestro archivo y realizamos dos procesos. Obtenemos el contenido del archivo a traves del método parse y luego toda la metadata por medio de getMetadata.

Vale la pena recalcar que no programe nada para determinar si es un PDF o un Word u otro tipo de archivo, solo uso una interfaz sencilla para traer los datos.

Para este ejemplo, retorno un POJO con la metadata y el contenido. Por supuesto, se puede regresar la metada de una manera más conveniente, pero lo trataré de manejar de una manera simple.

Veamos ahora nuestro API, vamos a extenderlo para que podamos recibir documentos.

@Path("/api")
public class TikaParserResource {

   @Inject
   TikaService serviceTika;

   @POST
   @Path("/guardar")
   @Consumes(MediaType.MEDIA_TYPE_WILDCARD)
   @Produces(MediaType.APPLICATION_JSON)
   public Documento extractText(InputStream stream) {
       Documento doc = serviceTika.parsear(stream);

       return doc;
   }

En este caso, tenemos un método que consume basicamente cualquier cosa (podemos ser más selectos y permitir solo PDF, imágenes, etc) y retornamos un JSON.

Para probarlo hacemos lo siguiente:

curl -X POST -H "Content-type: application/pdf"
    --data-binary @data/3.pdf http://localhost:8080/api/guardar

Y esto nos retorna un JSON con el contenido de ese PDF y su metadata, por ejemplo un extracto.

{"contenido":"Este es el contenido de un PDF",
"metadata":"date = [2020-05-19T17:57:30Z]\nContent-Type = [application/pdf]\n
  pdf:docinfo:creator = [personal]\n
  X-Parsed-By = [org.apache.tika.parser.CompositeParser, org.apache.tika.parser.pdf.PDFParser]\n
  creator = [personal]\ndc:language = [es-ES]"
}

Ahora, si enviamos una imagen

curl -X POST -H "Content-type: image/jpeg"
   --data-binary @data/1.jpg http://localhost:8080/api/guardar

Obtenemos que ciertamente no tiene texto (aunque si es una imagen de un documento, se podría aplicar un OCR), pero si metadata interesante.

{"contenido":"",
"metadata":"Exif Version = [2.31]
 Compression Type = [Baseline]
 Number of Components = [3]
 Make = [Apple]
 F-Number = [f/1.8]
 Primary Platform = [Apple Computer, Inc.]
 GPS Latitude Ref = [N]
 GPS Dest Bearing = [197.36 degrees]
}

Si enviamos un video

curl -X POST -H "Content-type: video/mp4"
    --data-binary @data/5.mp4 http://localhost:8080/api/guardar

Y obtenemos la metadata de ese video

{"contenido":"",
"metadata":"date = [2020-05-12T17:03:31Z]
   X-Parsed-By = [org.apache.tika.parser.CompositeParser, org.apache.tika.parser.mp4.MP4Parser]
   tiff:ImageLength = [720]
   xmpDM:duration = [36.3]
   Content-Type = [video/mp4]"
 }

Podemos seguir enviando diversos tipo de archivo y para cada uno se extrae la metadata y el contenido.

Ahora que tenemos ese contenido debemos poder ser capaces de luego hacer búsquedas sobre el mismo. Acá es donde entra el segundo componente, Apache Lucene.

¿Qué es Apache Lucene?

Este es otro proyecto de Apache que -citándolos- brinda una serie de características de indexación y búsqueda bastante poderosas. Por tanto, es idóneo para poder indexar ese contenido que obtuvimos y luego buscar por palabras o contenido de nuestro interés.

Debemos agregar a nuestro POM las siguientes dependencias. Aclaro que aún no hay una extensión de Quakus para Lucene, por lo cual no puedo garantizar que se pueda crear una imagen nativa.

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-queryparser</artifactId>
  <version>8.5.1</version>
</dependency>
<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-core</artifactId>
  <version>8.5.1</version>
</dependency>

Luego creamos un servicio nuevo para tener la lógica de indexación y búsqueda, nos concentramos en la indexación.

public void indexar(Documento documento) {
    try {
        Directory dir = FSDirectory.open(Paths.get(INDEX_PATH));
        Analyzer analyzer = new StandardAnalyzer();
        IndexWriterConfig iwc = new IndexWriterConfig(analyzer);

        // Se abre en modo create o append
        iwc.setOpenMode(OpenMode.CREATE_OR_APPEND);
        IndexWriter writer = new IndexWriter(dir, iwc);
        Document doc = new Document();
        Field pathField = new StringField("metadata",
             documento.getMetadata(), Field.Store.YES);
        doc.add(pathField);
        doc.add(new TextField("contents",
             documento.getContenido(), Field.Store.YES));
        writer.addDocument(doc);
        writer.close();
    } catch (IOException ex) {
        log.log(Level.SEVERE, "Error al indexar", ex);
    }
}

Noten que primero se define un directorio para el índice que crea Lucene y se crea o actualiza según corresponda. Se define un documento de Lucene y se agregan los atributos, en este caso dos de ellos, uno para la metadata y otro para el contenido, por último se escribe ese documento al índice.

Les recomiendo que vean toda la documentación de Lucene para que revisen los analizadores y los atributos que poseen.

Y para poder consultar ese índice, creamos otro método

public List<Documento> buscar(String texto) {
     ArrayList<Documento> respuestas = new ArrayList<>();
     try {
         IndexReader reader = DirectoryReader.open(
             FSDirectory.open(Paths.get(INDEX_PATH)));
         IndexSearcher searcher = new IndexSearcher(reader);
         Analyzer analyzer = new StandardAnalyzer();

         QueryParser parser = new QueryParser("contents",
           analyzer);
         Query query = parser.parse(texto);
         TopDocs results = searcher.search(query, 100);
         ScoreDoc[] hits = results.scoreDocs;

         for (int i = 0; i < hits.length; i++) {

             Document doc = searcher.doc(hits[i].doc);
             respuestas.add(new Documento(doc.get("metadata"),
                 doc.get("contents")));

         }
         reader.close();
     } catch (Exception ex) {
         log.log(Level.SEVERE, "Error al consultar", ex);
     }

     return respuestas;
 }

Tomamos un texto de entrada, y se accede el índice para buscar por el campo contents que indexamos anteriormente. Luego iteramos por cada resultado y retornamos una lista con los documentos que cumplen este criterio. Es importante señalar que se pueden hacer búsquedas mucho más elaboradas, pues en el texto de entrada puedo usar comodines, como por ejemplo y sin listar todos.

  • El comodín * permite realizar búsquedas por más de un caracter. Si busco por GER* retornaría todos los documentos que tengan GERARDO o GERMANIA.
  • El comodín ? le permite realizar búsquedas reemplazando un único caracter. Si busco GER? buscaría cualquier documento que tenga, como parte de su contenido, la palabra GERA y cualquier otra letra como: GERR y GERE pero no GERAR.
  • Si se desea buscar por un patrón exacto, se debe colocar entre comillas dobles el conjunto de palabras que desea localizar.
  • La palabra “AND” permite realizar búsquedas por exactamente las palabras que se detallan.
  • La palabra “NOT” permite realizar búsquedas en donde se desea que una o más palabras no sean parte del nombre de la empresa.

Ahora, extendemos nuestro recurso de la siguiente manera para poder llamar al servicio de indexación.

@POST
  @Path("/guardar")
  @Consumes(MediaType.MEDIA_TYPE_WILDCARD)
  @Produces(MediaType.APPLICATION_JSON)
  public Documento extractText(InputStream stream) {
      Documento doc = serviceTika.parsear(stream);

      luceneService.indexar(doc);

      log.log(Level.INFO, doc.toString());
      return doc;
  }

Y un recurso para la búsqueda:

  @GET
  @Path("/buscar")
  @Produces(MediaType.APPLICATION_JSON)
  public List<Documento> consultar(@QueryParam("query") String query) {
      return luceneService.buscar(query);
  }

Si accesamos a nuestro API de búsqueda (luego de indexar algunos documentos)

curl -i  http://localhost:8080/api/buscar?query=Gerardo

Obtenemos una lista de todos los documentos donde aparece mi nombre.

Conclusión


Espero que este artículo les permita dar un vistazo general de Apache Tika/Lucene y como pueden usarlos para accesar todos esos archivos que muchos de nuestros desarrollos almacenan. Todo usando un API muy sencillo y que nos simplifica nuestro trabajo.

El código fuente se encuentra disponible en github.