╭ search.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 3 // an inverted index over any xitdb cursor: a sorted map from word to the 4 // sorted set of doc keys whose text holds it. a doc key is opaque bytes whose 5 // sort order is the order results come back in. 6 7 // the shortest word indexed or queried. a single character would expand to 8 // hundreds of words under prefix matching. 9 const min_word_len = 2; 10 // a longer word is truncated to this rather than skipped, so a prefix query 11 // still reaches it. 12 const max_word_len = 32; 13 // how many distinct words one document contributes. 14 const max_words_per_doc = 64; 15 // how much of a document is tokenized. 16 pub const max_indexed_bytes = 4 * 1024; 17 // how many indexed words one typed word expands to. 18 const max_expansions = 50; 19 // the largest doc key, so results are read into a buffer rather than allocated. 20 pub const max_doc_key_len = 64; 21 22 // index `text` under `doc_key`. 23 pub fn add( 24 comptime DB: type, 25 index: DB.SortedMap(.read_write), 26 allocator: std.mem.Allocator, 27 doc_key: []const u8, 28 text: []const u8, 29 ) !void { 30 var arena = std.heap.ArenaAllocator.init(allocator); 31 defer arena.deinit(); 32 for (try tokenize(arena.allocator(), text)) |word| { 33 const set = try DB.SortedSet(.read_write).init(try index.putCursor(word)); 34 try set.put(doc_key); 35 } 36 } 37 38 // drop `doc_key`'s postings. the caller supplies the text again, so no forward 39 // index is stored. 40 pub fn remove( 41 comptime DB: type, 42 index: DB.SortedMap(.read_write), 43 allocator: std.mem.Allocator, 44 doc_key: []const u8, 45 text: []const u8, 46 ) !void { 47 var arena = std.heap.ArenaAllocator.init(allocator); 48 defer arena.deinit(); 49 for (try tokenize(arena.allocator(), text)) |word| { 50 if (null == try index.getCursor(word)) continue; 51 const set = try DB.SortedSet(.read_write).init(try index.putCursor(word)); 52 _ = try set.remove(doc_key); 53 if (0 == try set.count()) _ = try index.remove(word); 54 } 55 } 56 57 // the doc keys holding every typed word, in key order. a stream, so callers 58 // stop at their page size. 59 pub fn Query(comptime DB: type) type { 60 return struct { 61 // one per typed word; empty when nothing can match 62 terms: []Term, 63 // the key the next result starts from, plus room for the one byte that 64 // steps past a returned key 65 pos: [max_doc_key_len + 1]u8 = undefined, 66 pos_len: usize = 0, 67 // whether `pos` holds a key that was already returned 68 returned: bool = false, 69 70 const Self = @This(); 71 72 // one typed word: the doc keys of every indexed word it expands to, 73 // merged into one ascending stream. 74 const Term = struct { 75 postings: []Posting, 76 77 // the smallest doc key at or after `key`, or null when the term is 78 // exhausted. 79 fn seek(self: *Term, key: []const u8) !?[]const u8 { 80 var best: ?[]const u8 = null; 81 for (self.postings) |*posting| { 82 try posting.advance(key); 83 if (posting.done) continue; 84 const current = posting.key[0..posting.key_len]; 85 if (best) |found| { 86 if (std.mem.order(u8, current, found) == .lt) best = current; 87 } else best = current; 88 } 89 return best; 90 } 91 }; 92 93 // one indexed word's doc keys, positioned at the current one. 94 const Posting = struct { 95 set: DB.SortedSet(.read_only), 96 key: [max_doc_key_len]u8 = undefined, 97 key_len: usize = 0, 98 done: bool = false, 99 started: bool = false, 100 101 // move to the first key at or after `key`. seeking rather than 102 // stepping keeps the cost proportional to the results. 103 fn advance(self: *Posting, key: []const u8) !void { 104 if (self.done) return; 105 if (self.started and std.mem.order(u8, self.key[0..self.key_len], key) != .lt) return; 106 var iter = try self.set.iteratorFrom(key); 107 self.started = true; 108 const cursor = (try iter.next()) orelse { 109 self.done = true; 110 return; 111 }; 112 const pair = try cursor.readKeyValuePair(); 113 self.key_len = (try pair.key_cursor.readBytes(&self.key)).len; 114 } 115 }; 116 117 pub fn init(index: DB.SortedMap(.read_only), aa: std.mem.Allocator, text: []const u8) !Self { 118 var terms: std.ArrayList(Term) = .empty; 119 for (try tokenize(aa, text)) |word| { 120 var postings: std.ArrayList(Posting) = .empty; 121 var iter = try index.iteratorFrom(word); 122 while (try iter.next()) |cursor| { 123 if (postings.items.len == max_expansions) break; 124 const pair = try cursor.readKeyValuePair(); 125 var buffer: [max_word_len]u8 = undefined; 126 const indexed = try pair.key_cursor.readBytes(&buffer); 127 if (!std.mem.startsWith(u8, indexed, word)) break; 128 try postings.append(aa, .{ .set = try DB.SortedSet(.read_only).init(pair.value_cursor) }); 129 } 130 // a word nothing starts with rules out every document 131 if (postings.items.len == 0) return .{ .terms = &.{} }; 132 try terms.append(aa, .{ .postings = postings.items }); 133 } 134 return .{ .terms = terms.items }; 135 } 136 137 // start the next result at `key` rather than the first one. 138 pub fn seek(self: *Self, key: []const u8) void { 139 @memcpy(self.pos[0..key.len], key); 140 self.pos_len = key.len; 141 self.returned = false; 142 } 143 144 pub fn next(self: *Self) !?[]const u8 { 145 if (self.terms.len == 0) return null; 146 if (self.returned) { 147 // doc keys are at most max_doc_key_len, so appending a byte 148 // lands strictly after the one just returned 149 self.pos[self.pos_len] = 0; 150 self.pos_len += 1; 151 self.returned = false; 152 } 153 // intersect by seeking: a term that lands past the candidate makes 154 // its key the new candidate and the others seek up to it. 155 var i: usize = 0; 156 while (i < self.terms.len) { 157 const key = (try self.terms[i].seek(self.pos[0..self.pos_len])) orelse return null; 158 if (std.mem.order(u8, key, self.pos[0..self.pos_len]) == .gt) { 159 @memcpy(self.pos[0..key.len], key); 160 self.pos_len = key.len; 161 i = 0; 162 continue; 163 } 164 i += 1; 165 } 166 self.returned = true; 167 return self.pos[0..self.pos_len]; 168 } 169 }; 170 } 171 172 // the unique lowercased words of `text`, in the order they first appear. 173 fn tokenize(aa: std.mem.Allocator, text: []const u8) ![]const []const u8 { 174 var words: std.StringArrayHashMapUnmanaged(void) = .empty; 175 const limit = @min(text.len, max_indexed_bytes); 176 var i: usize = 0; 177 while (i < limit) { 178 if (!isWordByte(text[i])) { 179 i += 1; 180 continue; 181 } 182 const start = i; 183 while (i < limit and isWordByte(text[i])) i += 1; 184 const word = text[start..@min(i, start + max_word_len)]; 185 if (word.len < min_word_len) continue; 186 const lowered = try aa.alloc(u8, word.len); 187 _ = std.ascii.lowerString(lowered, word); 188 try words.put(aa, lowered, {}); 189 if (words.count() == max_words_per_doc) break; 190 } 191 return words.keys(); 192 } 193 194 // a word is a run of ascii alphanumerics and non-ascii bytes. 195 fn isWordByte(byte: u8) bool { 196 return std.ascii.isAlphanumeric(byte) or byte >= 0x80; 197 } 198