RED-10290: Improve SearchImplementation logic for dictionaries
* use a different implementation as the DoubleArrayTrieDictionarySearch has a massive performance drop as soon as a certain threshold in terms of dictionary size is reached
This commit is contained in:
parent
a9e4fa31bf
commit
d72e35723c
@ -63,6 +63,7 @@ dependencies {
|
|||||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}")
|
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}")
|
||||||
implementation("org.ahocorasick:ahocorasick:0.9.0")
|
implementation("org.ahocorasick:ahocorasick:0.9.0")
|
||||||
implementation("com.hankcs:aho-corasick-double-array-trie:1.2.2")
|
implementation("com.hankcs:aho-corasick-double-array-trie:1.2.2")
|
||||||
|
implementation("com.github.roklenarcic:aho-corasick:1.2")
|
||||||
implementation("org.javassist:javassist:3.29.2-GA")
|
implementation("org.javassist:javassist:3.29.2-GA")
|
||||||
|
|
||||||
implementation("org.drools:drools-engine:${droolsVersion}")
|
implementation("org.drools:drools-engine:${droolsVersion}")
|
||||||
|
|||||||
@ -0,0 +1,130 @@
|
|||||||
|
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
||||||
|
import com.iqser.red.service.redaction.v1.server.model.document.textblock.TextBlock;
|
||||||
|
|
||||||
|
public abstract class AbstractDictionarySearch implements DictionarySearch {
|
||||||
|
|
||||||
|
protected final Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap;
|
||||||
|
|
||||||
|
|
||||||
|
public AbstractDictionarySearch(Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap) {
|
||||||
|
|
||||||
|
this.keyWordToIdentifiersMap = keyWordToIdentifiersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<MatchTextRange> getBoundaries(CharSequence text) {
|
||||||
|
|
||||||
|
TextContext textContext = new TextContext(text);
|
||||||
|
return getMatchTextRangeStream(textContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<MatchTextRange> getBoundaries(CharSequence text, TextRange region) {
|
||||||
|
|
||||||
|
CharSequence subText = text.subSequence(region.start(), region.end());
|
||||||
|
TextContext textContext = new TextContext(subText, region.start());
|
||||||
|
return getMatchTextRangeStream(textContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<MatchTextRange> getBoundaries(TextBlock textBlock) {
|
||||||
|
|
||||||
|
return getBoundaries(textBlock, textBlock.getTextRange());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Stream<MatchPosition> getMatches(String text) {
|
||||||
|
|
||||||
|
TextContext textContext = new TextContext(text);
|
||||||
|
List<MatchPosition> matches = new ArrayList<>();
|
||||||
|
|
||||||
|
parseText(textContext.getLowerText(), (begin, end, value) -> addMatchPositionsForHit(textContext, matches, new Hit(begin, end, value)));
|
||||||
|
|
||||||
|
return matches.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private Stream<MatchTextRange> getMatchTextRangeStream(TextContext textContext) {
|
||||||
|
|
||||||
|
List<MatchTextRange> matches = new ArrayList<>();
|
||||||
|
|
||||||
|
parseText(textContext.getLowerText(), (begin, end, value) -> addMatchesForHit(textContext, matches, new Hit(begin, end, value)));
|
||||||
|
|
||||||
|
return matches.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected abstract void parseText(CharSequence text, HitHandler handler);
|
||||||
|
|
||||||
|
|
||||||
|
protected void addMatchesForHit(TextContext textContext, List<MatchTextRange> matches, Hit hit) {
|
||||||
|
|
||||||
|
int start = textContext.getStart(hit.begin);
|
||||||
|
int end = textContext.getEnd(hit.end);
|
||||||
|
String matchedText = textContext.getMatchedText(hit.begin, hit.end);
|
||||||
|
List<DictionaryIdentifierWithKeyword> idWithKeywords = hit.value;
|
||||||
|
|
||||||
|
for (DictionaryIdentifierWithKeyword idkw : idWithKeywords) {
|
||||||
|
if (idkw.identifier().caseSensitive()) {
|
||||||
|
if (matchedText.equals(idkw.keyword())) {
|
||||||
|
matches.add(new MatchTextRange(idkw.identifier(), new TextRange(start, end)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matches.add(new MatchTextRange(idkw.identifier(), new TextRange(start, end)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected void addMatchPositionsForHit(TextContext textContext, List<MatchPosition> matches, Hit hit) {
|
||||||
|
|
||||||
|
int start = textContext.getStart(hit.begin);
|
||||||
|
int end = textContext.getEnd(hit.end);
|
||||||
|
String matchedText = textContext.getMatchedText(hit.begin, hit.end);
|
||||||
|
List<DictionaryIdentifierWithKeyword> idWithKeywords = hit.value;
|
||||||
|
|
||||||
|
for (DictionaryIdentifierWithKeyword idkw : idWithKeywords) {
|
||||||
|
MatchPosition matchPosition = new MatchPosition(idkw.identifier(), start, end);
|
||||||
|
if (idkw.identifier().caseSensitive()) {
|
||||||
|
if (matchedText.equals(idkw.keyword())) {
|
||||||
|
matches.add(matchPosition);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matches.add(matchPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected interface HitHandler {
|
||||||
|
|
||||||
|
void handle(int begin, int end, List<DictionaryIdentifierWithKeyword> value);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static class Hit {
|
||||||
|
|
||||||
|
final int begin;
|
||||||
|
final int end;
|
||||||
|
final List<DictionaryIdentifierWithKeyword> value;
|
||||||
|
|
||||||
|
|
||||||
|
Hit(int begin, int end, List<DictionaryIdentifierWithKeyword> value) {
|
||||||
|
|
||||||
|
this.begin = begin;
|
||||||
|
this.end = end;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.roklenarcic.util.strings.AhoCorasickMap;
|
||||||
|
import com.roklenarcic.util.strings.MapMatchListener;
|
||||||
|
import com.roklenarcic.util.strings.StringMap;
|
||||||
|
|
||||||
|
public class AhoCorasickMapDictionarySearch extends AbstractDictionarySearch {
|
||||||
|
|
||||||
|
private final StringMap<List<DictionaryIdentifierWithKeyword>> map;
|
||||||
|
|
||||||
|
|
||||||
|
public AhoCorasickMapDictionarySearch(Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap) {
|
||||||
|
|
||||||
|
super(keyWordToIdentifiersMap);
|
||||||
|
map = new AhoCorasickMap<>(keyWordToIdentifiersMap.keySet(), keyWordToIdentifiersMap.values(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void parseText(CharSequence text, HitHandler handler) {
|
||||||
|
|
||||||
|
MapMatchListener<List<DictionaryIdentifierWithKeyword>> listener = (haystack, startPosition, endPosition, value) -> {
|
||||||
|
handler.handle(startPosition, endPosition, value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
map.match(text.toString(), listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@ -19,57 +20,70 @@ import lombok.SneakyThrows;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DictionaryFactory {
|
public class DictionaryFactory {
|
||||||
|
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public Dictionary create(List<DictionaryModel> dictionaryModels, DictionaryVersion dictionaryVersion) {
|
public Dictionary create(List<DictionaryModel> dictionaryModels, DictionaryVersion dictionaryVersion) {
|
||||||
|
|
||||||
Map<DictionaryIdentifier, List<String>> combinedMap = generateCombinedDictionaryValuesMap(dictionaryModels);
|
Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap = computeStringIdentifiersMap(dictionaryModels);
|
||||||
DictionarySearch dictionarySearch = new DoubleArrayTrieDictionarySearch(combinedMap);
|
DictionarySearch dictionarySearch = getDictionarySearch(keyWordToIdentifiersMap);
|
||||||
|
|
||||||
return new Dictionary(dictionaryModels, dictionaryVersion, dictionarySearch);
|
return new Dictionary(dictionaryModels, dictionaryVersion, dictionarySearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Map<DictionaryIdentifier, List<String>> generateCombinedDictionaryValuesMap(List<DictionaryModel> dictionaryModels) {
|
private static DictionarySearch getDictionarySearch(Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap) {
|
||||||
|
|
||||||
|
// a more sophisticated selection of the dictionarySearch could be done here
|
||||||
|
// but as we do not have the need to fine-tune at the moment we use the all-rounder solution, which is the AhoCoraSickMapDictionarySearch
|
||||||
|
// based on this repository https://github.com/RokLenarcic/AhoCorasick
|
||||||
|
|
||||||
|
// This is an outline how a more complex dictionarySearch decision could be made:
|
||||||
|
// if (!redactionServiceSettings.isPriorityMode() && keyWordToIdentifiersMap.keySet().size() < 50_000) {
|
||||||
|
// dictionarySearch = new DoubleArrayTrieDictionarySearch(keyWordToIdentifiersMap);
|
||||||
|
// } else {
|
||||||
|
// dictionarySearch = new AhoCorasickMapDictionarySearch(keyWordToIdentifiersMap);
|
||||||
|
// }
|
||||||
|
|
||||||
|
return new AhoCorasickMapDictionarySearch(keyWordToIdentifiersMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected static Map<String, List<DictionaryIdentifierWithKeyword>> computeStringIdentifiersMap(List<DictionaryModel> dictionaryModels) {
|
||||||
|
|
||||||
|
Map<String, List<DictionaryIdentifierWithKeyword>> stringToIdentifiersMap = new HashMap<>();
|
||||||
|
|
||||||
Map<DictionaryIdentifier, List<String>> combinedValuesMap = new HashMap<>();
|
|
||||||
for (DictionaryModel model : dictionaryModels) {
|
for (DictionaryModel model : dictionaryModels) {
|
||||||
addDictionaryEntries(combinedValuesMap, model);
|
|
||||||
|
// Add entries for different entity types
|
||||||
|
addEntriesToMap(stringToIdentifiersMap, model, EntityType.ENTITY, model.getEntries(), false);
|
||||||
|
addEntriesToMap(stringToIdentifiersMap, model, EntityType.FALSE_POSITIVE, model.getFalsePositives(), false);
|
||||||
|
addEntriesToMap(stringToIdentifiersMap, model, EntityType.FALSE_RECOMMENDATION, model.getFalseRecommendations(), false);
|
||||||
|
|
||||||
|
if (model.isDossierDictionary()) {
|
||||||
|
addEntriesToMap(stringToIdentifiersMap, model, EntityType.DICTIONARY_REMOVAL, model.getEntries(), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return combinedValuesMap;
|
|
||||||
|
return stringToIdentifiersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void addDictionaryEntries(Map<DictionaryIdentifier, List<String>> combinedValuesMap, DictionaryModel model) {
|
private static void addEntriesToMap(Map<String, List<DictionaryIdentifierWithKeyword>> stringToIdentifiersMap,
|
||||||
|
DictionaryModel model,
|
||||||
|
EntityType entityType,
|
||||||
|
Set<DictionaryEntryModel> entries,
|
||||||
|
boolean isDeleted) {
|
||||||
|
|
||||||
addValuesToMap(combinedValuesMap, createIdentifier(model, EntityType.ENTITY), filterValues(model.getEntries(), false));
|
DictionaryIdentifier identifier = new DictionaryIdentifier(model.getType(), entityType, model.isDossierDictionary(), !model.isCaseInsensitive());
|
||||||
addValuesToMap(combinedValuesMap, createIdentifier(model, EntityType.FALSE_POSITIVE), filterValues(model.getFalsePositives(), false));
|
|
||||||
addValuesToMap(combinedValuesMap, createIdentifier(model, EntityType.FALSE_RECOMMENDATION), filterValues(model.getFalseRecommendations(), false));
|
|
||||||
|
|
||||||
if (model.isDossierDictionary()) {
|
List<String> values = entries.stream()
|
||||||
addValuesToMap(combinedValuesMap, createIdentifier(model, EntityType.DICTIONARY_REMOVAL), filterValues(model.getEntries(), true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private DictionaryIdentifier createIdentifier(DictionaryModel model, EntityType entityType) {
|
|
||||||
|
|
||||||
return new DictionaryIdentifier(model.getType(), entityType, model.isDossierDictionary(), !model.isCaseInsensitive());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private List<String> filterValues(Set<DictionaryEntryModel> entries, boolean isDeleted) {
|
|
||||||
|
|
||||||
return entries.stream()
|
|
||||||
.filter(entry -> entry.isDeleted() == isDeleted)
|
.filter(entry -> entry.isDeleted() == isDeleted)
|
||||||
.map(DictionaryEntry::getValue)
|
.map(DictionaryEntry::getValue)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
|
||||||
|
|
||||||
|
for (String value : values) {
|
||||||
private void addValuesToMap(Map<DictionaryIdentifier, List<String>> map, DictionaryIdentifier key, List<String> values) {
|
DictionaryIdentifierWithKeyword idWithKeyword = new DictionaryIdentifierWithKeyword(identifier, value);
|
||||||
|
String key = value.toLowerCase(Locale.ROOT);
|
||||||
if (!values.isEmpty()) {
|
stringToIdentifiersMap.computeIfAbsent(key, k -> new ArrayList<>()).add(idWithKeyword);
|
||||||
map.computeIfAbsent(key, k -> new ArrayList<>()).addAll(values);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
||||||
|
|
||||||
|
public record DictionaryIdentifierWithKeyword(DictionaryIdentifier identifier, String keyword) {
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,177 +1,31 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
|
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.textblock.TextBlock;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
public class DoubleArrayTrieDictionarySearch extends AbstractDictionarySearch {
|
||||||
|
|
||||||
public class DoubleArrayTrieDictionarySearch implements DictionarySearch {
|
|
||||||
|
|
||||||
private final AhoCorasickDoubleArrayTrie<List<DictionaryIdentifierWithKeyword>> trie;
|
private final AhoCorasickDoubleArrayTrie<List<DictionaryIdentifierWithKeyword>> trie;
|
||||||
|
|
||||||
|
|
||||||
public DoubleArrayTrieDictionarySearch(Map<DictionaryIdentifier, List<String>> dictionaryValues) {
|
public DoubleArrayTrieDictionarySearch(Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap) {
|
||||||
|
|
||||||
|
super(keyWordToIdentifiersMap);
|
||||||
trie = new AhoCorasickDoubleArrayTrie<>();
|
trie = new AhoCorasickDoubleArrayTrie<>();
|
||||||
trie.build(computeStringIdentifiersMap(dictionaryValues));
|
trie.build(keyWordToIdentifiersMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static Map<String, List<DictionaryIdentifierWithKeyword>> computeStringIdentifiersMap(Map<DictionaryIdentifier, List<String>> dictionaryValues) {
|
|
||||||
|
|
||||||
Map<String, List<DictionaryIdentifierWithKeyword>> stringToIdentifiersMap = new HashMap<>();
|
|
||||||
for (Map.Entry<DictionaryIdentifier, List<String>> entry : dictionaryValues.entrySet()) {
|
|
||||||
DictionaryIdentifier identifier = entry.getKey();
|
|
||||||
List<String> values = entry.getValue();
|
|
||||||
for (String value : values) {
|
|
||||||
DictionaryIdentifierWithKeyword idWithKeyword = new DictionaryIdentifierWithKeyword(identifier, value);
|
|
||||||
stringToIdentifiersMap.computeIfAbsent(value.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(idWithKeyword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stringToIdentifiersMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Stream<MatchTextRange> getBoundaries(CharSequence text) {
|
protected void parseText(CharSequence text, HitHandler handler) {
|
||||||
|
|
||||||
TextContext textContext = new TextContext(text);
|
|
||||||
return getMatchTextRangeStream(textContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<MatchTextRange> getBoundaries(CharSequence text, TextRange region) {
|
|
||||||
|
|
||||||
CharSequence subText = text.subSequence(region.start(), region.end());
|
|
||||||
TextContext textContext = new TextContext(subText, region.start());
|
|
||||||
return getMatchTextRangeStream(textContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<MatchTextRange> getBoundaries(TextBlock textBlock) {
|
|
||||||
|
|
||||||
return getBoundaries(textBlock, textBlock.getTextRange());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Stream<MatchPosition> getMatches(String text) {
|
|
||||||
|
|
||||||
TextContext textContext = new TextContext(text);
|
|
||||||
List<MatchPosition> matches = new ArrayList<>();
|
|
||||||
List<AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>>> hits = trie.parseText(textContext.getLowerText());
|
|
||||||
|
|
||||||
|
List<AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>>> hits = trie.parseText(text);
|
||||||
for (AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>> hit : hits) {
|
for (AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>> hit : hits) {
|
||||||
addMatchPositionsForHit(textContext, matches, hit);
|
handler.handle(hit.begin, hit.end, hit.value);
|
||||||
}
|
}
|
||||||
return matches.stream();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private Stream<MatchTextRange> getMatchTextRangeStream(TextContext textContext) {
|
|
||||||
|
|
||||||
List<MatchTextRange> matches = new ArrayList<>();
|
|
||||||
List<AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>>> hits = trie.parseText(textContext.getLowerText());
|
|
||||||
|
|
||||||
for (AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>> hit : hits) {
|
|
||||||
addMatchesForHit(textContext, matches, hit);
|
|
||||||
}
|
|
||||||
return matches.stream();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void addMatchesForHit(TextContext textContext, List<MatchTextRange> matches, AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>> hit) {
|
|
||||||
|
|
||||||
int start = textContext.getStart(hit.begin);
|
|
||||||
int end = textContext.getEnd(hit.end);
|
|
||||||
String matchedText = textContext.getMatchedText(hit.begin, hit.end);
|
|
||||||
List<DictionaryIdentifierWithKeyword> idWithKeywords = hit.value;
|
|
||||||
|
|
||||||
for (DictionaryIdentifierWithKeyword idkw : idWithKeywords) {
|
|
||||||
if (idkw.identifier.caseSensitive()) {
|
|
||||||
if (matchedText.equals(idkw.keyword)) {
|
|
||||||
matches.add(new MatchTextRange(idkw.identifier, new TextRange(start, end)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matches.add(new MatchTextRange(idkw.identifier, new TextRange(start, end)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void addMatchPositionsForHit(TextContext textContext, List<MatchPosition> matches, AhoCorasickDoubleArrayTrie.Hit<List<DictionaryIdentifierWithKeyword>> hit) {
|
|
||||||
|
|
||||||
int start = textContext.getStart(hit.begin);
|
|
||||||
int end = textContext.getEnd(hit.end);
|
|
||||||
String matchedText = textContext.getMatchedText(hit.begin, hit.end);
|
|
||||||
List<DictionaryIdentifierWithKeyword> idWithKeywords = hit.value;
|
|
||||||
|
|
||||||
for (DictionaryIdentifierWithKeyword idkw : idWithKeywords) {
|
|
||||||
MatchPosition matchPosition = new MatchPosition(idkw.identifier, start, end);
|
|
||||||
if (idkw.identifier.caseSensitive()) {
|
|
||||||
if (matchedText.equals(idkw.keyword)) {
|
|
||||||
matches.add(matchPosition);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
matches.add(matchPosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static class TextContext {
|
|
||||||
|
|
||||||
private final CharSequence text;
|
|
||||||
@Getter
|
|
||||||
private final String lowerText;
|
|
||||||
private final int offset;
|
|
||||||
|
|
||||||
|
|
||||||
TextContext(CharSequence text, int offset) {
|
|
||||||
|
|
||||||
this.text = text;
|
|
||||||
this.lowerText = text.toString().toLowerCase(Locale.ROOT);
|
|
||||||
this.offset = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
TextContext(CharSequence text) {
|
|
||||||
|
|
||||||
this(text, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public int getStart(int hitBegin) {
|
|
||||||
|
|
||||||
return hitBegin + offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public int getEnd(int hitEnd) {
|
|
||||||
|
|
||||||
return hitEnd + offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public String getMatchedText(int hitBegin, int hitEnd) {
|
|
||||||
|
|
||||||
return text.subSequence(hitBegin, hitEnd).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private record DictionaryIdentifierWithKeyword(DictionaryIdentifier identifier, String keyword) {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import java.util.stream.Stream;
|
|||||||
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.textblock.TextBlock;
|
import com.iqser.red.service.redaction.v1.server.model.document.textblock.TextBlock;
|
||||||
|
|
||||||
public class TrieDictionarySearch implements DictionarySearch {
|
public class DoubleTrieDictionarySearch implements DictionarySearch {
|
||||||
|
|
||||||
private final Map<DictionaryIdentifier, List<String>> caseSensitiveEntries = new HashMap<>();
|
private final Map<DictionaryIdentifier, List<String>> caseSensitiveEntries = new HashMap<>();
|
||||||
private final Map<DictionaryIdentifier, List<String>> caseInsensitiveEntries = new HashMap<>();
|
private final Map<DictionaryIdentifier, List<String>> caseInsensitiveEntries = new HashMap<>();
|
||||||
@ -17,7 +17,7 @@ public class TrieDictionarySearch implements DictionarySearch {
|
|||||||
private final DictionaryIdentifierTrie caseInsensitiveTrie;
|
private final DictionaryIdentifierTrie caseInsensitiveTrie;
|
||||||
|
|
||||||
|
|
||||||
public TrieDictionarySearch(Map<DictionaryIdentifier, List<String>> dictionaryValues) {
|
public DoubleTrieDictionarySearch(Map<DictionaryIdentifier, List<String>> dictionaryValues) {
|
||||||
|
|
||||||
for (Map.Entry<DictionaryIdentifier, List<String>> entry : dictionaryValues.entrySet()) {
|
for (Map.Entry<DictionaryIdentifier, List<String>> entry : dictionaryValues.entrySet()) {
|
||||||
DictionaryIdentifier identifier = entry.getKey();
|
DictionaryIdentifier identifier = entry.getKey();
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.iqser.red.service.redaction.v1.server.model.dictionary;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
public class TextContext {
|
||||||
|
|
||||||
|
private final CharSequence text;
|
||||||
|
@Getter
|
||||||
|
private final String lowerText;
|
||||||
|
private final int offset;
|
||||||
|
|
||||||
|
|
||||||
|
TextContext(CharSequence text, int offset) {
|
||||||
|
|
||||||
|
this.text = text;
|
||||||
|
this.lowerText = text.toString().toLowerCase(Locale.ROOT);
|
||||||
|
this.offset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TextContext(CharSequence text) {
|
||||||
|
|
||||||
|
this(text, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public int getStart(int hitBegin) {
|
||||||
|
|
||||||
|
return hitBegin + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public int getEnd(int hitEnd) {
|
||||||
|
|
||||||
|
return hitEnd + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public String getMatchedText(int hitBegin, int hitEnd) {
|
||||||
|
|
||||||
|
return text.subSequence(hitBegin, hitEnd).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.iqser.red.service.redaction.v1.server.service;
|
package com.iqser.red.service.redaction.v1.server.service;
|
||||||
|
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
@ -111,18 +112,16 @@ public class DictionaryService {
|
|||||||
|
|
||||||
DictionaryVersion latestVersion = updateDictionary(dossierTemplateId, dossierId);
|
DictionaryVersion latestVersion = updateDictionary(dossierTemplateId, dossierId);
|
||||||
|
|
||||||
Set<DictionaryIncrementValue> newValues = new HashSet<>();
|
Set<DictionaryIncrementValue> newValues = Collections.synchronizedSet(new HashSet<>());
|
||||||
List<DictionaryModel> templateDictionaries = dictionaryCacheService.getDossierTemplateDictionary(dossierTemplateId).getDictionary();
|
List<DictionaryModel> templateDictionaries = dictionaryCacheService.getDossierTemplateDictionary(dossierTemplateId).getDictionary();
|
||||||
|
|
||||||
templateDictionaries.forEach(dictionaryModel -> {
|
templateDictionaries.parallelStream()
|
||||||
addNewEntries(dictionaryModel, fromVersion.getDossierTemplateVersion(), newValues);
|
.forEach(dictionaryModel -> addNewEntries(dictionaryModel, fromVersion.getDossierTemplateVersion(), newValues));
|
||||||
});
|
|
||||||
|
|
||||||
if (dossierDictionaryExists(dossierId)) {
|
if (dossierDictionaryExists(dossierId)) {
|
||||||
List<DictionaryModel> dossierDictionaries = dictionaryCacheService.getDossierDictionary(dossierId).getDictionary();
|
List<DictionaryModel> dossierDictionaries = dictionaryCacheService.getDossierDictionary(dossierId).getDictionary();
|
||||||
dossierDictionaries.forEach(dictionaryModel -> {
|
dossierDictionaries.parallelStream()
|
||||||
addNewEntries(dictionaryModel, fromVersion.getDossierVersion(), newValues);
|
.forEach(dictionaryModel -> addNewEntries(dictionaryModel, fromVersion.getDossierVersion(), newValues));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DictionaryIncrement(newValues, latestVersion);
|
return new DictionaryIncrement(newValues, latestVersion);
|
||||||
@ -247,76 +246,103 @@ public class DictionaryService {
|
|||||||
Set<DictionaryEntryModel> combinedFalseRecommendations) {
|
Set<DictionaryEntryModel> combinedFalseRecommendations) {
|
||||||
|
|
||||||
if (oldModel.isCaseInsensitive() && !newType.isCaseInsensitive()) {
|
if (oldModel.isCaseInsensitive() && !newType.isCaseInsensitive()) {
|
||||||
|
// Compute new entries' values in lowercase once
|
||||||
|
Set<String> newEntryValuesLower = newEntries.getEntries()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedEntries.addAll(oldModel.getEntries()
|
combinedEntries.addAll(oldModel.getEntries()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getEntries()
|
.filter(f -> !newEntryValuesLower.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false positives
|
||||||
|
Set<String> newFalsePositivesValuesLower = newEntries.getFalsePositives()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalsePositives()
|
.filter(f -> !newFalsePositivesValuesLower.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false recommendations
|
||||||
|
Set<String> newFalseRecommendationsValuesLower = newEntries.getFalseRecommendations()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalseRecommendations()
|
.filter(f -> !newFalseRecommendationsValuesLower.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
} else if (!oldModel.isCaseInsensitive() && newType.isCaseInsensitive()) {
|
} else if (!oldModel.isCaseInsensitive() && newType.isCaseInsensitive()) {
|
||||||
|
// Compute new entries' values in lowercase once
|
||||||
|
Set<String> newEntryValuesLower = newEntries.getEntries()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedEntries.addAll(oldModel.getEntries()
|
combinedEntries.addAll(oldModel.getEntries()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getEntries()
|
.filter(f -> !newEntryValuesLower.contains(f.getValue().toLowerCase(Locale.ROOT)))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue().toLowerCase(Locale.ROOT)))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false positives
|
||||||
|
Set<String> newFalsePositivesValuesLower = newEntries.getFalsePositives()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalsePositives()
|
.filter(f -> !newFalsePositivesValuesLower.contains(f.getValue().toLowerCase(Locale.ROOT)))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue().toLowerCase(Locale.ROOT)))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false recommendations
|
||||||
|
Set<String> newFalseRecommendationsValuesLower = newEntries.getFalseRecommendations()
|
||||||
|
.stream()
|
||||||
|
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalseRecommendations()
|
.filter(f -> !newFalseRecommendationsValuesLower.contains(f.getValue().toLowerCase(Locale.ROOT)))
|
||||||
.stream()
|
|
||||||
.map(s -> s.getValue().toLowerCase(Locale.ROOT))
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue().toLowerCase(Locale.ROOT)))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
} else {
|
} else {
|
||||||
|
// Both have the same case sensitivity
|
||||||
|
Set<String> newEntryValues = newEntries.getEntries()
|
||||||
|
.stream()
|
||||||
|
.map(DictionaryEntryModel::getValue)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedEntries.addAll(oldModel.getEntries()
|
combinedEntries.addAll(oldModel.getEntries()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getEntries()
|
.filter(f -> !newEntryValues.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(DictionaryEntryModel::getValue)
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false positives
|
||||||
|
Set<String> newFalsePositivesValues = newEntries.getFalsePositives()
|
||||||
|
.stream()
|
||||||
|
.map(DictionaryEntryModel::getValue)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
combinedFalsePositives.addAll(oldModel.getFalsePositives()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalsePositives()
|
.filter(f -> !newFalsePositivesValues.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(DictionaryEntryModel::getValue)
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
|
|
||||||
|
// Similarly for false recommendations
|
||||||
|
Set<String> newFalseRecommendationsValues = newEntries.getFalseRecommendations()
|
||||||
|
.stream()
|
||||||
|
.map(DictionaryEntryModel::getValue)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
combinedFalseRecommendations.addAll(oldModel.getFalseRecommendations()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(f -> !newEntries.getFalseRecommendations()
|
.filter(f -> !newFalseRecommendationsValues.contains(f.getValue()))
|
||||||
.stream()
|
|
||||||
.map(DictionaryEntryModel::getValue)
|
|
||||||
.collect(Collectors.toSet()).contains(f.getValue()))
|
|
||||||
.collect(Collectors.toSet()));
|
.collect(Collectors.toSet()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -407,8 +433,8 @@ public class DictionaryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dictionaryFactory.create(mergedDictionaries.stream()
|
return dictionaryFactory.create(mergedDictionaries.stream()
|
||||||
.sorted(Comparator.comparingInt(DictionaryModel::getRank).reversed())
|
.sorted(Comparator.comparingInt(DictionaryModel::getRank).reversed())
|
||||||
.collect(Collectors.toList()), dictionaryVersion);
|
.collect(Collectors.toList()), dictionaryVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,64 +2,97 @@ package com.iqser.red.service.redaction.v1.server.document.graph;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.io.BufferedReader;
|
||||||
import java.util.ArrayList;
|
import java.io.InputStreamReader;
|
||||||
import java.util.HashMap;
|
import java.util.*;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.IntStream;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
|
import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.DictionaryIdentifier;
|
import com.iqser.red.service.redaction.v1.server.model.dictionary.*;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.DictionarySearch;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.DoubleArrayTrieDictionarySearch;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.TrieDictionarySearch;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.SearchImplementation;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
import com.iqser.red.service.redaction.v1.server.model.document.TextRange;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.entity.EntityType;
|
import com.iqser.red.service.redaction.v1.server.model.document.entity.EntityType;
|
||||||
|
|
||||||
public class DictionarySearchImplementationsTest {
|
public class DictionarySearchImplementationsTest {
|
||||||
|
|
||||||
private static final int LARGE_DICTIONARY_SIZE = 4_000;
|
private static final int LARGE_TEXT_REPETITIONS = 50_000;
|
||||||
private static final int LARGE_TEXT_REPETITIONS = 500_000;
|
private static final int MAX_PII_ENTRY_COUNT = 500_000;
|
||||||
private static final String LARGE_TEXT_SAMPLE = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
|
private static final String LARGE_TEXT_SAMPLE = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
|
||||||
+ "Entity_1 match text. Recommendation_1 also here. Random text continues. ";
|
+ "Entity_1 match text. Recommendation_1 also here. Random text continues. ";
|
||||||
|
|
||||||
|
// Dictionary identifiers
|
||||||
|
protected static final String VERTEBRATE_INDICATOR = "vertebrate";
|
||||||
|
protected static final String DICTIONARY_ADDRESS = "CBI_address";
|
||||||
|
protected static final String DICTIONARY_AUTHOR = "CBI_author";
|
||||||
|
protected static final String DICTIONARY_SPONSOR = "CBI_sponsor";
|
||||||
|
protected static final String DICTIONARY_PII = "PII";
|
||||||
|
protected static final String NO_REDACTION_INDICATOR = "no_redaction_indicator";
|
||||||
|
protected static final String REDACTION_INDICATOR = "redaction_indicator";
|
||||||
|
protected static final String HINT_ONLY_INDICATOR = "hint_only";
|
||||||
|
protected static final String MUST_REDACT_INDICATOR = "must_redact";
|
||||||
|
protected static final String PUBLISHED_INFORMATION_INDICATOR = "published_information";
|
||||||
|
protected static final String TEST_METHOD_INDICATOR = "test_method";
|
||||||
|
protected static final String PURITY_INDICATOR = "purity";
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Disabled
|
public void performanceTestWithRealDictionaries() {
|
||||||
public void performanceTest() {
|
|
||||||
|
// Load dictionaries from files
|
||||||
|
Map<String, List<String>> loadedDictionaries = loadDictionaries();
|
||||||
|
|
||||||
Map<DictionaryIdentifier, List<String>> dictionaryValues = new HashMap<>();
|
Map<DictionaryIdentifier, List<String>> dictionaryValues = new HashMap<>();
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
// Generate dictionary values with specific terms for matching
|
// Randomly assign EntityType.ENTITY or EntityType.RECOMMENDATION to dictionaries
|
||||||
IntStream.range(0, 6)
|
for (Map.Entry<String, List<String>> entry : loadedDictionaries.entrySet()) {
|
||||||
.forEach(i -> {
|
String dictionaryName = entry.getKey();
|
||||||
EntityType entityType = i % 2 == 0 ? EntityType.ENTITY : EntityType.RECOMMENDATION;
|
List<String> dictionaryTerms = entry.getValue();
|
||||||
boolean caseSensitive = i % 2 == 0;
|
|
||||||
|
|
||||||
DictionaryIdentifier identifier = new DictionaryIdentifier("Type_" + i, entityType, true, caseSensitive);
|
EntityType entityType = random.nextBoolean() ? EntityType.ENTITY : EntityType.RECOMMENDATION;
|
||||||
List<String> dictionary = generateLargeDictionary();
|
boolean caseSensitive = random.nextBoolean();
|
||||||
|
|
||||||
// Add specific terms that are included in the large text for matches
|
DictionaryIdentifier identifier = new DictionaryIdentifier(
|
||||||
if (i == 0) {
|
dictionaryName,
|
||||||
dictionary.add("Entity_1");
|
entityType,
|
||||||
}
|
true,
|
||||||
if (i == 1) {
|
caseSensitive
|
||||||
dictionary.add("recommendation_1");
|
);
|
||||||
}
|
|
||||||
dictionaryValues.put(identifier, dictionary);
|
dictionaryValues.put(identifier, dictionaryTerms);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// **Added dummy dictionaries as per request**
|
||||||
|
// Case-sensitive dictionary containing "Entity_1"
|
||||||
|
DictionaryIdentifier entity1Identifier = new DictionaryIdentifier(
|
||||||
|
"dummy_case_sensitive",
|
||||||
|
EntityType.ENTITY,
|
||||||
|
true,
|
||||||
|
true // Case-sensitive
|
||||||
|
);
|
||||||
|
dictionaryValues.put(entity1Identifier, List.of("Entity_1"));
|
||||||
|
|
||||||
|
// Case-insensitive dictionary containing "recommendation_1"
|
||||||
|
DictionaryIdentifier recommendation1Identifier = new DictionaryIdentifier(
|
||||||
|
"dummy_case_insensitive",
|
||||||
|
EntityType.RECOMMENDATION,
|
||||||
|
true,
|
||||||
|
false // Case-insensitive
|
||||||
|
);
|
||||||
|
dictionaryValues.put(recommendation1Identifier, List.of("recommendation_1"));
|
||||||
|
|
||||||
// Measure construction time for TrieDictionarySearch
|
// Measure construction time for TrieDictionarySearch
|
||||||
long trieDictionaryConstructionStart = System.currentTimeMillis();
|
long trieDictionaryConstructionStart = System.currentTimeMillis();
|
||||||
TrieDictionarySearch trieDictionarySearchImpl = new TrieDictionarySearch(dictionaryValues);
|
DoubleTrieDictionarySearch doubleTrieDictionarySearchImpl = new DoubleTrieDictionarySearch(dictionaryValues);
|
||||||
long trieDictionaryConstructionDuration = System.currentTimeMillis() - trieDictionaryConstructionStart;
|
long trieDictionaryConstructionDuration = System.currentTimeMillis() - trieDictionaryConstructionStart;
|
||||||
|
|
||||||
|
// Measure construction time for AnotherTrieDictionarySearch
|
||||||
|
long anotherTrieConstructionStart = System.currentTimeMillis();
|
||||||
|
Map<String, List<DictionaryIdentifierWithKeyword>> keyWordToIdentifiersMap = computeStringIdentifiersMap(dictionaryValues);
|
||||||
|
AhoCorasickMapDictionarySearch ahoCorasickMapDictionarySearchImpl = new AhoCorasickMapDictionarySearch(keyWordToIdentifiersMap);
|
||||||
|
long anotherTrieConstructionDuration = System.currentTimeMillis() - anotherTrieConstructionStart;
|
||||||
|
|
||||||
// Measure construction time for SearchImplementations
|
// Measure construction time for SearchImplementations
|
||||||
long searchTrieConstructionStart = System.currentTimeMillis();
|
long searchTrieConstructionStart = System.currentTimeMillis();
|
||||||
List<SearchImplementation> searchImplementations = dictionaryValues.entrySet()
|
List<SearchImplementation> searchImplementations = dictionaryValues.entrySet()
|
||||||
@ -70,16 +103,22 @@ public class DictionarySearchImplementationsTest {
|
|||||||
|
|
||||||
// Measure construction time for DoubleArrayTrieDictionarySearch
|
// Measure construction time for DoubleArrayTrieDictionarySearch
|
||||||
long doubleArrayTrieConstructionStart = System.currentTimeMillis();
|
long doubleArrayTrieConstructionStart = System.currentTimeMillis();
|
||||||
DoubleArrayTrieDictionarySearch doubleArrayTrieSearchImpl = new DoubleArrayTrieDictionarySearch(dictionaryValues);
|
DoubleArrayTrieDictionarySearch doubleArrayTrieSearchImpl = new DoubleArrayTrieDictionarySearch(keyWordToIdentifiersMap);
|
||||||
long doubleArrayTrieConstructionDuration = System.currentTimeMillis() - doubleArrayTrieConstructionStart;
|
long doubleArrayTrieConstructionDuration = System.currentTimeMillis() - doubleArrayTrieConstructionStart;
|
||||||
|
|
||||||
|
|
||||||
String largeText = LARGE_TEXT_SAMPLE.repeat(LARGE_TEXT_REPETITIONS);
|
String largeText = LARGE_TEXT_SAMPLE.repeat(LARGE_TEXT_REPETITIONS);
|
||||||
|
|
||||||
// Measure search time for TrieDictionarySearch
|
// Measure search time for TrieDictionarySearch
|
||||||
long trieDictionarySearchStart = System.currentTimeMillis();
|
long trieDictionarySearchStart = System.currentTimeMillis();
|
||||||
List<TrieDictionarySearch.MatchTextRange> trieDictionaryMatches = trieDictionarySearchImpl.getBoundariesAsList(largeText);
|
List<DoubleTrieDictionarySearch.MatchTextRange> trieDictionaryMatches = doubleTrieDictionarySearchImpl.getBoundariesAsList(largeText);
|
||||||
long trieDictionarySearchDuration = System.currentTimeMillis() - trieDictionarySearchStart;
|
long trieDictionarySearchDuration = System.currentTimeMillis() - trieDictionarySearchStart;
|
||||||
|
|
||||||
|
// Measure search time for AnotherTrieDictionarySearch
|
||||||
|
long anotherTrieSearchStart = System.currentTimeMillis();
|
||||||
|
List<DictionarySearch.MatchTextRange> anotherTrieMatches = ahoCorasickMapDictionarySearchImpl.getBoundaries(largeText).toList();
|
||||||
|
long anotherTrieSearchDuration = System.currentTimeMillis() - anotherTrieSearchStart;
|
||||||
|
|
||||||
// Measure search time for SearchImplementations
|
// Measure search time for SearchImplementations
|
||||||
long searchImplStart = System.currentTimeMillis();
|
long searchImplStart = System.currentTimeMillis();
|
||||||
List<TextRange> searchMatches = new ArrayList<>();
|
List<TextRange> searchMatches = new ArrayList<>();
|
||||||
@ -94,72 +133,113 @@ public class DictionarySearchImplementationsTest {
|
|||||||
long doubleArrayTrieSearchDuration = System.currentTimeMillis() - doubleArrayTrieSearchStart;
|
long doubleArrayTrieSearchDuration = System.currentTimeMillis() - doubleArrayTrieSearchStart;
|
||||||
|
|
||||||
// Output the performance results
|
// Output the performance results
|
||||||
System.out.printf("TrieDictionarySearch construction took %d ms\n", trieDictionaryConstructionDuration);
|
System.out.println("\nTotal number of keywords is: " + keyWordToIdentifiersMap.size());
|
||||||
System.out.printf("TrieDictionarySearch search took %d ms and found %d matches\n", trieDictionarySearchDuration, trieDictionaryMatches.size());
|
|
||||||
System.out.printf("Combined Trie construction took %d ms\n", searchTrieConstructionDuration);
|
System.out.printf("DoubleTrieDictionarySearch construction took %d ms%n", trieDictionaryConstructionDuration);
|
||||||
System.out.printf("Combined SearchImplementation search took %d ms and found %d matches\n", searchImplDuration, searchMatches.size());
|
System.out.printf("DoubleTrieDictionarySearch search took %d ms and found %d matches%n", trieDictionarySearchDuration, trieDictionaryMatches.size());
|
||||||
System.out.printf("DoubleArrayTrieDictionarySearch construction took %d ms\n", doubleArrayTrieConstructionDuration);
|
System.out.println();
|
||||||
System.out.printf("DoubleArrayTrieDictionarySearch search took %d ms and found %d matches\n", doubleArrayTrieSearchDuration, doubleArrayTrieMatches.size());
|
|
||||||
|
System.out.printf("AhoCorasickMapDictionarySearch construction took %d ms%n", anotherTrieConstructionDuration);
|
||||||
|
System.out.printf("AhoCorasickMapDictionarySearch search took %d ms and found %d matches%n", anotherTrieSearchDuration, anotherTrieMatches.size());
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
System.out.printf("Multiple Tries construction took %d ms%n", searchTrieConstructionDuration);
|
||||||
|
System.out.printf("Combined SearchImplementations search took %d ms and found %d matches%n", searchImplDuration, searchMatches.size());
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
System.out.printf("DoubleArrayTrieDictionarySearch construction took %d ms%n", doubleArrayTrieConstructionDuration);
|
||||||
|
System.out.printf("DoubleArrayTrieDictionarySearch search took %d ms and found %d matches%n", doubleArrayTrieSearchDuration, doubleArrayTrieMatches.size());
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
// Assert that all implementations found matches
|
// Assert that all implementations found matches
|
||||||
assert !trieDictionaryMatches.isEmpty() && !searchMatches.isEmpty() && !doubleArrayTrieMatches.isEmpty() : "All implementations should find entities.";
|
assert !trieDictionaryMatches.isEmpty() && !anotherTrieMatches.isEmpty() && !searchMatches.isEmpty() && !doubleArrayTrieMatches.isEmpty(): "All implementations should find entities.";
|
||||||
|
|
||||||
// Ensure all implementations found the same number of matches
|
// Ensure all implementations found the same number of matches
|
||||||
assertEquals(trieDictionaryMatches.size(), searchMatches.size(), "Mismatch between TrieDictionarySearch and SearchImplementations");
|
int expectedMatches = trieDictionaryMatches.size();
|
||||||
assertEquals(trieDictionaryMatches.size(), doubleArrayTrieMatches.size(), "Mismatch between TrieDictionarySearch and DoubleArrayTrieDictionarySearch");
|
assertEquals(expectedMatches, anotherTrieMatches.size(), "Mismatch between DoubleTrieDictionarySearch and AhoCorasickMapDictionarySearch");
|
||||||
|
assertEquals(expectedMatches, searchMatches.size(), "Mismatch between DoubleTrieDictionarySearch and Combined SearchImplementations");
|
||||||
|
assertEquals(expectedMatches, doubleArrayTrieMatches.size(), "Mismatch between DoubleTrieDictionarySearch and DoubleArrayTrieDictionarySearch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, List<String>> loadDictionaries() {
|
||||||
|
Map<String, List<String>> dictionaries = new HashMap<>();
|
||||||
|
|
||||||
private List<String> generateLargeDictionary() {
|
dictionaries.put(DICTIONARY_AUTHOR, loadDictionaryFromFile("dictionaries/CBI_author.txt"));
|
||||||
|
dictionaries.put(DICTIONARY_SPONSOR, loadDictionaryFromFile("dictionaries/CBI_sponsor.txt"));
|
||||||
|
dictionaries.put(VERTEBRATE_INDICATOR, loadDictionaryFromFile("dictionaries/vertebrate.txt"));
|
||||||
|
dictionaries.put(DICTIONARY_ADDRESS, loadDictionaryFromFile("dictionaries/CBI_address.txt"));
|
||||||
|
dictionaries.put(NO_REDACTION_INDICATOR, loadDictionaryFromFile("dictionaries/no_redaction_indicator.txt"));
|
||||||
|
dictionaries.put(REDACTION_INDICATOR, loadDictionaryFromFile("dictionaries/redaction_indicator.txt"));
|
||||||
|
dictionaries.put(HINT_ONLY_INDICATOR, loadDictionaryFromFile("dictionaries/hint_only.txt"));
|
||||||
|
dictionaries.put(MUST_REDACT_INDICATOR, loadDictionaryFromFile("dictionaries/must_redact.txt"));
|
||||||
|
dictionaries.put(PUBLISHED_INFORMATION_INDICATOR, loadDictionaryFromFile("dictionaries/published_information.txt"));
|
||||||
|
dictionaries.put(TEST_METHOD_INDICATOR, loadDictionaryFromFile("dictionaries/test_method.txt"));
|
||||||
|
List<String> piis = loadDictionaryFromFile("dictionaries/PII_large.txt");
|
||||||
|
dictionaries.put(DICTIONARY_PII, MAX_PII_ENTRY_COUNT < piis.size() ? piis.subList(0, MAX_PII_ENTRY_COUNT) : piis);
|
||||||
|
dictionaries.put(PURITY_INDICATOR, loadDictionaryFromFile("dictionaries/purity.txt"));
|
||||||
|
|
||||||
return IntStream.range(0, LARGE_DICTIONARY_SIZE).mapToObj(i -> RandomStringGenerator.generateRandomString())
|
return dictionaries;
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> loadDictionaryFromFile(String filePath) {
|
||||||
|
List<String> terms = new ArrayList<>();
|
||||||
|
|
||||||
static final class RandomStringGenerator {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||||
|
Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream(filePath))))) {
|
||||||
|
|
||||||
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
terms = reader.lines()
|
||||||
private static final int STRING_LENGTH = 50;
|
.map(this::cleanDictionaryEntry)
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
public static String generateRandomString() {
|
System.err.println("Failed to load dictionary from " + filePath + ": " + e.getMessage());
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder(STRING_LENGTH);
|
|
||||||
for (int i = 0; i < STRING_LENGTH; i++) {
|
|
||||||
int index = RANDOM.nextInt(CHARACTERS.length());
|
|
||||||
sb.append(CHARACTERS.charAt(index));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return terms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String cleanDictionaryEntry(String entry) {
|
||||||
|
return entry.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static Map<String, List<DictionaryIdentifierWithKeyword>> computeStringIdentifiersMap(Map<DictionaryIdentifier, List<String>> dictionaryValues) {
|
||||||
|
|
||||||
|
Map<String, List<DictionaryIdentifierWithKeyword>> stringToIdentifiersMap = new HashMap<>();
|
||||||
|
for (Map.Entry<DictionaryIdentifier, List<String>> entry : dictionaryValues.entrySet()) {
|
||||||
|
DictionaryIdentifier identifier = entry.getKey();
|
||||||
|
List<String> values = entry.getValue();
|
||||||
|
for (String value : values) {
|
||||||
|
DictionaryIdentifierWithKeyword idWithKeyword = new DictionaryIdentifierWithKeyword(identifier, value);
|
||||||
|
stringToIdentifiersMap.computeIfAbsent(value.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(idWithKeyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stringToIdentifiersMap;
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testMultiplePayloads() {
|
public void testMultiplePayloads() {
|
||||||
|
|
||||||
TrieDictionarySearch dictionarySearchImpl = new TrieDictionarySearch(Map.of(new DictionaryIdentifier("type1", EntityType.ENTITY, false, false),
|
DoubleTrieDictionarySearch dictionarySearchImpl = new DoubleTrieDictionarySearch(Map.of(
|
||||||
List.of("apple", "banana"),
|
new DictionaryIdentifier("type1", EntityType.ENTITY, false, false),
|
||||||
new DictionaryIdentifier("type2", EntityType.RECOMMENDATION, false, false),
|
List.of("apple", "banana"),
|
||||||
List.of("apple", "orange"),
|
new DictionaryIdentifier("type2", EntityType.RECOMMENDATION, false, false),
|
||||||
new DictionaryIdentifier("type3", EntityType.FALSE_POSITIVE, false, false),
|
List.of("apple", "orange"),
|
||||||
List.of("apple", "kiwi")));
|
new DictionaryIdentifier("type3", EntityType.FALSE_POSITIVE, false, false),
|
||||||
|
List.of("apple", "kiwi")));
|
||||||
|
|
||||||
List<TrieDictionarySearch.MatchTextRange> dictionaryMatches = dictionarySearchImpl.getBoundariesAsList(
|
List<DoubleTrieDictionarySearch.MatchTextRange> dictionaryMatches = dictionarySearchImpl.getBoundariesAsList(
|
||||||
"an apple is delicious, a banana and a kiwi as well. orange is a color.");
|
"an apple is delicious, a banana and a kiwi as well. orange is a color.");
|
||||||
|
|
||||||
assertEquals(dictionaryMatches.size(), 6);
|
assertEquals(dictionaryMatches.size(), 6);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testDoubleArrayTrie() {
|
public void testDoubleArrayTrie() {
|
||||||
|
|
||||||
Map<String, List<String>> map = new HashMap<>();
|
Map<String, List<String>> map = new HashMap<>();
|
||||||
String[] keyArray = new String[]{"hers", "his", "she", "he"};
|
String[] keyArray = new String[] { "hers", "his", "she", "he" };
|
||||||
for (String key : keyArray) {
|
for (String key : keyArray) {
|
||||||
map.put(key, List.of(key, key, key));
|
map.put(key, List.of(key, key, key));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,6 @@ import com.iqser.red.service.redaction.v1.server.logger.Context;
|
|||||||
import com.iqser.red.service.redaction.v1.server.model.NerEntities;
|
import com.iqser.red.service.redaction.v1.server.model.NerEntities;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.Dictionary;
|
import com.iqser.red.service.redaction.v1.server.model.dictionary.Dictionary;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.DictionarySearch;
|
import com.iqser.red.service.redaction.v1.server.model.dictionary.DictionarySearch;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.dictionary.TrieDictionarySearch;
|
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.entity.TextEntity;
|
import com.iqser.red.service.redaction.v1.server.model.document.entity.TextEntity;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.nodes.Document;
|
import com.iqser.red.service.redaction.v1.server.model.document.nodes.Document;
|
||||||
import com.iqser.red.service.redaction.v1.server.model.document.nodes.Page;
|
import com.iqser.red.service.redaction.v1.server.model.document.nodes.Page;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user