This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/main by this push:
     new 64a8439a5 OPENNLP-587: Document the Entity Linker framework (#1169)
64a8439a5 is described below

commit 64a8439a5ef88f0df242d8dce6b80619ae536e97
Author: jerry317395616 <[email protected]>
AuthorDate: Thu Jul 16 14:46:30 2026 +0800

    OPENNLP-587: Document the Entity Linker framework (#1169)
---
 .../EntityLinkerManualExamplesTest.java            | 177 +++++++++++++++++
 opennlp-docs/src/docbkx/cli.xml                    |   8 +
 opennlp-docs/src/docbkx/entitylinker.xml           | 213 +++++++++++++++++++++
 opennlp-docs/src/docbkx/opennlp.xml                |   1 +
 4 files changed, 399 insertions(+)

diff --git 
a/opennlp-api/src/test/java/opennlp/tools/entitylinker/EntityLinkerManualExamplesTest.java
 
b/opennlp-api/src/test/java/opennlp/tools/entitylinker/EntityLinkerManualExamplesTest.java
new file mode 100644
index 000000000..40f97f54d
--- /dev/null
+++ 
b/opennlp-api/src/test/java/opennlp/tools/entitylinker/EntityLinkerManualExamplesTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.entitylinker;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.namefind.TokenNameFinder;
+import opennlp.tools.sentdetect.SentenceDetector;
+import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.tokenize.WhitespaceTokenizer;
+import opennlp.tools.util.Span;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class EntityLinkerManualExamplesTest {
+
+  private static final String DOCUMENT_TEXT = "Visit Paris\nThen New York";
+
+  @Test
+  void testNameTokenIndexesBecomeDocumentOffsets() {
+    EntityLinker<LinkedSpan<GazetteerLink>> linker = new 
GazetteerLinker(Map.of(
+        "Paris", new GazetteerLink("Q90", "Paris"),
+        "New York", new GazetteerLink("Q60", "New York")));
+
+    List<? extends Span> results = linkEntities(DOCUMENT_TEXT, new 
FixedSentenceDetector(),
+        WhitespaceTokenizer.INSTANCE, new FixedNameFinder(), linker);
+
+    assertEquals(2, results.size());
+    assertLinkedSpan((LinkedSpan<?>) results.get(0), 6, 11, 0, "Paris", "Q90");
+    assertLinkedSpan((LinkedSpan<?>) results.get(1), 17, 25, 1, "New York", 
"Q60");
+  }
+
+  private static List<? extends Span> linkEntities(String documentText,
+      SentenceDetector sentenceDetector, Tokenizer tokenizer,
+      TokenNameFinder nameFinder, EntityLinker<? extends Span> linker) {
+
+    Span[] sentences = sentenceDetector.sentPosDetect(documentText);
+    Span[][] tokensBySentence = new Span[sentences.length][];
+    Span[][] namesBySentence = new Span[sentences.length][];
+
+    for (int i = 0; i < sentences.length; i++) {
+      Span sentence = sentences[i];
+      String sentenceText = sentence.getCoveredText(documentText).toString();
+      Span[] relativeTokenSpans = tokenizer.tokenizePos(sentenceText);
+      String[] tokenTexts = Span.spansToStrings(relativeTokenSpans, 
sentenceText);
+
+      namesBySentence[i] = nameFinder.find(tokenTexts);
+      tokensBySentence[i] = new Span[relativeTokenSpans.length];
+      for (int j = 0; j < relativeTokenSpans.length; j++) {
+        // Tokenizer offsets are sentence-relative; EntityLinker expects 
document offsets.
+        tokensBySentence[i][j] = new Span(relativeTokenSpans[j], 
sentence.getStart());
+      }
+    }
+
+    return linker.find(documentText, sentences, tokensBySentence, 
namesBySentence);
+  }
+
+  private static void assertLinkedSpan(LinkedSpan<?> span, int start, int end,
+      int sentenceId, String searchTerm, String itemId) {
+    assertAll(
+        () -> assertEquals(start, span.getStart()),
+        () -> assertEquals(end, span.getEnd()),
+        () -> assertEquals("location", span.getType()),
+        () -> assertEquals(sentenceId, span.getSentenceid()),
+        () -> assertEquals(searchTerm, span.getSearchTerm()),
+        () -> assertEquals(itemId, 
span.getLinkedEntries().get(0).getItemID()));
+  }
+
+  private static final class FixedSentenceDetector implements SentenceDetector 
{
+
+    @Override
+    public String[] sentDetect(CharSequence text) {
+      return Span.spansToStrings(sentPosDetect(text), text);
+    }
+
+    @Override
+    public Span[] sentPosDetect(CharSequence text) {
+      return new Span[] {new Span(0, 11), new Span(12, 25)};
+    }
+  }
+
+  private static final class FixedNameFinder implements TokenNameFinder {
+
+    @Override
+    public Span[] find(String[] tokens) {
+      if (tokens.length == 2) {
+        return new Span[] {new Span(1, 2, "location")};
+      }
+      return new Span[] {new Span(1, 3, "location")};
+    }
+
+    @Override
+    public void clearAdaptiveData() {
+      // The fixed test implementation does not retain adaptive data.
+    }
+  }
+
+  private static final class GazetteerLink extends BaseLink {
+
+    private GazetteerLink(String id, String name) {
+      super(null, id, name, "location");
+    }
+  }
+
+  private static final class GazetteerLinker
+      implements EntityLinker<LinkedSpan<GazetteerLink>> {
+
+    private final Map<String, GazetteerLink> entries;
+
+    private GazetteerLinker(Map<String, GazetteerLink> entries) {
+      this.entries = entries;
+    }
+
+    @Override
+    public void init(EntityLinkerProperties properties) {
+      // The test linker receives its entries in memory and needs no external 
initialization.
+    }
+
+    @Override
+    public List<LinkedSpan<GazetteerLink>> find(String documentText,
+        Span[] sentences, Span[][] tokensBySentence, Span[][] namesBySentence) 
{
+      List<LinkedSpan<GazetteerLink>> results = new ArrayList<>();
+      for (int i = 0; i < sentences.length; i++) {
+        results.addAll(find(documentText, sentences, tokensBySentence,
+            namesBySentence, i));
+      }
+      return results;
+    }
+
+    @Override
+    public List<LinkedSpan<GazetteerLink>> find(String documentText,
+        Span[] sentences, Span[][] tokensBySentence, Span[][] namesBySentence,
+        int sentenceIndex) {
+      List<LinkedSpan<GazetteerLink>> results = new ArrayList<>();
+      Span[] tokens = tokensBySentence[sentenceIndex];
+
+      for (Span name : namesBySentence[sentenceIndex]) {
+        // Name spans contain token indexes, so map their boundaries through 
the token spans.
+        int start = tokens[name.getStart()].getStart();
+        int end = tokens[name.getEnd() - 1].getEnd();
+        String searchTerm = documentText.substring(start, end);
+        GazetteerLink match = entries.get(searchTerm);
+
+        if (match != null) {
+          ArrayList<GazetteerLink> matches = new ArrayList<>();
+          matches.add(match);
+          LinkedSpan<GazetteerLink> linkedSpan =
+              new LinkedSpan<>(matches, start, end, name.getType());
+          linkedSpan.setSentenceid(sentenceIndex);
+          linkedSpan.setSearchTerm(searchTerm);
+          results.add(linkedSpan);
+        }
+      }
+      return results;
+    }
+  }
+}
diff --git a/opennlp-docs/src/docbkx/cli.xml b/opennlp-docs/src/docbkx/cli.xml
index 23d44a070..0c29307cf 100644
--- a/opennlp-docs/src/docbkx/cli.xml
+++ b/opennlp-docs/src/docbkx/cli.xml
@@ -4381,6 +4381,14 @@ Arguments description:
 
   <para>Links an entity to an external data set</para>
 
+  <para>
+  The <emphasis>model</emphasis> argument is an Entity Linker properties file. 
The command loads
+  the implementation named by <literal>linker.location</literal> and reads 
tokenized,
+  named-entity-annotated sentences from standard input. See the
+  <link linkend="tools.entitylinker.commandline">Entity Linker chapter</link> 
for the input format
+  and configuration details.
+  </para>
+
   <screen>
 <![CDATA[Usage: opennlp EntityLinker model < sentences]]>
   </screen>
diff --git a/opennlp-docs/src/docbkx/entitylinker.xml 
b/opennlp-docs/src/docbkx/entitylinker.xml
new file mode 100644
index 000000000..628d9a7d5
--- /dev/null
+++ b/opennlp-docs/src/docbkx/entitylinker.xml
@@ -0,0 +1,213 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE chapter PUBLIC "-//OASIS//DTD DocBook XML 5.0//EN"
+"http://docbook.org/xml/5.0/dtd/docbook.dtd";[
+]>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<chapter xml:id="tools.entitylinker" 
xmlns:xlink="http://www.w3.org/1999/xlink";>
+
+       <title>Entity Linker</title>
+
+       <section xml:id="tools.entitylinker.overview">
+               <title>Overview</title>
+               <para>
+               The Entity Linker API lets an application associate named 
entities with records in an
+               external data source. For example, a location found by a Name 
Finder can be matched to a
+               gazetteer entry, or a person can be matched to a directory 
record. OpenNLP provides the
+               linking interfaces and data types; applications supply the 
implementation and data source.
+               </para>
+               <para>
+               An <classname>EntityLinker</classname> receives the document 
text, sentence and token spans,
+               and the name spans produced for each sentence. It returns 
application-defined spans that hold
+               the links. <classname>LinkedSpan</classname> and 
<classname>BaseLink</classname> are support
+               classes for this result; they are not linker implementations.
+               </para>
+       </section>
+
+       <section xml:id="tools.entitylinker.inputs">
+               <title>Preparing Input Spans</title>
+               <para>
+               The four arguments passed to <methodname>find</methodname> use 
two coordinate systems. Sentence
+               and token spans are character offsets in the complete document. 
Name spans are token indexes
+               within their sentence, as returned by 
<classname>NameFinderME</classname>. The outer indexes of
+               <varname>tokensBySentence</varname> and 
<varname>namesBySentence</varname> must correspond to the
+               same entry in <varname>sentences</varname>.
+               </para>
+               <para>
+               The following method prepares those arrays. The sentence 
detector, tokenizer, and name finder
+               are accepted as interfaces so the same code works with their 
standard OpenNLP implementations.
+               <programlisting language="java">
+<![CDATA[static List<? extends Span> linkEntities(String documentText,
+    SentenceDetector sentenceDetector, Tokenizer tokenizer,
+    TokenNameFinder nameFinder, EntityLinker<? extends Span> linker) {
+
+  Span[] sentences = sentenceDetector.sentPosDetect(documentText);
+  Span[][] tokensBySentence = new Span[sentences.length][];
+  Span[][] namesBySentence = new Span[sentences.length][];
+
+  for (int i = 0; i < sentences.length; i++) {
+    Span sentence = sentences[i];
+    String sentenceText = sentence.getCoveredText(documentText).toString();
+    Span[] relativeTokenSpans = tokenizer.tokenizePos(sentenceText);
+    String[] tokenTexts = Span.spansToStrings(relativeTokenSpans, 
sentenceText);
+
+    namesBySentence[i] = nameFinder.find(tokenTexts);
+    tokensBySentence[i] = new Span[relativeTokenSpans.length];
+    for (int j = 0; j < relativeTokenSpans.length; j++) {
+      // Tokenizer offsets are sentence-relative; EntityLinker expects 
document offsets.
+      tokensBySentence[i][j] = new Span(relativeTokenSpans[j], 
sentence.getStart());
+    }
+  }
+
+  return linker.find(documentText, sentences, tokensBySentence, 
namesBySentence);
+}]]>
+               </programlisting>
+               </para>
+       </section>
+
+       <section xml:id="tools.entitylinker.implementing">
+               <title>Implementing an Entity Linker</title>
+               <para>
+               Implement <classname>EntityLinker&lt;T&gt;</classname>, where 
<classname>T</classname> extends
+               <classname>Span</classname>. Load reusable resources in 
<methodname>init</methodname>. Implement
+               the document overload of <methodname>find</methodname> by 
visiting each sentence, and implement
+               the sentence overload with the actual lookup logic.
+               </para>
+               <para>
+               This example uses <classname>LinkedSpan</classname> for its 
results and a small concrete
+               subclass of <classname>BaseLink</classname> for gazetteer 
records:
+               <programlisting language="java">
+<![CDATA[public final class GazetteerLink extends BaseLink {
+
+  public GazetteerLink(String id, String name) {
+    super(null, id, name, "location");
+  }
+}
+
+public final class GazetteerLinker
+    implements EntityLinker<LinkedSpan<GazetteerLink>> {
+
+  private Map<String, GazetteerLink> entries;
+
+  @Override
+  public void init(EntityLinkerProperties properties) throws IOException {
+    String path = properties.getProperty("gazetteer.path", "gazetteer.tsv");
+    entries = loadEntries(Path.of(path));
+  }
+
+  @Override
+  public List<LinkedSpan<GazetteerLink>> find(String documentText,
+      Span[] sentences, Span[][] tokensBySentence, Span[][] namesBySentence) {
+    List<LinkedSpan<GazetteerLink>> results = new ArrayList<>();
+    for (int i = 0; i < sentences.length; i++) {
+      results.addAll(find(documentText, sentences, tokensBySentence,
+          namesBySentence, i));
+    }
+    return results;
+  }
+
+  @Override
+  public List<LinkedSpan<GazetteerLink>> find(String documentText,
+      Span[] sentences, Span[][] tokensBySentence, Span[][] namesBySentence,
+      int sentenceIndex) {
+    List<LinkedSpan<GazetteerLink>> results = new ArrayList<>();
+    Span[] tokens = tokensBySentence[sentenceIndex];
+
+    for (Span name : namesBySentence[sentenceIndex]) {
+      // Name spans contain token indexes, so map their boundaries through the 
token spans.
+      int start = tokens[name.getStart()].getStart();
+      int end = tokens[name.getEnd() - 1].getEnd();
+      String searchTerm = documentText.substring(start, end);
+      GazetteerLink match = entries.get(searchTerm);
+
+      if (match != null) {
+        ArrayList<GazetteerLink> matches = new ArrayList<>();
+        matches.add(match);
+        LinkedSpan<GazetteerLink> linkedSpan =
+            new LinkedSpan<>(matches, start, end, name.getType());
+        linkedSpan.setSentenceid(sentenceIndex);
+        linkedSpan.setSearchTerm(searchTerm);
+        results.add(linkedSpan);
+      }
+    }
+    return results;
+  }
+
+  private static Map<String, GazetteerLink> loadEntries(Path path)
+      throws IOException {
+    // Parse the application's data source here.
+    return Map.of();
+  }
+}]]>
+               </programlisting>
+               </para>
+               <para>
+               A production implementation should validate array lengths and 
name boundaries before indexing
+               into <varname>tokens</varname>, normalize lookup keys 
consistently, and define how multiple
+               candidate records are scored and ordered.
+               </para>
+       </section>
+
+       <section xml:id="tools.entitylinker.configuration">
+               <title>Loading an Implementation</title>
+               <para>
+               <classname>EntityLinkerFactory</classname> loads an 
implementation through OpenNLP's extension
+               loader and then calls its <methodname>init</methodname> method. 
With the overload that accepts an
+               entity type, the key is 
<literal>linker.&lt;entityType&gt;</literal>:
+               <screen>
+<![CDATA[linker.location=com.example.GazetteerLinker
+gazetteer.path=/data/locations.tsv]]>
+               </screen>
+               </para>
+               <para>
+               Load the properties and request the configured linker as 
follows:
+               <programlisting language="java">
+<![CDATA[EntityLinkerProperties properties =
+    new EntityLinkerProperties(new File("entity-linker.properties"));
+EntityLinker<?> linker = EntityLinkerFactory.getLinker("location", 
properties);]]>
+               </programlisting>
+               The overload without an entity type reads the key 
<literal>linker</literal> instead.
+               </para>
+       </section>
+
+       <section xml:id="tools.entitylinker.commandline">
+               <title>Command Line Tool</title>
+               <para>
+               The <command>EntityLinker</command> command loads the 
<literal>linker.location</literal>
+               implementation from a properties file. It reads 
blank-line-separated documents in the native
+               Name Finder sample format from standard input and writes each 
linked span to standard output.
+               </para>
+               <screen>
+<![CDATA[opennlp EntityLinker entity-linker.properties < names.txt]]>
+               </screen>
+               <para>
+               Each non-empty input line is a tokenized sentence whose names 
use
+               <literal>&lt;START:type&gt;</literal> and 
<literal>&lt;END&gt;</literal> markers. For example:
+               <screen>
+<![CDATA[The office is in <START:location> New York <END> .
+
+The second document starts after the blank line .
+
+]]>
+               </screen>
+               </para>
+       </section>
+
+</chapter>
diff --git a/opennlp-docs/src/docbkx/opennlp.xml 
b/opennlp-docs/src/docbkx/opennlp.xml
index 5d0166c5e..263c5f96c 100644
--- a/opennlp-docs/src/docbkx/opennlp.xml
+++ b/opennlp-docs/src/docbkx/opennlp.xml
@@ -111,6 +111,7 @@ under the License.
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./spellcheck.xml" />
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./chunker.xml" />
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./parser.xml" />
+       <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./entitylinker.xml" />
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./coref.xml" />
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./model-loading.xml" />
        <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"; 
href="./extension.xml" />

Reply via email to