Skip to content

Commit c0091aa

Browse files
authored
Name index update: ngram-based posting lists (dart-lang#9465)
1 parent e4e4a54 commit c0091aa

4 files changed

Lines changed: 93 additions & 63 deletions

File tree

app/lib/search/mem_index.dart

Lines changed: 79 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -659,9 +659,14 @@ class PackageNameIndex {
659659
/// Maps the collapsed name to all the original names (e.g. `asyncmap`=> [`async_map`, `as_y_n_cmaP`]).
660660
late final Map<String, List<String>> _collapsedNameResolvesToMap;
661661

662-
/// Inverted trigram index: maps each trigram to the package indexes (ascending),
663-
/// whose collapsed name contains that trigram.
664-
late final Map<String, List<int>> _trigramPostings;
662+
/// Inverted n-gram index: maps each n-gram (1-3 characters) to the token postings
663+
/// of the packages whose collapsed name contains that n-gram.
664+
///
665+
/// The weight byte is 99 or 100, depending whether the n-gram also occurs
666+
/// in the original - e.g. underscored - name, as opposed to appearing only
667+
/// after underscores are collapsed.
668+
/// The scoring can use it without re-checking substrings.
669+
late final Map<String, TokenPostings> _ngramPostings;
665670

666671
late final _counterPool = IndexedCounterPool(_packageNames.length);
667672

@@ -672,19 +677,52 @@ class PackageNameIndex {
672677
return _PkgNameData(lowercased, collapsed);
673678
}).toList();
674679
_collapsedNameResolvesToMap = {};
675-
_trigramPostings = {};
680+
final builders = <String, PostingsBuilder>{};
676681
for (var i = 0; i < _data.length; i++) {
682+
final collapsed = _data[i].collapsed;
677683
_collapsedNameResolvesToMap
678-
.putIfAbsent(_data[i].collapsed, () => [])
684+
.putIfAbsent(collapsed, () => [])
679685
.add(_packageNames[i]);
680-
// Register only a single trigram -> document index posting.
681-
// TODO(https://github.com/dart-lang/pub-dev/issues/9462): consider posting weights
682-
for (final trigram in trigrams(_data[i].collapsed).toSet()) {
683-
_trigramPostings.putIfAbsent(trigram, () => <int>[]).add(i);
686+
final lowercased = _data[i].lowercased;
687+
// Register one posting per distinct 1-, 2- and 3-gram of the collapsed name.
688+
for (final ngram in _ngrams(collapsed)) {
689+
// The weight byte indicates whether the n-gram also occurs in the
690+
// original (e.g. underscored) name. Postings are added in ascending
691+
// document index order, as required by the delta encoding.
692+
final weight = lowercased.contains(ngram)
693+
? _originalNameWeight
694+
: _collapsedNameWeight;
695+
builders.putIfAbsent(ngram, PostingsBuilder.new).add(i, weight);
696+
}
697+
}
698+
_ngramPostings = builders.map((k, b) => MapEntry(k, b.build()));
699+
}
700+
701+
Iterable<String> _ngrams(String collapsed) {
702+
final result = <String>{};
703+
final length = collapsed.length;
704+
for (var i = 0; i < length; i++) {
705+
for (var j = 1; j <= 3 && i + j <= length; j++) {
706+
result.add(collapsed.substring(i, i + j));
684707
}
685708
}
709+
return result;
686710
}
687711

712+
/// Score assigned when the query n-gram(s) occur in the original (underscored)
713+
/// package name.
714+
static const _originalNameScore = 1.0;
715+
716+
/// Score assigned when the query n-gram(s) occur only in the collapsed name,
717+
/// i.e. the match spans a removed underscore.
718+
static const _collapsedNameScore = _collapsedNameWeight / _originalNameWeight;
719+
720+
/// Posting weight bytes: the match score scaled by 100. Storing them as small
721+
/// integers lets the trigram counter sum the weights and recognize a perfect
722+
/// all-original match as `parts.length * _originalNameWeight`.
723+
static const _originalNameWeight = 100;
724+
static const _collapsedNameWeight = 99;
725+
688726
String _removeUnderscores(String text) => text.replaceAll('_', '');
689727

690728
/// Search [text] and return the matching packages with scores.
@@ -723,57 +761,50 @@ class PackageNameIndex {
723761
final lowercasedWord = word.toLowerCase();
724762
final collapsedWord = _removeUnderscores(lowercasedWord);
725763

726-
bool tryScoreWithSubstringMatch(int index) {
727-
final entry = _data[index];
728-
if (entry.collapsed.length >= collapsedWord.length &&
729-
entry.collapsed.contains(collapsedWord)) {
730-
// also check for non-collapsed match
731-
if (entry.lowercased.length >= lowercasedWord.length &&
732-
entry.lowercased.contains(lowercasedWord)) {
733-
score.setValue(index, 1.0);
734-
} else {
735-
score.setValue(index, 0.99);
736-
}
737-
return true;
738-
}
739-
return false;
740-
}
741-
742-
// Note: short strings (less than a trigram) are not indexed separately,
743-
// we need to do full substring check, evaluating every package.
744-
// TODO(https://github.com/dart-lang/pub-dev/issues/9462): consider updating the index + the evaluation algorithm
745-
if (collapsedWord.length < 3) {
746-
for (var i = 0; i < _data.length; i++) {
747-
if (filterOnNonZeros?.isNotPositive(i) ?? false) continue;
748-
tryScoreWithSubstringMatch(i);
749-
}
764+
// Short queries (1-3 chars) are themselves a single indexed n-gram: look the
765+
// whole query up in the postings and score each candidate directly from its
766+
// stored original/collapsed weight, without any substring re-check.
767+
if (collapsedWord.length <= 3) {
768+
final postings = _ngramPostings[collapsedWord];
769+
if (postings == null) return;
770+
postings.forEach((i, weight) {
771+
if (filterOnNonZeros?.isNotPositive(i) ?? false) return;
772+
score.setValue(
773+
i,
774+
weight == _originalNameWeight
775+
? _originalNameScore
776+
: _collapsedNameScore,
777+
);
778+
});
750779
return;
751780
}
752781

753782
_counterPool.withPoolItemSync(
754783
fn: (counts) {
755-
// count the trigrams for each package
784+
// Sum the posting weights of the matching trigrams for each package.
756785
final parts = trigrams(collapsedWord);
757786
for (final part in parts) {
758-
final postings = _trigramPostings[part];
787+
final postings = _ngramPostings[part];
759788
if (postings == null) continue;
760-
for (final i in postings) {
761-
counts.increment(i);
762-
}
789+
postings.forEach((i, weight) => counts.add(i, weight));
763790
}
764791

765-
final acceptThreshold = parts.length ~/ 2;
792+
final n = parts.length;
793+
final fullMatchSum = n * _originalNameWeight;
794+
final fullMatchCollapsedSum = n * _collapsedNameWeight;
795+
final minSum = fullMatchCollapsedSum ~/ 2;
796+
766797
for (var i = 0; i < _packageNames.length; i++) {
767-
final matched = counts.getValue(i);
768-
if (matched == 0) continue;
798+
final sum = counts.getValue(i);
799+
// Partial matches must still cover at least half of the trigrams.
800+
if (sum < minSum) continue;
769801
if (filterOnNonZeros?.isNotPositive(i) ?? false) continue;
770-
if (tryScoreWithSubstringMatch(i)) continue;
771-
if (matched >= acceptThreshold) {
772-
// making sure that match score is minimum 0.5
773-
final v = matched / parts.length;
774-
if (v >= 0.5) {
775-
score.setValue(i, v);
776-
}
802+
803+
if (sum >= fullMatchSum) {
804+
score.setValue(i, _originalNameScore);
805+
} else {
806+
// Partial match: fractional relevance score in [0.5, 1.0).
807+
score.setValue(i, sum / fullMatchSum);
777808
}
778809
}
779810
},

app/lib/search/token_index.dart

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ class TokenMatch {
4949
/// index (or 0 for the first entry). Each byte stores 7 data bits in
5050
/// bits 0-6; bit 7 is the continuation flag (1 = more bytes follow).
5151
/// Typical deltas (1-127) encode in a single byte.
52-
/// - **Weight**: quantized token weight, 0-255, where the original float
53-
/// weight is recovered as `(weight + 1) / 256`.
52+
/// - **Weight**: an application-defined weight byte, 0-255. [TokenIndex] stores
53+
/// a quantized float weight recovered as `(weight + 1) / 256`; other indexes
54+
/// (e.g. the package name index) may use it to store a binary indicator instead.
5455
///
5556
/// Example: doc indices [3, 5, 200] with weights [255, 128, 64] encodes as:
5657
///
@@ -90,7 +91,7 @@ class TokenPostings {
9091

9192
/// Builds a [TokenPostings] incrementally using 128-byte buffer chunks,
9293
/// concatenating into a single [Uint8List] at the end.
93-
class _PostingsBuilder {
94+
class PostingsBuilder {
9495
static const _bufferSize = 128;
9596

9697
List<Uint8List>? _chunks;
@@ -166,7 +167,7 @@ class TokenIndex {
166167
TokenIndex._(this._length, this._postings);
167168

168169
factory TokenIndex(List<String?> values, {bool skipDocumentWeight = false}) {
169-
final builders = <String, _PostingsBuilder>{};
170+
final builders = <String, PostingsBuilder>{};
170171
for (var i = 0; i < values.length; i++) {
171172
final text = values[i];
172173
if (text == null) continue;
@@ -185,7 +186,7 @@ class TokenIndex {
185186
List<T> objects,
186187
List<TokenIndexField> Function(T object) extractFields,
187188
) {
188-
final builders = <String, _PostingsBuilder>{};
189+
final builders = <String, PostingsBuilder>{};
189190
for (var i = 0; i < objects.length; i++) {
190191
final merged = <String, double>{};
191192
for (final field in extractFields(objects[i])) {
@@ -212,7 +213,7 @@ class TokenIndex {
212213
List<List<String>?> values, {
213214
bool skipDocumentWeight = false,
214215
}) {
215-
final builders = <String, _PostingsBuilder>{};
216+
final builders = <String, PostingsBuilder>{};
216217
for (var i = 0; i < values.length; i++) {
217218
final parts = values[i];
218219
if (parts == null || parts.isEmpty) continue;
@@ -234,7 +235,7 @@ class TokenIndex {
234235
}
235236

236237
static Map<String, TokenPostings> _finalize(
237-
Map<String, _PostingsBuilder> builders,
238+
Map<String, PostingsBuilder> builders,
238239
) {
239240
return builders.map((token, builder) => MapEntry(token, builder.build()));
240241
}
@@ -244,17 +245,15 @@ class TokenIndex {
244245
int docIndex,
245246
Map<String, double>? tokens,
246247
bool skipDocumentWeight,
247-
Map<String, _PostingsBuilder> builders,
248+
Map<String, PostingsBuilder> builders,
248249
) {
249250
if (tokens == null || tokens.isEmpty) return;
250251
// Document weight is a highly scaled-down proxy of the length.
251252
final dw = skipDocumentWeight ? 1.0 : _documentWeight(tokens.length);
252253
for (final e in tokens.entries) {
253254
final weight = e.value / dw;
254255
final quantized = (weight * 256 - 1).round().clamp(0, 255);
255-
builders
256-
.putIfAbsent(e.key, _PostingsBuilder.new)
257-
.add(docIndex, quantized);
256+
builders.putIfAbsent(e.key, PostingsBuilder.new).add(docIndex, quantized);
258257
}
259258
}
260259

@@ -519,7 +518,7 @@ extension type IndexedCounter(List<int> values) {
519518

520519
int getValue(int index) => values[index];
521520

522-
void increment(int index) {
523-
values[index]++;
521+
void add(int index, int amount) {
522+
values[index] += amount;
524523
}
525524
}

app/test/search/package_name_index_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ void main() {
8383
test('substring: entu', () {
8484
expect(index.search('entu'), {
8585
'fluent': 0.5,
86-
'fluent_ui': 0.99, // not 1.0
86+
'fluent_ui': 0.995, // not 1.0
8787
});
8888
});
8989
});

app/test/search/stack_trace_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ void main() {
3838
'totalCount': 1,
3939
'sdkLibraryHits': [],
4040
'packageHits': [
41-
{'package': 'stack_trace', 'score': 0.99},
41+
{'package': 'stack_trace', 'score': 0.9975},
4242
],
4343
});
4444
});

0 commit comments

Comments
 (0)