@plebeiantech/lat.md 0.12.3-fork.16 → 0.12.3-fork.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/cli/index.js +12 -6
- package/dist/src/cli/init.js +3 -4
- package/dist/src/cli/reindex.js +4 -8
- package/dist/src/cli/search.d.ts +3 -2
- package/dist/src/cli/search.js +69 -59
- package/dist/src/format.d.ts +5 -0
- package/dist/src/format.js +32 -4
- package/dist/src/lattice-model.d.ts +8 -1
- package/dist/src/lattice-model.js +4 -1
- package/dist/src/lattice.d.ts +2 -2
- package/dist/src/lattice.js +17 -34
- package/dist/src/markdown-analysis.d.ts +11 -0
- package/dist/src/markdown-analysis.js +31 -1
- package/dist/src/mcp/server.js +6 -6
- package/dist/src/parser-cache.d.ts +1 -1
- package/dist/src/parser-cache.js +1 -1
- package/dist/src/search/cache.d.ts +3 -0
- package/dist/src/search/cache.js +71 -0
- package/dist/src/search/chunks.d.ts +23 -0
- package/dist/src/search/chunks.js +197 -0
- package/dist/src/search/db.d.ts +27 -16
- package/dist/src/search/db.js +108 -38
- package/dist/src/search/index.d.ts +5 -2
- package/dist/src/search/index.js +173 -69
- package/dist/src/search/lexical.d.ts +6 -0
- package/dist/src/search/lexical.js +36 -0
- package/dist/src/search/lock.d.ts +2 -0
- package/dist/src/search/lock.js +23 -0
- package/dist/src/search/query.d.ts +2 -2
- package/dist/src/search/query.js +24 -9
- package/dist/src/search/search.d.ts +13 -8
- package/dist/src/search/search.js +155 -23
- package/dist/src/search/types.d.ts +31 -0
- package/dist/src/search/types.js +1 -0
- package/dist/src/source-formats.d.ts +1 -1
- package/dist/src/source-formats.js +2 -0
- package/dist/src/source-parser.js +197 -1
- package/dist/src/view/client/assets/{GraphView-BuZg_wrj.js → GraphView-BWlRdQM4.js} +2 -2
- package/dist/src/view/client/assets/{MarkdownEditor-BKqJtZYv.js → MarkdownEditor-MW-Ck1O0.js} +1 -1
- package/dist/src/view/client/assets/{abnfDiagram-VCTEODGH-CAu3KwfO.js → abnfDiagram-VCTEODGH-Bn3RshMV.js} +1 -1
- package/dist/src/view/client/assets/{arc-CFnPZhKM.js → arc-En9tdFQF.js} +1 -1
- package/dist/src/view/client/assets/{architectureDiagram-5GKGNRK7-Cf9Kd0OA.js → architectureDiagram-5GKGNRK7-B-9J1VAM.js} +1 -1
- package/dist/src/view/client/assets/{blockDiagram-I7D4REHJ-dPn7g_vm.js → blockDiagram-I7D4REHJ-QlznwalP.js} +1 -1
- package/dist/src/view/client/assets/{c4Diagram-7LVT6UL2-DdZrwaPd.js → c4Diagram-7LVT6UL2-Bp2y8B4_.js} +1 -1
- package/dist/src/view/client/assets/channel-uy0fFvcW.js +1 -0
- package/dist/src/view/client/assets/{chunk-2Q5K7J3B-C42JuteH.js → chunk-2Q5K7J3B-CnBKL-lm.js} +1 -1
- package/dist/src/view/client/assets/{chunk-5VM5RSS4-BKK3z8Zy.js → chunk-5VM5RSS4-LLYMi_n6.js} +1 -1
- package/dist/src/view/client/assets/{chunk-F27PBJKO-qZl_uKcW.js → chunk-F27PBJKO-VIeO7VF0.js} +1 -1
- package/dist/src/view/client/assets/{chunk-IMKFNOWR-Bs4Rnoj_.js → chunk-IMKFNOWR-D5i67DVD.js} +1 -1
- package/dist/src/view/client/assets/{chunk-JWPE2WC7-BpxgV2rA.js → chunk-JWPE2WC7-D4qAtoL3.js} +1 -1
- package/dist/src/view/client/assets/{chunk-POPQ4Y6H-hIynofdE.js → chunk-POPQ4Y6H-BMzOyiW_.js} +1 -1
- package/dist/src/view/client/assets/{chunk-SVP7TREG-t3gKMqeu.js → chunk-SVP7TREG-DWy5nbfp.js} +1 -1
- package/dist/src/view/client/assets/{chunk-TICWLB2K-B2x394mh.js → chunk-TICWLB2K-CVqCANFB.js} +1 -1
- package/dist/src/view/client/assets/{chunk-XXDRQBXY-CHaVKDsH.js → chunk-XXDRQBXY-Bp23h6Tf.js} +1 -1
- package/dist/src/view/client/assets/classDiagram-ZZMXUADV-DbSEqjA5.js +1 -0
- package/dist/src/view/client/assets/classDiagram-v2-VYDZK3BY-DbSEqjA5.js +1 -0
- package/dist/src/view/client/assets/{cose-bilkent-JH36ORCC-DE2-Jgqu.js → cose-bilkent-JH36ORCC-BCw7fCC2.js} +1 -1
- package/dist/src/view/client/assets/{cynefin-OW5HDTMX-BsEtOvRy.js → cynefin-OW5HDTMX--lYN4N9m.js} +1 -1
- package/dist/src/view/client/assets/{cynefinDiagram-5FMLGOSQ-BKYG1Xs8.js → cynefinDiagram-5FMLGOSQ-9sK5EV7v.js} +1 -1
- package/dist/src/view/client/assets/{dagre-GXQ25YYZ-hgrdNjl7.js → dagre-GXQ25YYZ-CoMdEFCq.js} +1 -1
- package/dist/src/view/client/assets/{diagram-S7CK7UJ4-VIx8zAxf.js → diagram-S7CK7UJ4-D13dyWdd.js} +1 -1
- package/dist/src/view/client/assets/{diagram-UQ7AKVKN-yfloehRm.js → diagram-UQ7AKVKN-PdwLPPPA.js} +1 -1
- package/dist/src/view/client/assets/{diagram-VSXAHHWV-BT_dA4Q9.js → diagram-VSXAHHWV-CtWWa_A7.js} +1 -1
- package/dist/src/view/client/assets/{diagram-VX7I27RA-Gfmwxx6u.js → diagram-VX7I27RA-BeCYcSU-.js} +1 -1
- package/dist/src/view/client/assets/{diagram-Z3DM3KII-C2yM7EOo.js → diagram-Z3DM3KII-DiWabOBM.js} +1 -1
- package/dist/src/view/client/assets/{ebnfDiagram-PWID7BFC-Bkb_Lj59.js → ebnfDiagram-PWID7BFC-CZQ3MD_K.js} +1 -1
- package/dist/src/view/client/assets/{erDiagram-RLTQ6QDP-C7wQHPye.js → erDiagram-RLTQ6QDP-B1AhXIrP.js} +1 -1
- package/dist/src/view/client/assets/{flowDiagram-HODETNUW-CXoKr3oI.js → flowDiagram-HODETNUW-Bhr0JM6P.js} +1 -1
- package/dist/src/view/client/assets/{ganttDiagram-EL5Y4UJY-DqqXKgLa.js → ganttDiagram-EL5Y4UJY-CbP8iAkg.js} +1 -1
- package/dist/src/view/client/assets/{gitGraphDiagram-WWUBYQGX-D_DAhSgZ.js → gitGraphDiagram-WWUBYQGX-B9uuGdLF.js} +1 -1
- package/dist/src/view/client/assets/{index-DAxFk0q-.css → index-DG0Ez15D.css} +1 -1
- package/dist/src/view/client/assets/index-nBmbYfun.js +13 -0
- package/dist/src/view/client/assets/{infoDiagram-27XIBGKW-B50EOats.js → infoDiagram-27XIBGKW-BrfwQ_Cq.js} +1 -1
- package/dist/src/view/client/assets/{ishikawaDiagram-5VMMS53U-D1rny9gj.js → ishikawaDiagram-5VMMS53U-CHtJ36re.js} +1 -1
- package/dist/src/view/client/assets/{journeyDiagram-3NMN7TZE-B5UD2Xo4.js → journeyDiagram-3NMN7TZE-BChcgX1s.js} +1 -1
- package/dist/src/view/client/assets/{kanban-definition-UXKFOSKX-CTgey9zw.js → kanban-definition-UXKFOSKX-BnwFsKiB.js} +1 -1
- package/dist/src/view/client/assets/{layout-4X2n4os-.js → layout-Dnd0pLdb.js} +1 -1
- package/dist/src/view/client/assets/{linear-D0RIBXsp.js → linear-CFhW2ozn.js} +1 -1
- package/dist/src/view/client/assets/{mermaid.core-BAnH8y-D.js → mermaid.core--OP6lXtW.js} +5 -5
- package/dist/src/view/client/assets/{mindmap-definition-YA3MSWOX-WiFsh3Jg.js → mindmap-definition-YA3MSWOX-D51y2zbm.js} +1 -1
- package/dist/src/view/client/assets/{pegDiagram-XKGWAZYB-DCJOrckP.js → pegDiagram-XKGWAZYB--zyQJDvI.js} +1 -1
- package/dist/src/view/client/assets/{pieDiagram-E7YTZNPT-C78IkUE2.js → pieDiagram-E7YTZNPT-Cj8mvpJy.js} +1 -1
- package/dist/src/view/client/assets/{quadrantDiagram-AXDQQJYC-xZ6guleB.js → quadrantDiagram-AXDQQJYC-qvhJrnBf.js} +1 -1
- package/dist/src/view/client/assets/{railroadDiagram-O6MQD6OU-D3PwmgX0.js → railroadDiagram-O6MQD6OU-CtWvxpSo.js} +1 -1
- package/dist/src/view/client/assets/{requirementDiagram-BXWQKSXE-v-I8wLGH.js → requirementDiagram-BXWQKSXE-CBxen_KG.js} +1 -1
- package/dist/src/view/client/assets/{sankeyDiagram-P5KCCOFB-DBY-sdO7.js → sankeyDiagram-P5KCCOFB-B_fUkmfc.js} +1 -1
- package/dist/src/view/client/assets/{sequenceDiagram-WJ2MYXX4-D9HApBFi.js → sequenceDiagram-WJ2MYXX4-Dclv4p68.js} +1 -1
- package/dist/src/view/client/assets/{sizeCapture-INFHLROL-ERAytLnQ.js → sizeCapture-INFHLROL-GfG0sMTZ.js} +1 -1
- package/dist/src/view/client/assets/{stateDiagram-D77RDMKH-BKef_Qwv.js → stateDiagram-D77RDMKH-YQWPnzxU.js} +1 -1
- package/dist/src/view/client/assets/stateDiagram-v2-MP3YSRHH-gUj6Hzgl.js +1 -0
- package/dist/src/view/client/assets/{swimlanes-42K2YHIH-D7dS_tnF.js → swimlanes-42K2YHIH-5yiCveVS.js} +1 -1
- package/dist/src/view/client/assets/swimlanesDiagram-VR7AAH4N-DR7JhrxL.js +8 -0
- package/dist/src/view/client/assets/{timeline-definition-24CTP7MA-Di1UCseG.js → timeline-definition-24CTP7MA-BvAbB5Zs.js} +1 -1
- package/dist/src/view/client/assets/{vennDiagram-4TSXK5OY-g7Je3H2H.js → vennDiagram-4TSXK5OY-guC-r60n.js} +1 -1
- package/dist/src/view/client/assets/{wardleyDiagram-VM6X3IG4-C_diPEka.js → wardleyDiagram-VM6X3IG4-C0yXcU2x.js} +1 -1
- package/dist/src/view/client/assets/{xychartDiagram-S5SC5T6Z-C8uIQy_I.js → xychartDiagram-S5SC5T6Z-RPmkPNfK.js} +1 -1
- package/dist/src/view/client/index.html +2 -2
- package/dist/src/view/highlight.js +2 -0
- package/dist/src/view/markdown.js +15 -0
- package/dist/src/view/protocol.d.ts +7 -1
- package/dist/src/view/references.js +3 -3
- package/dist/src/view/repository.js +10 -2
- package/dist/src/view/search-result.js +7 -2
- package/dist/src/view/server-build.js +1 -1
- package/dist/src/view/server-deployment.js +2 -1
- package/dist/src/view/server-index-worker.js +1 -1
- package/dist/src/view/source-target.d.ts +2 -0
- package/dist/src/view/source-target.js +24 -0
- package/dist/src/view/store.js +3 -3
- package/dist/src/view/vercel-build.js +7 -0
- package/package.json +6 -3
- package/templates/AGENTS.md +1 -1
- package/templates/cursor-rules.md +1 -1
- package/templates/skill/SKILL.md +1 -1
- package/dist/src/view/client/assets/channel-C-byJRR7.js +0 -1
- package/dist/src/view/client/assets/classDiagram-ZZMXUADV-FlqGrEwv.js +0 -1
- package/dist/src/view/client/assets/classDiagram-v2-VYDZK3BY-FlqGrEwv.js +0 -1
- package/dist/src/view/client/assets/index-DPuOWFj2.js +0 -10
- package/dist/src/view/client/assets/stateDiagram-v2-MP3YSRHH-BePfayWI.js +0 -1
- package/dist/src/view/client/assets/swimlanesDiagram-VR7AAH4N-DaqCEguT.js +0 -8
package/dist/src/search/index.js
CHANGED
|
@@ -1,85 +1,189 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { PARSER_CACHE_VERSION } from '../parser-cache.js';
|
|
2
|
+
import { synchronizeLexical } from './lexical.js';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { CREATE_PASSAGE_FTS } from './db.js';
|
|
3
5
|
import { analyzeMarkdownProject, } from '../project-analysis.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
import { chunkFile, digest, embeddingFingerprint } from './chunks.js';
|
|
7
|
+
export function projectFingerprint(project) {
|
|
8
|
+
return digest(`parser:${PARSER_CACHE_VERSION}\n` +
|
|
9
|
+
[...project.files.values()]
|
|
10
|
+
.map((f) => `${f.projectPath}\0${digest(f.content)}`)
|
|
11
|
+
.sort()
|
|
12
|
+
.join('\n'));
|
|
6
13
|
}
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const lines = new Map();
|
|
15
|
-
for (const { filePath } of sections) {
|
|
16
|
-
if (lines.has(filePath))
|
|
17
|
-
continue;
|
|
18
|
-
const content = project.filesByAbsolutePath.get(join(projectRoot, filePath))?.content;
|
|
19
|
-
if (content === undefined)
|
|
20
|
-
throw new Error(`Analyzed Markdown file not found: ${filePath}`);
|
|
21
|
-
lines.set(filePath, content.split('\n'));
|
|
22
|
-
}
|
|
23
|
-
return lines;
|
|
24
|
-
}
|
|
25
|
-
function sectionContent(section, lines) {
|
|
26
|
-
return lines
|
|
27
|
-
.get(section.filePath)
|
|
28
|
-
.slice(section.startLine - 1, section.endLine)
|
|
29
|
-
.join('\n');
|
|
14
|
+
/** Exact identifier tokens complement FTS's punctuation and long-token handling. */
|
|
15
|
+
export function identifierTokens(text) {
|
|
16
|
+
return [
|
|
17
|
+
...new Set((text.match(/[\p{L}\p{N}_][\p{L}\p{N}_./:#@-]*/gu) ?? [])
|
|
18
|
+
.map((t) => t.toLowerCase())
|
|
19
|
+
.filter((t) => /[_./:#@-]/.test(t) || t.length >= 40)),
|
|
20
|
+
];
|
|
30
21
|
}
|
|
31
22
|
export async function indexSections(latDir, db, embedder, onProgress, analyzedProject) {
|
|
32
|
-
const projectRoot = dirname(latDir);
|
|
33
23
|
const project = analyzedProject ??
|
|
34
|
-
(await analyzeMarkdownProject(latDir,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
(await analyzeMarkdownProject(latDir, dirname(latDir), {
|
|
25
|
+
executor: 'auto',
|
|
26
|
+
}));
|
|
27
|
+
const fingerprint = embeddingFingerprint(embedder);
|
|
28
|
+
const projectHash = projectFingerprint(project);
|
|
29
|
+
const oldFingerprint = (await db.execute("SELECT value FROM meta WHERE key='fingerprint'")).rows[0]?.value;
|
|
30
|
+
if (oldFingerprint && oldFingerprint !== fingerprint)
|
|
31
|
+
throw new Error('Search chunking or embedding model changed; run lat reindex.');
|
|
32
|
+
const byFile = new Map();
|
|
33
|
+
for (const s of project.sections) {
|
|
34
|
+
const list = byFile.get(s.filePath) ?? [];
|
|
35
|
+
list.push(s);
|
|
36
|
+
byFile.set(s.filePath, list);
|
|
37
|
+
}
|
|
38
|
+
const files = new Map([...project.files.values()].map((f) => [f.projectPath, f]));
|
|
39
|
+
const passages = [...byFile].flatMap(([path, sections]) => {
|
|
40
|
+
const file = files.get(path);
|
|
41
|
+
if (!file)
|
|
42
|
+
throw new Error(`Missing analyzed file: ${path}`);
|
|
43
|
+
return chunkFile(file, sections, embedder);
|
|
44
|
+
});
|
|
45
|
+
const existing = new Map((await db.execute('SELECT id,content_hash FROM sections')).rows.map((r) => [
|
|
46
|
+
r.id,
|
|
47
|
+
r.content_hash,
|
|
48
|
+
]));
|
|
49
|
+
const sectionHashes = new Map();
|
|
50
|
+
const owned = new Map();
|
|
51
|
+
for (const p of passages) {
|
|
52
|
+
const list = owned.get(p.sectionId) ?? [];
|
|
53
|
+
list.push(p);
|
|
54
|
+
owned.set(p.sectionId, list);
|
|
42
55
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
for (const s of project.sections) {
|
|
57
|
+
sectionHashes.set(s.id, digest(JSON.stringify([
|
|
58
|
+
{ ...s, children: undefined },
|
|
59
|
+
(owned.get(s.id) ?? []).map((p) => [p.inputHash, p.spans]),
|
|
60
|
+
])));
|
|
48
61
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const changed = new Set([...sectionHashes]
|
|
63
|
+
.filter(([id, hash]) => existing.get(id) !== hash)
|
|
64
|
+
.map(([id]) => id));
|
|
65
|
+
const removed = [...existing.keys()].filter((id) => !sectionHashes.has(id));
|
|
66
|
+
const stats = {
|
|
67
|
+
added: [...changed].filter((id) => !existing.has(id)).length,
|
|
68
|
+
updated: [...changed].filter((id) => existing.has(id)).length,
|
|
69
|
+
removed: removed.length,
|
|
70
|
+
unchanged: project.sections.length - changed.size,
|
|
71
|
+
};
|
|
72
|
+
if (!changed.size && !removed.length && oldFingerprint === fingerprint) {
|
|
73
|
+
// Lexical maintenance can repair old generations without embedding work.
|
|
74
|
+
await db.execute('BEGIN');
|
|
75
|
+
try {
|
|
76
|
+
await synchronizeLexical(db);
|
|
77
|
+
await db.execute({
|
|
78
|
+
sql: "UPDATE meta SET value=? WHERE key='project_hash' AND value<>?",
|
|
79
|
+
args: [projectHash, projectHash],
|
|
80
|
+
});
|
|
81
|
+
await db.execute('COMMIT');
|
|
56
82
|
}
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
catch (error) {
|
|
84
|
+
await db.execute('ROLLBACK');
|
|
85
|
+
throw error;
|
|
59
86
|
}
|
|
87
|
+
return stats;
|
|
60
88
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
const storedHashes = new Set((await db.execute('SELECT hash FROM embeddings')).rows.map((r) => r.hash));
|
|
90
|
+
const missing = new Map(passages
|
|
91
|
+
.filter((p) => !storedHashes.has(p.inputHash))
|
|
92
|
+
.map((p) => [p.inputHash, p.input]));
|
|
93
|
+
const entries = [...missing];
|
|
94
|
+
const vectors = entries.length
|
|
95
|
+
? await embedder.embed(entries.map(([, input]) => input), onProgress)
|
|
96
|
+
: [];
|
|
97
|
+
if (vectors.length !== entries.length ||
|
|
98
|
+
vectors.some((v) => v.length !== embedder.dimensions || v.some((n) => !Number.isFinite(n))))
|
|
99
|
+
throw new Error('Embedding backend returned invalid vectors');
|
|
100
|
+
const rebuildFts = !existing.size ||
|
|
101
|
+
// Tantivy retains deleted versions in BM25 statistics until rebuilt.
|
|
102
|
+
stats.updated > 0 ||
|
|
103
|
+
removed.length > 0 ||
|
|
104
|
+
passages.filter((p) => changed.has(p.sectionId)).length > 512;
|
|
105
|
+
await db.execute('BEGIN');
|
|
106
|
+
try {
|
|
107
|
+
if (rebuildFts)
|
|
108
|
+
await db.execute('DROP INDEX IF EXISTS chunks_fts');
|
|
109
|
+
for (let i = 0; i < entries.length; i++)
|
|
110
|
+
await db.execute({
|
|
111
|
+
sql: 'INSERT INTO embeddings VALUES (?,vector32(?))',
|
|
112
|
+
args: [entries[i][0], JSON.stringify(vectors[i])],
|
|
113
|
+
});
|
|
114
|
+
for (const id of [...changed, ...removed]) {
|
|
115
|
+
await db.execute({
|
|
116
|
+
sql: 'DELETE FROM lexical_chunks WHERE id IN (SELECT id FROM chunks WHERE section_id=?)',
|
|
117
|
+
args: [id],
|
|
118
|
+
});
|
|
119
|
+
await db.execute({
|
|
120
|
+
sql: 'DELETE FROM identifiers WHERE chunk_id IN (SELECT id FROM chunks WHERE section_id=?)',
|
|
121
|
+
args: [id],
|
|
122
|
+
});
|
|
71
123
|
await db.execute({
|
|
72
|
-
sql:
|
|
73
|
-
|
|
74
|
-
args: [id, section.file, section.heading, content, hash, vecJson, now],
|
|
124
|
+
sql: 'DELETE FROM chunks WHERE section_id=?',
|
|
125
|
+
args: [id],
|
|
75
126
|
});
|
|
127
|
+
await db.execute({ sql: 'DELETE FROM sections WHERE id=?', args: [id] });
|
|
76
128
|
}
|
|
129
|
+
const parents = new Map();
|
|
130
|
+
for (const s of project.sections)
|
|
131
|
+
for (const child of s.children)
|
|
132
|
+
parents.set(child.id, s.id);
|
|
133
|
+
for (const s of project.sections)
|
|
134
|
+
if (changed.has(s.id))
|
|
135
|
+
await db.execute({
|
|
136
|
+
sql: 'INSERT INTO sections VALUES (?,?,?,?,?,?,?,?)',
|
|
137
|
+
args: [
|
|
138
|
+
s.id,
|
|
139
|
+
s.file,
|
|
140
|
+
s.heading,
|
|
141
|
+
s.firstParagraph,
|
|
142
|
+
sectionHashes.get(s.id),
|
|
143
|
+
parents.get(s.id) ?? null,
|
|
144
|
+
s.startLine,
|
|
145
|
+
s.endLine,
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
for (const p of passages)
|
|
149
|
+
if (changed.has(p.sectionId)) {
|
|
150
|
+
const row = (await db.execute({
|
|
151
|
+
sql: 'INSERT INTO chunks(source_id,section_id,ordinal,type,spans,body,heading,path,input_hash) VALUES (?,?,?,?,?,?,?,?,?) RETURNING id',
|
|
152
|
+
args: [
|
|
153
|
+
p.id,
|
|
154
|
+
p.sectionId,
|
|
155
|
+
p.ordinal,
|
|
156
|
+
p.type,
|
|
157
|
+
JSON.stringify(p.spans),
|
|
158
|
+
p.text,
|
|
159
|
+
p.heading,
|
|
160
|
+
p.path,
|
|
161
|
+
p.inputHash,
|
|
162
|
+
],
|
|
163
|
+
})).rows[0];
|
|
164
|
+
for (const token of identifierTokens(`${p.sectionId}\n${project.sectionById.get(p.sectionId.toLowerCase())?.filePath ?? ''}\n${p.heading}\n${p.path}\n${p.text}`))
|
|
165
|
+
await db.execute({
|
|
166
|
+
sql: 'INSERT INTO identifiers VALUES (?,?)',
|
|
167
|
+
args: [token, row.id],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
await db.execute('DELETE FROM embeddings WHERE hash NOT IN (SELECT input_hash FROM chunks)');
|
|
171
|
+
await db.execute({
|
|
172
|
+
sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
|
|
173
|
+
args: ['fingerprint', fingerprint],
|
|
174
|
+
});
|
|
175
|
+
await db.execute({
|
|
176
|
+
sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
|
|
177
|
+
args: ['project_hash', projectHash],
|
|
178
|
+
});
|
|
179
|
+
await synchronizeLexical(db);
|
|
180
|
+
if (rebuildFts)
|
|
181
|
+
await db.execute(CREATE_PASSAGE_FTS);
|
|
182
|
+
await db.execute('COMMIT');
|
|
77
183
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
184
|
+
catch (error) {
|
|
185
|
+
await db.execute('ROLLBACK');
|
|
186
|
+
throw error;
|
|
81
187
|
}
|
|
82
|
-
|
|
83
|
-
const updated = toEmbed.filter((e) => existing.has(e.id)).length;
|
|
84
|
-
return { added, updated, removed: toDelete.length, unchanged };
|
|
188
|
+
return stats;
|
|
85
189
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SearchDb } from './db.js';
|
|
2
|
+
export declare const LEXICAL_VERSION = "snowball-english-rust-stemmers-1.2.0-v1:unicode-words-v1:live-statistics-v1";
|
|
3
|
+
/** Same analysis for indexed fields and queries; exact identifiers bypass it. */
|
|
4
|
+
export declare function lexicalTokens(text: string): string[];
|
|
5
|
+
/** Populate derived lexical rows, reusing original passages and vectors. */
|
|
6
|
+
export declare function synchronizeLexical(db: SearchDb): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { stemWords, STEMMER_VERSION } from '@lat.md/stemmer';
|
|
2
|
+
import { CREATE_PASSAGE_FTS } from './db.js';
|
|
3
|
+
export const LEXICAL_VERSION = `${STEMMER_VERSION}:unicode-words-v1:live-statistics-v1`;
|
|
4
|
+
/** Same analysis for indexed fields and queries; exact identifiers bypass it. */
|
|
5
|
+
export function lexicalTokens(text) {
|
|
6
|
+
const tokens = text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
7
|
+
const english = tokens.filter((token) => /^[a-z]+$/.test(token));
|
|
8
|
+
const stems = stemWords(english);
|
|
9
|
+
let i = 0;
|
|
10
|
+
return tokens.map((token) => (/^[a-z]+$/.test(token) ? stems[i++] : token));
|
|
11
|
+
}
|
|
12
|
+
/** Populate derived lexical rows, reusing original passages and vectors. */
|
|
13
|
+
export async function synchronizeLexical(db) {
|
|
14
|
+
const version = (await db.execute("SELECT value FROM meta WHERE key='lexical_version'")).rows[0]?.value;
|
|
15
|
+
if (version !== LEXICAL_VERSION) {
|
|
16
|
+
await db.execute('DROP INDEX IF EXISTS chunks_fts');
|
|
17
|
+
await db.execute('DELETE FROM lexical_chunks');
|
|
18
|
+
}
|
|
19
|
+
await db.execute('DELETE FROM lexical_chunks WHERE id NOT IN (SELECT id FROM chunks)');
|
|
20
|
+
const rows = (await db.execute('SELECT id,body,heading,path FROM chunks WHERE id NOT IN (SELECT id FROM lexical_chunks)')).rows;
|
|
21
|
+
for (const row of rows)
|
|
22
|
+
await db.execute({
|
|
23
|
+
sql: 'INSERT INTO lexical_chunks VALUES (?,?,?,?)',
|
|
24
|
+
args: [
|
|
25
|
+
row.id,
|
|
26
|
+
...[row.body, row.heading, row.path].map((text) => lexicalTokens(text).join(' ')),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
if (version !== LEXICAL_VERSION) {
|
|
30
|
+
await db.execute(CREATE_PASSAGE_FTS);
|
|
31
|
+
await db.execute({
|
|
32
|
+
sql: 'INSERT OR REPLACE INTO meta VALUES (?,?)',
|
|
33
|
+
args: ['lexical_version', LEXICAL_VERSION],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { setTimeout } from 'node:timers/promises';
|
|
4
|
+
/** The persistent file must never be unlinked: all writers lock the same inode. */
|
|
5
|
+
export async function acquireSearchLock(cacheDir, timeoutMs = 120_000) {
|
|
6
|
+
const { tryLock } = await import('fs-native-extensions');
|
|
7
|
+
const handle = await open(join(cacheDir, 'search-write.lock'), 'a+');
|
|
8
|
+
const deadline = performance.now() + timeoutMs;
|
|
9
|
+
try {
|
|
10
|
+
while (!tryLock(handle.fd)) {
|
|
11
|
+
const remaining = deadline - performance.now();
|
|
12
|
+
if (remaining <= 0)
|
|
13
|
+
throw new Error('Search index writer is busy; retry shortly.');
|
|
14
|
+
await setTimeout(Math.min(100, remaining));
|
|
15
|
+
}
|
|
16
|
+
// Closing releases the kernel lock, including on abnormal process exit.
|
|
17
|
+
return () => handle.close();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
await handle.close();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -6,7 +6,7 @@ export type IndexedSearchResult = {
|
|
|
6
6
|
matches: SectionMatch[];
|
|
7
7
|
};
|
|
8
8
|
export type IndexedSearchSession = {
|
|
9
|
-
search: (query: string, limit: number,
|
|
9
|
+
search: (query: string, limit: number, minSimilarity?: number) => Promise<SearchResult[]>;
|
|
10
10
|
close: () => Promise<void>;
|
|
11
11
|
};
|
|
12
12
|
/** Resolve scored index rows against one already-analyzed project snapshot. */
|
|
@@ -19,5 +19,5 @@ export declare function openIndexedSearchSession(latDir: string, options?: {
|
|
|
19
19
|
/** Query a finished index and resolve its ids from precomputed section data. */
|
|
20
20
|
export declare function searchIndexedSections(latDir: string, query: string, limit: number, sectionById: ReadonlyMap<string, Section>, options?: {
|
|
21
21
|
cacheDir?: string;
|
|
22
|
-
|
|
22
|
+
minSimilarity?: number;
|
|
23
23
|
}): Promise<IndexedSearchResult>;
|
package/dist/src/search/query.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LEXICAL_VERSION } from './lexical.js';
|
|
2
|
+
import { ReindexRequiredError } from './embedder.js';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { hasIndex } from './db.js';
|
|
6
|
+
import { closeDb, getStoredModel, openDb } from './db.js';
|
|
2
7
|
import { embedderForIndex } from './embedder.js';
|
|
3
8
|
import { searchSections } from './search.js';
|
|
4
9
|
/** Resolve scored index rows against one already-analyzed project snapshot. */
|
|
@@ -9,8 +14,14 @@ export function resolveSearchMatches(results, sectionById) {
|
|
|
9
14
|
? [
|
|
10
15
|
{
|
|
11
16
|
section,
|
|
12
|
-
reason: '
|
|
13
|
-
|
|
17
|
+
reason: 'hybrid match',
|
|
18
|
+
rankScore: result.rankScore,
|
|
19
|
+
semanticSimilarity: result.semanticSimilarity,
|
|
20
|
+
lexicalScore: result.lexicalScore,
|
|
21
|
+
semanticRank: result.semanticRank,
|
|
22
|
+
lexicalRank: result.lexicalRank,
|
|
23
|
+
evidence: result.evidence,
|
|
24
|
+
diagnostics: result.diagnostics,
|
|
14
25
|
},
|
|
15
26
|
]
|
|
16
27
|
: [];
|
|
@@ -18,7 +29,10 @@ export function resolveSearchMatches(results, sectionById) {
|
|
|
18
29
|
}
|
|
19
30
|
/** Open one reusable database and embedder for a sequence of index queries. */
|
|
20
31
|
export async function openIndexedSearchSession(latDir, options = {}) {
|
|
21
|
-
const
|
|
32
|
+
const cacheDir = options.cacheDir ?? join(latDir, '.cache');
|
|
33
|
+
if (!existsSync(cacheDir) || !hasIndex(cacheDir))
|
|
34
|
+
return { search: async () => [], close: async () => { } };
|
|
35
|
+
const db = openDb(latDir, options.cacheDir, true);
|
|
22
36
|
let closed = false;
|
|
23
37
|
const close = async () => {
|
|
24
38
|
if (closed)
|
|
@@ -27,7 +41,6 @@ export async function openIndexedSearchSession(latDir, options = {}) {
|
|
|
27
41
|
await closeDb(db);
|
|
28
42
|
};
|
|
29
43
|
try {
|
|
30
|
-
await ensureMeta(db);
|
|
31
44
|
const stored = await getStoredModel(db);
|
|
32
45
|
if (stored === null) {
|
|
33
46
|
return {
|
|
@@ -39,13 +52,15 @@ export async function openIndexedSearchSession(latDir, options = {}) {
|
|
|
39
52
|
close,
|
|
40
53
|
};
|
|
41
54
|
}
|
|
55
|
+
const lexicalVersion = (await db.execute("SELECT value FROM meta WHERE key='lexical_version'")).rows[0]?.value;
|
|
56
|
+
if (lexicalVersion !== LEXICAL_VERSION)
|
|
57
|
+
throw new ReindexRequiredError('Search lexical index changed; run lat search to update it.');
|
|
42
58
|
const embedder = await embedderForIndex(stored, latDir, options.createSearchEngine);
|
|
43
|
-
await ensureSectionsSchema(db, embedder.dimensions);
|
|
44
59
|
return {
|
|
45
|
-
async search(query, limit,
|
|
60
|
+
async search(query, limit, minSimilarity) {
|
|
46
61
|
if (closed)
|
|
47
62
|
throw new Error('Search session is closed');
|
|
48
|
-
return searchSections(db, query, embedder, limit,
|
|
63
|
+
return searchSections(db, query, embedder, limit, minSimilarity);
|
|
49
64
|
},
|
|
50
65
|
close,
|
|
51
66
|
};
|
|
@@ -59,7 +74,7 @@ export async function openIndexedSearchSession(latDir, options = {}) {
|
|
|
59
74
|
export async function searchIndexedSections(latDir, query, limit, sectionById, options = {}) {
|
|
60
75
|
const session = await openIndexedSearchSession(latDir, options);
|
|
61
76
|
try {
|
|
62
|
-
const results = await session.search(query, limit, options.
|
|
77
|
+
const results = await session.search(query, limit, options.minSimilarity);
|
|
63
78
|
return {
|
|
64
79
|
query,
|
|
65
80
|
matches: resolveSearchMatches(results, sectionById),
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SearchDb } from './db.js';
|
|
2
2
|
import type { Embedder } from './embedder.js';
|
|
3
|
+
import type { SearchResult } from './types.js';
|
|
4
|
+
export type { SearchResult, SearchEvidence, SearchDiagnostics, } from './types.js';
|
|
3
5
|
export declare const DEFAULT_SEARCH_LIMIT = 5;
|
|
4
|
-
export declare const
|
|
5
|
-
export type
|
|
6
|
-
id:
|
|
7
|
-
|
|
8
|
-
heading: string;
|
|
9
|
-
content: string;
|
|
6
|
+
export declare const DEFAULT_MIN_SIMILARITY = 0.2;
|
|
7
|
+
export type Candidate = {
|
|
8
|
+
id: number;
|
|
9
|
+
sectionId: string;
|
|
10
10
|
score: number;
|
|
11
|
+
exact?: boolean;
|
|
11
12
|
};
|
|
12
|
-
export declare function
|
|
13
|
+
export declare function collapse(candidates: Candidate[]): Map<string, Candidate & {
|
|
14
|
+
rank: number;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function literalFtsQuery(query: string): string;
|
|
17
|
+
export declare function searchSections(db: SearchDb, query: string, embedder: Embedder, limit?: number, minSimilarity?: number): Promise<SearchResult[]>;
|
|
@@ -1,25 +1,157 @@
|
|
|
1
|
+
import { lexicalTokens } from './lexical.js';
|
|
1
2
|
export const DEFAULT_SEARCH_LIMIT = 5;
|
|
2
|
-
export const
|
|
3
|
-
export
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
3
|
+
export const DEFAULT_MIN_SIMILARITY = 0.2;
|
|
4
|
+
export function collapse(candidates) {
|
|
5
|
+
const unique = new Map();
|
|
6
|
+
for (const c of candidates)
|
|
7
|
+
if (!unique.has(c.sectionId))
|
|
8
|
+
unique.set(c.sectionId, c);
|
|
9
|
+
let rank = 0, previous;
|
|
10
|
+
return new Map([...unique].map(([id, c]) => {
|
|
11
|
+
if (!previous ||
|
|
12
|
+
c.score !== previous.score ||
|
|
13
|
+
!!c.exact !== !!previous.exact)
|
|
14
|
+
rank++;
|
|
15
|
+
previous = c;
|
|
16
|
+
return [id, { ...c, rank }];
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
export function literalFtsQuery(query) {
|
|
20
|
+
const stopwords = new Set('a an and are as at be by do does for from how i in is it of on or our that the their this to we what when where which who why with you your use uses used have has had can could would should'.split(' '));
|
|
21
|
+
return (query.match(/[\p{L}\p{N}_]+/gu) ?? [])
|
|
22
|
+
.filter((t) => !stopwords.has(t.toLowerCase()))
|
|
23
|
+
.flatMap((t) => lexicalTokens(t))
|
|
24
|
+
.map((t) => `"${t.replaceAll('"', '\\"')}"`)
|
|
25
|
+
.join(' OR ');
|
|
26
|
+
}
|
|
27
|
+
export async function searchSections(db, query, embedder, limit = DEFAULT_SEARCH_LIMIT, minSimilarity = DEFAULT_MIN_SIMILARITY) {
|
|
28
|
+
query = query.trim();
|
|
29
|
+
if (!query)
|
|
30
|
+
return [];
|
|
31
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
32
|
+
throw new Error('limit must be a positive integer');
|
|
33
|
+
if (!Number.isFinite(minSimilarity) || minSimilarity < 0 || minSimilarity > 1)
|
|
34
|
+
throw new Error('min-similarity must be a number from 0 to 1');
|
|
35
|
+
if (embedder.countTokens(query) > embedder.maxInputTokens)
|
|
36
|
+
throw new Error('Search query exceeds the embedding model token limit; shorten the query.');
|
|
37
|
+
const [vector] = await embedder.embed([query]);
|
|
38
|
+
if (!vector ||
|
|
39
|
+
vector.length !== embedder.dimensions ||
|
|
40
|
+
vector.some((n) => !Number.isFinite(n)))
|
|
41
|
+
throw new Error('Embedding backend returned an invalid query vector');
|
|
42
|
+
const vectorJson = JSON.stringify(vector), fts = literalFtsQuery(query);
|
|
43
|
+
const target = Math.max(50, limit), cap = 10 * target;
|
|
44
|
+
const owners = new Map((await db.execute('SELECT id,section_id FROM chunks')).rows.map((r) => [
|
|
45
|
+
r.id,
|
|
46
|
+
r.section_id,
|
|
47
|
+
]));
|
|
48
|
+
const exactRows = (await db.execute({
|
|
49
|
+
sql: 'SELECT chunk_id FROM identifiers WHERE token=? LIMIT ?',
|
|
50
|
+
args: [query.toLowerCase(), cap],
|
|
51
|
+
})).rows;
|
|
52
|
+
const exact = exactRows.map((r) => ({
|
|
53
|
+
id: r.chunk_id,
|
|
54
|
+
sectionId: owners.get(r.chunk_id),
|
|
55
|
+
score: 0,
|
|
56
|
+
exact: true,
|
|
57
|
+
}));
|
|
58
|
+
async function retrieve(channel) {
|
|
59
|
+
let count = 2 * target, candidates = [], capped = false;
|
|
60
|
+
while (true) {
|
|
61
|
+
const rows = channel === 'lexical'
|
|
62
|
+
? fts
|
|
63
|
+
? (await db.execute({
|
|
64
|
+
sql: 'SELECT id,fts_score(body,heading,path,?) AS score FROM lexical_chunks ORDER BY score DESC LIMIT ?',
|
|
65
|
+
args: [fts, count],
|
|
66
|
+
})).rows
|
|
67
|
+
: []
|
|
68
|
+
: (await db.execute({
|
|
69
|
+
sql: 'SELECT c.id,1-vector_distance_cos(e.embedding,vector32(?)) AS score FROM chunks c JOIN embeddings e ON c.input_hash=e.hash ORDER BY score DESC LIMIT ?',
|
|
70
|
+
args: [vectorJson, count],
|
|
71
|
+
})).rows;
|
|
72
|
+
const hits = rows
|
|
73
|
+
.filter((r) => channel === 'lexical' ? r.score > 0 : r.score >= minSimilarity)
|
|
74
|
+
.map((r) => ({
|
|
75
|
+
id: r.id,
|
|
76
|
+
sectionId: owners.get(r.id),
|
|
77
|
+
score: Number(r.score),
|
|
78
|
+
}));
|
|
79
|
+
if (channel === 'lexical') {
|
|
80
|
+
const exactIds = new Set(exact.map((c) => c.id));
|
|
81
|
+
candidates = [...exact, ...hits.filter((c) => !exactIds.has(c.id))];
|
|
82
|
+
}
|
|
83
|
+
else
|
|
84
|
+
candidates = hits;
|
|
85
|
+
candidates.sort((a, b) => Number(!!b.exact) - Number(!!a.exact) ||
|
|
86
|
+
b.score - a.score ||
|
|
87
|
+
a.sectionId.localeCompare(b.sectionId) ||
|
|
88
|
+
a.id - b.id);
|
|
89
|
+
if (new Set(candidates.map((c) => c.sectionId)).size >= target ||
|
|
90
|
+
rows.length < count ||
|
|
91
|
+
hits.length < rows.length ||
|
|
92
|
+
!rows.length)
|
|
93
|
+
break;
|
|
94
|
+
if (count >= cap) {
|
|
95
|
+
capped = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
count = Math.min(cap, count * 2);
|
|
99
|
+
}
|
|
100
|
+
return { list: collapse(candidates), capped, count: candidates.length };
|
|
101
|
+
}
|
|
102
|
+
const lexical = await retrieve('lexical'), semantic = await retrieve('semantic');
|
|
103
|
+
const all = new Set([...lexical.list.keys(), ...semantic.list.keys()]);
|
|
104
|
+
const ranked = [...all]
|
|
105
|
+
.map((id) => ({ id, l: lexical.list.get(id), v: semantic.list.get(id) }))
|
|
106
|
+
.map((r) => ({
|
|
107
|
+
...r,
|
|
108
|
+
rankScore: (r.l ? 1 / (60 + r.l.rank) : 0) + (r.v ? 1 / (60 + r.v.rank) : 0),
|
|
109
|
+
}))
|
|
110
|
+
.sort((a, b) => b.rankScore - a.rankScore || a.id.localeCompare(b.id))
|
|
111
|
+
.slice(0, limit);
|
|
112
|
+
const results = [];
|
|
113
|
+
for (const r of ranked) {
|
|
114
|
+
const s = (await db.execute({
|
|
115
|
+
sql: 'SELECT * FROM sections WHERE id=?',
|
|
116
|
+
args: [r.id],
|
|
117
|
+
})).rows[0];
|
|
118
|
+
if (!s)
|
|
119
|
+
continue;
|
|
120
|
+
const channels = [
|
|
121
|
+
...(r.l ? [{ candidate: r.l, channel: 'lexical' }] : []),
|
|
122
|
+
...(r.v ? [{ candidate: r.v, channel: 'semantic' }] : []),
|
|
123
|
+
].sort((a, b) => a.candidate.rank - b.candidate.rank);
|
|
124
|
+
const seen = new Set(), evidence = [];
|
|
125
|
+
for (const { candidate, channel } of channels)
|
|
126
|
+
if (!seen.has(candidate.id)) {
|
|
127
|
+
seen.add(candidate.id);
|
|
128
|
+
const p = (await db.execute({
|
|
129
|
+
sql: 'SELECT source_id,body,spans FROM chunks WHERE id=?',
|
|
130
|
+
args: [candidate.id],
|
|
131
|
+
})).rows[0];
|
|
132
|
+
evidence.push({
|
|
133
|
+
chunkId: p.source_id,
|
|
134
|
+
text: p.body,
|
|
135
|
+
spans: JSON.parse(p.spans),
|
|
136
|
+
channel,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
results.push({
|
|
140
|
+
id: s.id,
|
|
141
|
+
file: s.file,
|
|
142
|
+
heading: s.heading,
|
|
143
|
+
content: s.content,
|
|
144
|
+
rankScore: r.rankScore,
|
|
145
|
+
...(r.v ? { semanticSimilarity: r.v.score, semanticRank: r.v.rank } : {}),
|
|
146
|
+
...(r.l ? { lexicalScore: r.l.score, lexicalRank: r.l.rank } : {}),
|
|
147
|
+
evidence,
|
|
148
|
+
diagnostics: {
|
|
149
|
+
lexicalCapped: lexical.capped,
|
|
150
|
+
semanticCapped: semantic.capped,
|
|
151
|
+
lexicalCandidates: lexical.count,
|
|
152
|
+
semanticCandidates: semantic.count,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return results;
|
|
25
157
|
}
|