md-semantic-search 0.1.0
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/LICENSE +21 -0
- package/README.md +162 -0
- package/RESEARCH.md +152 -0
- package/bin/cli.mjs +157 -0
- package/package.json +48 -0
- package/src/core.mjs +0 -0
- package/src/indexer.mjs +107 -0
- package/src/models.mjs +55 -0
- package/src/search.mjs +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chelslava
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# md-semantic-search
|
|
2
|
+
|
|
3
|
+
**Local, private semantic (vector) search over any folder of Markdown files.**
|
|
4
|
+
|
|
5
|
+
Find passages by *meaning*, not just keywords — and across languages (ask in
|
|
6
|
+
one language, match documents written in another). Runs fully on your machine
|
|
7
|
+
via [transformers.js](https://github.com/xenova/transformers.js): no API keys,
|
|
8
|
+
no cloud calls, no vector database. Your notes never leave the disk.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx md-semantic-search index --db ./docs
|
|
12
|
+
npx md-semantic-search search --db ./docs "how do I rotate the API token"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
Keyword search misses paraphrases. A query like *"как починить зависший ввод на
|
|
20
|
+
windows"* will never match a page titled *"win32 stdin re-wrap closes buffer"* —
|
|
21
|
+
zero shared words, different language. Semantic search embeds both into the same
|
|
22
|
+
vector space and matches on meaning. This tool was extracted from a real wiki
|
|
23
|
+
where exactly that gap kept biting; see **[RESEARCH.md](./RESEARCH.md)** for the
|
|
24
|
+
measurements that shaped its defaults.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- 🔌 **Any folder, anywhere.** Point `--db` at any directory of `.md`/`.markdown`
|
|
29
|
+
files. It does **not** have to live inside this project. Recursive by default.
|
|
30
|
+
- 🌍 **Cross-lingual.** Multilingual embeddings (default `multilingual-e5-base`).
|
|
31
|
+
- 🧠 **Hybrid ranking.** Reciprocal Rank Fusion of vector similarity (meaning)
|
|
32
|
+
and lexical overlap (exact names like `win32`, `TextIOWrapper`).
|
|
33
|
+
- ⚡ **Incremental.** Per-file md5 — re-indexing only re-embeds changed files.
|
|
34
|
+
- 🔒 **Private & offline.** Model downloads once, then no network. Nothing is
|
|
35
|
+
uploaded anywhere.
|
|
36
|
+
- 📦 **Zero infra.** One JSON index, brute-force cosine in memory. No Pinecone,
|
|
37
|
+
no Qdrant, no pgvector. Scales fine to thousands of chunks.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Node.js ≥ 18
|
|
42
|
+
- ~280 MB disk for the default model (downloaded once into a cache dir)
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
Run on demand with `npx` (no install):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx md-semantic-search --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or install globally for the short `mdss` alias:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install -g md-semantic-search
|
|
56
|
+
mdss --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or from source:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/chelslava/md-semantic-search
|
|
63
|
+
cd md-semantic-search
|
|
64
|
+
npm install
|
|
65
|
+
node bin/cli.mjs --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
### 1. Build the index
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
mdss index --db /path/to/your/markdown
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
First run downloads the model (~280 MB). The index is written to `<db>/.mdss/`
|
|
77
|
+
by default (override with `--index-dir`). Re-run after editing your notes — it's
|
|
78
|
+
incremental, so only changed files are re-embedded.
|
|
79
|
+
|
|
80
|
+
### 2. Search
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
mdss search --db /path/to/your/markdown "your question in plain language"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Example output:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Top 3 for: "how do I add a new translation language"
|
|
90
|
+
|
|
91
|
+
1. [cos 0.833] i18n Application Analysis
|
|
92
|
+
i18n-analysis.md › Language status
|
|
93
|
+
| Language | File | Status | ... English | en/shared.json | complete | ...
|
|
94
|
+
|
|
95
|
+
2. ...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Options
|
|
99
|
+
|
|
100
|
+
| Flag | Meaning |
|
|
101
|
+
|------|---------|
|
|
102
|
+
| `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
|
|
103
|
+
| `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
|
|
104
|
+
| `--cache-dir <dir>` | Model cache dir (default: package `.cache`, or `MDSS_CACHE_DIR`). |
|
|
105
|
+
| `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
|
|
106
|
+
| `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
|
|
107
|
+
| `--k <n>` | Number of results (default 6). |
|
|
108
|
+
| `--json` | Machine-readable output. |
|
|
109
|
+
| `--semantic` | Pure vector ranking, skip lexical fusion. |
|
|
110
|
+
|
|
111
|
+
### The base can live outside the project
|
|
112
|
+
|
|
113
|
+
The index does not need write access to your notes if they're read-only — just
|
|
114
|
+
point the index somewhere writable:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
mdss index --db /mnt/shared/team-wiki --index-dir ~/.cache/team-wiki-index
|
|
118
|
+
mdss search --db /mnt/shared/team-wiki --index-dir ~/.cache/team-wiki-index "incident runbook for db failover"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or drive everything from environment variables:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
export MDSS_DB=/mnt/shared/team-wiki
|
|
125
|
+
export MDSS_INDEX_DIR=~/.cache/team-wiki-index
|
|
126
|
+
mdss index
|
|
127
|
+
mdss search "incident runbook for db failover"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Models
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
mdss models
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Alias | Model | Dim | Notes |
|
|
137
|
+
|-------|-------|-----|-------|
|
|
138
|
+
| `e5-small` | `Xenova/multilingual-e5-small` | 384 | Fastest (~120 MB). **Weak cross-lingual** — see RESEARCH. |
|
|
139
|
+
| `e5-base` ⭐ | `Xenova/multilingual-e5-base` | 768 | Default. Best balance. |
|
|
140
|
+
| `e5-large` | `Xenova/multilingual-e5-large` | 1024 | ~2.2 GB, higher quality. |
|
|
141
|
+
| `bge-m3` | `Xenova/bge-m3` | 1024 | ~2.3 GB. Best cross-lingual separation in tests. |
|
|
142
|
+
|
|
143
|
+
Switching models invalidates the stored vectors automatically — the next
|
|
144
|
+
`index` run does a full rebuild. You can also pass any raw `Xenova/*` id.
|
|
145
|
+
|
|
146
|
+
## How it works
|
|
147
|
+
|
|
148
|
+
1. **Walk** `--db` recursively for `.md`/`.markdown` (dotfiles & `--ignore`
|
|
149
|
+
globs skipped).
|
|
150
|
+
2. **Chunk** each file by Markdown headings; oversized sections split on blank
|
|
151
|
+
lines (~1400 chars/chunk).
|
|
152
|
+
3. **Embed** each chunk (`passage:` prefix for E5) → store `{file, heading,
|
|
153
|
+
text, vec}` in `vectors.json`, plus per-file md5 in `.hashes.json`.
|
|
154
|
+
4. **Search**: embed the query (`query:` prefix), score every chunk by cosine,
|
|
155
|
+
score by lexical term-overlap, then **fuse with RRF**. Return top-k chunks.
|
|
156
|
+
|
|
157
|
+
No external services, no database — the whole index is one JSON file and search
|
|
158
|
+
is an in-memory dot-product sweep.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT © chelslava
|
package/RESEARCH.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# RESEARCH — design notes & measurements
|
|
2
|
+
|
|
3
|
+
This tool's defaults aren't guesses. They come from building semantic search
|
|
4
|
+
over a real, bilingual (Russian/English) engineering wiki and measuring what
|
|
5
|
+
actually worked. This document records those experiments so the choices are
|
|
6
|
+
auditable and reproducible.
|
|
7
|
+
|
|
8
|
+
> **TL;DR**
|
|
9
|
+
> - For a small corpus you do **not** need a vector database — brute-force
|
|
10
|
+
> cosine over a JSON file is microseconds.
|
|
11
|
+
> - **`multilingual-e5-small` is not good enough for cross-lingual search.** It
|
|
12
|
+
> ranked an irrelevant page *above* the correct one. `e5-base` fixed it.
|
|
13
|
+
> - **Quantization is not the problem** people assume — int8 vs fp32 changed
|
|
14
|
+
> cosine scores by <0.003 and never changed the ranking.
|
|
15
|
+
> - **Hybrid (vector + keyword via RRF)** beats either alone: vectors catch
|
|
16
|
+
> paraphrases, keywords catch exact identifiers.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. The problem
|
|
21
|
+
|
|
22
|
+
The starting point was a keyword-only wiki search (substring + tag matching).
|
|
23
|
+
It failed on the most common real query shape: a paraphrase, often in a
|
|
24
|
+
different language than the document.
|
|
25
|
+
|
|
26
|
+
Concrete failure: the query
|
|
27
|
+
|
|
28
|
+
> «как починить зависший ввод консоли на windows»
|
|
29
|
+
> *(how to fix frozen console input on windows)*
|
|
30
|
+
|
|
31
|
+
should surface a page documenting a Windows `stdin` bridge bug
|
|
32
|
+
(*"win32 stdin re-wrap closes the shared buffer"*). Keyword search returns
|
|
33
|
+
**nothing** — there is not one shared token, and the document is in English
|
|
34
|
+
while the query is in Russian.
|
|
35
|
+
|
|
36
|
+
## 2. Corpus scale — why no vector DB
|
|
37
|
+
|
|
38
|
+
The wiki under test:
|
|
39
|
+
|
|
40
|
+
| Metric | Value |
|
|
41
|
+
|--------|-------|
|
|
42
|
+
| Pages | 46 markdown files |
|
|
43
|
+
| Raw size | 238 KB (~140,800 chars ≈ 35K tokens) |
|
|
44
|
+
| Chunks (by heading) | 220 |
|
|
45
|
+
| Vector dim (e5-base) | 768 |
|
|
46
|
+
|
|
47
|
+
220 × 768 floats is a ~1.5 MB JSON file. A full cosine sweep over it is well
|
|
48
|
+
under a millisecond. **Pinecone / Qdrant / Chroma / pgvector would be pure
|
|
49
|
+
overhead here.** The entire index is one file; search is an in-memory
|
|
50
|
+
dot-product loop. This holds comfortably into the low tens of thousands of
|
|
51
|
+
chunks before you'd want an ANN index.
|
|
52
|
+
|
|
53
|
+
## 3. Experiment A — does quantization hurt?
|
|
54
|
+
|
|
55
|
+
transformers.js loads **int8-quantized** ONNX weights by default. A common
|
|
56
|
+
worry is that quantization collapses the embedding space. We tested the same
|
|
57
|
+
query against three passages, quantized vs full fp32 (`quantized: false`):
|
|
58
|
+
|
|
59
|
+
Query: «редактор кода съедает пробелы при наборе» *(code editor eats spaces while typing)*
|
|
60
|
+
|
|
61
|
+
| Passage | quantized (int8) | full (fp32) |
|
|
62
|
+
|---------|------------------:|------------:|
|
|
63
|
+
| win32 stdin re-wrap closes the shared buffer | 0.816 | 0.818 |
|
|
64
|
+
| Monaco editor drops every space while typing | 0.806 | 0.806 |
|
|
65
|
+
| Excel library writes cells / formats workbooks | 0.779 | 0.767 |
|
|
66
|
+
|
|
67
|
+
**Conclusion:** differences are ≤ 0.003 and the **ranking is identical**.
|
|
68
|
+
Quantization is not worth disabling — you'd pay 4× the download and memory for
|
|
69
|
+
no ranking change. (Note the *other* problem visible here, addressed next: the
|
|
70
|
+
irrelevant `win32` passage outscores the correct `Monaco` one — that's the
|
|
71
|
+
model, not the quantization.)
|
|
72
|
+
|
|
73
|
+
## 4. Experiment B — model choice decides cross-lingual quality
|
|
74
|
+
|
|
75
|
+
Same query («редактор кода съедает пробелы при наборе»), where the **correct**
|
|
76
|
+
answer is the Monaco "spaces" passage. We compared three multilingual models.
|
|
77
|
+
A model passes only if it ranks Monaco **above** the unrelated passages.
|
|
78
|
+
|
|
79
|
+
| Model | win32 stdin | **Monaco (correct)** | Excel | Correct #1? | Margin over runner-up |
|
|
80
|
+
|-------|------------:|---------------------:|------:|:-----------:|----------------------:|
|
|
81
|
+
| `multilingual-e5-small` | **0.818** | 0.806 | 0.767 | ❌ no | −0.012 |
|
|
82
|
+
| `multilingual-e5-base` | 0.784 | **0.810** | 0.741 | ✅ yes | +0.026 |
|
|
83
|
+
| `bge-m3` | 0.700 | **0.765** | 0.664 | ✅ yes | **+0.065** |
|
|
84
|
+
|
|
85
|
+
**Findings:**
|
|
86
|
+
|
|
87
|
+
- **`e5-small` fails.** It puts the unrelated `win32` passage *above* the
|
|
88
|
+
correct one. On a real corpus this means the right page never reaches the top.
|
|
89
|
+
Its cosine scores also sit in a narrow band (~0.77–0.82 for everything),
|
|
90
|
+
i.e. it barely separates documents.
|
|
91
|
+
- **`e5-base` fixes the ordering** at ~280 MB and stays fast — chosen as the
|
|
92
|
+
**default**.
|
|
93
|
+
- **`bge-m3` gives the widest separation** (+0.065 margin), the best quality,
|
|
94
|
+
but costs ~2.3 GB and is slower. Offered as an opt-in for quality-critical use.
|
|
95
|
+
|
|
96
|
+
This is why the default is `e5-base`, not the tempting lightweight `e5-small`.
|
|
97
|
+
|
|
98
|
+
## 5. Design decisions that follow
|
|
99
|
+
|
|
100
|
+
### E5 prefixes are mandatory
|
|
101
|
+
The E5 family is trained with instruction prefixes: documents must be embedded
|
|
102
|
+
as `passage: <text>` and queries as `query: <text>`. Omitting them measurably
|
|
103
|
+
degrades retrieval. The model registry encodes this per-model, because
|
|
104
|
+
**`bge-m3` is the opposite — it wants no prefix at all.** Getting this wrong is
|
|
105
|
+
a silent quality bug.
|
|
106
|
+
|
|
107
|
+
### Chunk by heading, not by page
|
|
108
|
+
Embedding a whole multi-section page into one vector blurs distinct topics.
|
|
109
|
+
We split on Markdown headings (`#`..`######`); sections over ~1400 chars split
|
|
110
|
+
again on blank lines. Search then returns the relevant *section*, with its
|
|
111
|
+
heading, not just "the page where the word appears somewhere."
|
|
112
|
+
|
|
113
|
+
### Hybrid ranking via Reciprocal Rank Fusion
|
|
114
|
+
Pure vectors miss exact identifiers (`win32`, `TextIOWrapper`, error codes);
|
|
115
|
+
pure keywords miss paraphrases. We compute two rankings — cosine and lexical
|
|
116
|
+
term-overlap — and fuse them with RRF (`score = Σ 1/(k + rank)`, `k=60`). RRF
|
|
117
|
+
needs no score normalization or weight tuning, which is what makes it robust
|
|
118
|
+
across very different score scales.
|
|
119
|
+
|
|
120
|
+
### Stop-words protect the lexical lane
|
|
121
|
+
Cross-lingual queries share function words ("при", "the", "кода") with
|
|
122
|
+
irrelevant documents, letting lexical overlap promote noise. A small ru/en
|
|
123
|
+
stop-list is removed before lexical scoring so RRF fuses signal, not filler.
|
|
124
|
+
|
|
125
|
+
### Incremental by content hash
|
|
126
|
+
Each file's md5 is stored. Re-indexing reuses embeddings for unchanged files
|
|
127
|
+
and only re-embeds what changed — a no-op re-index of 46 files is sub-second.
|
|
128
|
+
Changing the model invalidates all vectors and forces a clean rebuild
|
|
129
|
+
(dimensions and prefix semantics differ).
|
|
130
|
+
|
|
131
|
+
## 6. Reproducing
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Build with the default, then with the heavy model, and compare a known query:
|
|
135
|
+
mdss index --db ./your-wiki
|
|
136
|
+
mdss search --db ./your-wiki --semantic "a paraphrase of something you know is in there"
|
|
137
|
+
|
|
138
|
+
mdss index --db ./your-wiki --model bge-m3
|
|
139
|
+
mdss search --db ./your-wiki --semantic "the same paraphrase"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use `--semantic` to see the raw vector ranking (no lexical fusion) when
|
|
143
|
+
evaluating a model — that isolates embedding quality from the keyword lane.
|
|
144
|
+
|
|
145
|
+
## 7. Recommendations
|
|
146
|
+
|
|
147
|
+
| If you want… | Use |
|
|
148
|
+
|--------------|-----|
|
|
149
|
+
| Sensible default, fast, ~280 MB | `e5-base` (default) |
|
|
150
|
+
| Maximum cross-lingual quality, disk/time no object | `bge-m3` |
|
|
151
|
+
| Smallest footprint and your queries are same-language & literal | `e5-small` (with eyes open) |
|
|
152
|
+
| Best precision on exact identifiers | keep hybrid on (don't pass `--semantic`) |
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* md-semantic-search (mdss) — local semantic search over any folder of .md files.
|
|
4
|
+
*
|
|
5
|
+
* mdss index --db <dir> [--model e5-base] [--index-dir <dir>] [--ignore <glob>]
|
|
6
|
+
* mdss search --db <dir> [--k 6] [--json] [--semantic] "query text"
|
|
7
|
+
* mdss models
|
|
8
|
+
*
|
|
9
|
+
* The markdown base (--db) can live anywhere on disk — it does NOT need to be
|
|
10
|
+
* inside this project. The index defaults to <db>/.mdss unless --index-dir is given.
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import { buildIndex } from '../src/indexer.mjs';
|
|
16
|
+
import { search } from '../src/search.mjs';
|
|
17
|
+
import { MODELS, DEFAULT_MODEL } from '../src/models.mjs';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PKG_ROOT = path.resolve(HERE, '..');
|
|
21
|
+
const DEFAULT_CACHE = path.join(PKG_ROOT, '.cache');
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const opts = { _: [], ignore: [] };
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a === '--db') opts.db = argv[++i];
|
|
28
|
+
else if (a === '--index-dir') opts.indexDir = argv[++i];
|
|
29
|
+
else if (a === '--cache-dir') opts.cacheDir = argv[++i];
|
|
30
|
+
else if (a === '--model') opts.model = argv[++i];
|
|
31
|
+
else if (a === '--k') opts.k = parseInt(argv[++i], 10);
|
|
32
|
+
else if (a === '--ignore') opts.ignore.push(argv[++i]);
|
|
33
|
+
else if (a === '--json') opts.json = true;
|
|
34
|
+
else if (a === '--semantic') opts.semantic = true;
|
|
35
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
36
|
+
else opts._.push(a);
|
|
37
|
+
}
|
|
38
|
+
return opts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveDb(opts) {
|
|
42
|
+
const db = opts.db || process.env.MDSS_DB;
|
|
43
|
+
if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
|
|
44
|
+
const abs = path.resolve(db);
|
|
45
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
46
|
+
die(`--db is not a directory: ${abs}`);
|
|
47
|
+
}
|
|
48
|
+
return abs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveIndexDir(opts, db) {
|
|
52
|
+
// Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
|
|
53
|
+
const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
|
|
54
|
+
return path.resolve(dir);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolveCache = (opts) =>
|
|
58
|
+
path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || DEFAULT_CACHE);
|
|
59
|
+
|
|
60
|
+
function die(msg) {
|
|
61
|
+
process.stderr.write(`error: ${msg}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
mdss index --db <dir> [options] Build/refresh the index
|
|
69
|
+
mdss search --db <dir> [options] "query" Search by meaning
|
|
70
|
+
mdss models List available models
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
--db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
|
|
74
|
+
--index-dir <dir> Where to store the index (default: <db>/.mdss).
|
|
75
|
+
--cache-dir <dir> Model cache dir (default: <pkg>/.cache or MDSS_CACHE_DIR).
|
|
76
|
+
--model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
|
|
77
|
+
--ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
|
|
78
|
+
--k <n> Number of results (search, default 6).
|
|
79
|
+
--json Machine-readable output (search).
|
|
80
|
+
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
81
|
+
-h, --help Show this help.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
mdss index --db ./docs
|
|
85
|
+
mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
|
|
86
|
+
mdss search --db ./docs "how do I rotate the api token"
|
|
87
|
+
MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
async function cmdIndex(opts) {
|
|
91
|
+
const db = resolveDb(opts);
|
|
92
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
93
|
+
const cacheDir = resolveCache(opts);
|
|
94
|
+
const t0 = Date.now();
|
|
95
|
+
const r = await buildIndex({
|
|
96
|
+
db, indexDir, cacheDir,
|
|
97
|
+
modelName: opts.model || DEFAULT_MODEL,
|
|
98
|
+
ignore: opts.ignore,
|
|
99
|
+
log: s => process.stderr.write(s + '\n'),
|
|
100
|
+
});
|
|
101
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
`\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
|
|
104
|
+
`(${r.reused} reused, ${r.embedded} embedded), dim=${r.dim}, ` +
|
|
105
|
+
`model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function cmdSearch(opts) {
|
|
110
|
+
const db = resolveDb(opts);
|
|
111
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
112
|
+
const cacheDir = resolveCache(opts);
|
|
113
|
+
const query = opts._.join(' ').trim();
|
|
114
|
+
if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
|
|
115
|
+
|
|
116
|
+
const results = await search({
|
|
117
|
+
indexDir, cacheDir, query,
|
|
118
|
+
k: opts.k || 6,
|
|
119
|
+
semanticOnly: !!opts.semantic,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
123
|
+
if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
|
|
124
|
+
|
|
125
|
+
process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
|
|
126
|
+
results.forEach((r, i) => {
|
|
127
|
+
const loc = r.heading ? `${r.file} › ${r.heading}` : r.file;
|
|
128
|
+
process.stdout.write(`${i + 1}. [cos ${r.cosine}] ${r.title}\n`);
|
|
129
|
+
process.stdout.write(` ${loc}\n`);
|
|
130
|
+
process.stdout.write(` ${r.snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cmdModels() {
|
|
135
|
+
process.stdout.write('Available models (alias → id):\n\n');
|
|
136
|
+
for (const [alias, m] of Object.entries(MODELS)) {
|
|
137
|
+
const star = alias === DEFAULT_MODEL ? ' (default)' : '';
|
|
138
|
+
process.stdout.write(` ${alias}${star}\n ${m.id} · dim ${m.dim}\n ${m.note}\n\n`);
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write('You can also pass any raw Xenova/* model id to --model.\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function main() {
|
|
144
|
+
const argv = process.argv.slice(2);
|
|
145
|
+
const opts = parseArgs(argv);
|
|
146
|
+
const cmd = opts._.shift();
|
|
147
|
+
|
|
148
|
+
if (opts.help || !cmd) { process.stdout.write(HELP); return; }
|
|
149
|
+
switch (cmd) {
|
|
150
|
+
case 'index': return cmdIndex(opts);
|
|
151
|
+
case 'search': return cmdSearch(opts);
|
|
152
|
+
case 'models': return cmdModels();
|
|
153
|
+
default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
main().catch(e => die(e.message));
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "md-semantic-search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"md-semantic-search": "bin/cli.mjs",
|
|
8
|
+
"mdss": "bin/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"RESEARCH.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"semantic-search",
|
|
22
|
+
"vector-search",
|
|
23
|
+
"embeddings",
|
|
24
|
+
"markdown",
|
|
25
|
+
"rag",
|
|
26
|
+
"transformers",
|
|
27
|
+
"e5",
|
|
28
|
+
"local",
|
|
29
|
+
"offline",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"author": "chelslava",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/chelslava/md-semantic-search.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/chelslava/md-semantic-search#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/chelslava/md-semantic-search/issues"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@xenova/transformers": "^2.17.2"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/core.mjs
ADDED
|
Binary file
|
package/src/indexer.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build / refresh the semantic index for a folder of markdown files.
|
|
3
|
+
* Incremental: per-file md5; unchanged files reuse their stored embeddings.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { walkMarkdown, parseFile, embed, resolveModel } from './core.mjs';
|
|
9
|
+
|
|
10
|
+
const md5 = s => crypto.createHash('md5').update(s).digest('hex');
|
|
11
|
+
const loadJSON = (p, fb) => {
|
|
12
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {string} opts.db - base dir of .md files (absolute)
|
|
18
|
+
* @param {string} opts.indexDir - where vectors.json / .hashes.json live
|
|
19
|
+
* @param {string} opts.cacheDir - model cache dir
|
|
20
|
+
* @param {string} opts.modelName - model alias or id
|
|
21
|
+
* @param {string[]} opts.ignore - glob patterns to skip
|
|
22
|
+
* @param {(s:string)=>void} [opts.log]
|
|
23
|
+
*/
|
|
24
|
+
export async function buildIndex(opts) {
|
|
25
|
+
const { db, indexDir, cacheDir, modelName, ignore = [], log = () => {} } = opts;
|
|
26
|
+
const model = resolveModel(modelName);
|
|
27
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
28
|
+
const hashesPath = path.join(indexDir, '.hashes.json');
|
|
29
|
+
|
|
30
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const files = walkMarkdown(db, ignore);
|
|
33
|
+
const oldHashes = loadJSON(hashesPath, {});
|
|
34
|
+
const oldIndex = loadJSON(vectorsPath, { chunks: [], model: null });
|
|
35
|
+
|
|
36
|
+
// If the model changed, all stored vectors are incompatible → full rebuild.
|
|
37
|
+
const modelChanged = oldIndex.model && oldIndex.model !== model.id;
|
|
38
|
+
|
|
39
|
+
const oldByFile = new Map();
|
|
40
|
+
if (!modelChanged) {
|
|
41
|
+
for (const c of oldIndex.chunks) {
|
|
42
|
+
if (!oldByFile.has(c.file)) oldByFile.set(c.file, []);
|
|
43
|
+
oldByFile.get(c.file).push(c);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const newHashes = {};
|
|
48
|
+
const chunks = [];
|
|
49
|
+
const toEmbed = [];
|
|
50
|
+
let reused = 0, changedFiles = 0;
|
|
51
|
+
|
|
52
|
+
for (const abs of files) {
|
|
53
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
54
|
+
const rel = path.relative(db, abs).split(path.sep).join('/');
|
|
55
|
+
const h = md5(raw);
|
|
56
|
+
newHashes[rel] = h;
|
|
57
|
+
|
|
58
|
+
if (!modelChanged && oldHashes[rel] === h && oldByFile.has(rel)) {
|
|
59
|
+
const old = oldByFile.get(rel);
|
|
60
|
+
chunks.push(...old);
|
|
61
|
+
reused += old.length;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
changedFiles++;
|
|
65
|
+
const parsed = parseFile(abs, db);
|
|
66
|
+
for (const c of parsed) { chunks.push(c); toEmbed.push(c); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (toEmbed.length > 0) {
|
|
70
|
+
log(`Embedding ${toEmbed.length} chunks from ${changedFiles} changed file(s) ` +
|
|
71
|
+
`with ${model.id}...`);
|
|
72
|
+
const BATCH = 32;
|
|
73
|
+
for (let i = 0; i < toEmbed.length; i += BATCH) {
|
|
74
|
+
const slice = toEmbed.slice(i, i + BATCH);
|
|
75
|
+
const vecs = await embed(
|
|
76
|
+
slice.map(c => `${c.title}\n${c.heading}\n${c.text}`),
|
|
77
|
+
'passage', model, cacheDir,
|
|
78
|
+
);
|
|
79
|
+
slice.forEach((c, j) => { c.vec = vecs[j]; });
|
|
80
|
+
log(` ${Math.min(i + BATCH, toEmbed.length)}/${toEmbed.length}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dim = chunks[0]?.vec?.length ?? model.dim ?? 0;
|
|
85
|
+
const index = {
|
|
86
|
+
model: model.id,
|
|
87
|
+
modelAlias: modelName || 'e5-base',
|
|
88
|
+
dim,
|
|
89
|
+
db,
|
|
90
|
+
built: new Date().toISOString(),
|
|
91
|
+
chunkCount: chunks.length,
|
|
92
|
+
chunks,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(vectorsPath, JSON.stringify(index));
|
|
96
|
+
fs.writeFileSync(hashesPath, JSON.stringify(newHashes, null, 2));
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
files: files.length,
|
|
100
|
+
chunks: chunks.length,
|
|
101
|
+
reused,
|
|
102
|
+
embedded: toEmbed.length,
|
|
103
|
+
dim,
|
|
104
|
+
model: model.id,
|
|
105
|
+
vectorsPath,
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/models.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding model registry.
|
|
3
|
+
*
|
|
4
|
+
* `prefix` matters: the E5 family is trained with instruction prefixes
|
|
5
|
+
* ("query: " / "passage: ") and ranks poorly without them. BGE-M3 is trained
|
|
6
|
+
* WITHOUT prefixes, so forcing them in would hurt. Each entry declares how to
|
|
7
|
+
* format inputs so the rest of the code stays model-agnostic.
|
|
8
|
+
*/
|
|
9
|
+
export const MODELS = {
|
|
10
|
+
'e5-small': {
|
|
11
|
+
id: 'Xenova/multilingual-e5-small',
|
|
12
|
+
dim: 384,
|
|
13
|
+
queryPrefix: 'query: ',
|
|
14
|
+
passagePrefix: 'passage: ',
|
|
15
|
+
note: 'Fastest, ~120MB. Weak cross-lingual ranking — see RESEARCH.md.',
|
|
16
|
+
},
|
|
17
|
+
'e5-base': {
|
|
18
|
+
id: 'Xenova/multilingual-e5-base',
|
|
19
|
+
dim: 768,
|
|
20
|
+
queryPrefix: 'query: ',
|
|
21
|
+
passagePrefix: 'passage: ',
|
|
22
|
+
note: 'Default. ~280MB. Solid multilingual + cross-lingual balance.',
|
|
23
|
+
},
|
|
24
|
+
'e5-large': {
|
|
25
|
+
id: 'Xenova/multilingual-e5-large',
|
|
26
|
+
dim: 1024,
|
|
27
|
+
queryPrefix: 'query: ',
|
|
28
|
+
passagePrefix: 'passage: ',
|
|
29
|
+
note: '~2.2GB. Higher quality, slower.',
|
|
30
|
+
},
|
|
31
|
+
'bge-m3': {
|
|
32
|
+
id: 'Xenova/bge-m3',
|
|
33
|
+
dim: 1024,
|
|
34
|
+
queryPrefix: '',
|
|
35
|
+
passagePrefix: '',
|
|
36
|
+
note: '~2.3GB. Best cross-lingual separation in our tests; no prefixes.',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_MODEL = 'e5-base';
|
|
41
|
+
|
|
42
|
+
/** Resolve a model alias OR a raw Xenova/... id into a model descriptor. */
|
|
43
|
+
export function resolveModel(name) {
|
|
44
|
+
if (!name) return MODELS[DEFAULT_MODEL];
|
|
45
|
+
if (MODELS[name]) return MODELS[name];
|
|
46
|
+
// Allow passing a raw HF/Xenova id; assume E5-style prefixes unless it's bge.
|
|
47
|
+
const isBge = /bge/i.test(name);
|
|
48
|
+
return {
|
|
49
|
+
id: name,
|
|
50
|
+
dim: 0,
|
|
51
|
+
queryPrefix: isBge ? '' : 'query: ',
|
|
52
|
+
passagePrefix: isBge ? '' : 'passage: ',
|
|
53
|
+
note: 'Custom model id.',
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid semantic + lexical search over a prebuilt index.
|
|
3
|
+
* Ranking = Reciprocal Rank Fusion of cosine similarity (meaning) and
|
|
4
|
+
* term-overlap (exact names). The model is read from the index, so callers
|
|
5
|
+
* never have to repeat --model at search time.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { embed, cosine, resolveModel } from './core.mjs';
|
|
10
|
+
|
|
11
|
+
// Common ru/en function words — they match everywhere and pollute lexical scores.
|
|
12
|
+
const STOP = new Set([
|
|
13
|
+
'the', 'and', 'for', 'are', 'was', 'has', 'with', 'this', 'that', 'from',
|
|
14
|
+
'not', 'but', 'you', 'your', 'can', 'all', 'any', 'its',
|
|
15
|
+
'все', 'как', 'что', 'это', 'при', 'для', 'или', 'был', 'без', 'над',
|
|
16
|
+
'под', 'так', 'его', 'нет', 'код', 'кода', 'есть',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function tokenize(text) {
|
|
20
|
+
const m = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu);
|
|
21
|
+
return m ? m.filter(t => !STOP.has(t)) : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function keywordScores(chunks, query) {
|
|
25
|
+
const qTerms = new Set(tokenize(query));
|
|
26
|
+
return chunks.map(c => {
|
|
27
|
+
const hay = `${c.title} ${c.heading} ${c.text}`.toLowerCase();
|
|
28
|
+
let s = 0;
|
|
29
|
+
for (const t of qTerms) if (hay.includes(t)) s++;
|
|
30
|
+
return s;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reciprocal Rank Fusion. rankings: arrays of {idx, score}; higher = better. */
|
|
35
|
+
function rrf(rankings, k = 60) {
|
|
36
|
+
const fused = new Map();
|
|
37
|
+
for (const ranking of rankings) {
|
|
38
|
+
const sorted = [...ranking].sort((a, b) => b.score - a.score);
|
|
39
|
+
sorted.forEach((item, rank) => {
|
|
40
|
+
if (item.score <= 0) return;
|
|
41
|
+
fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + rank + 1));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return fused;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {string} opts.indexDir
|
|
50
|
+
* @param {string} opts.cacheDir
|
|
51
|
+
* @param {string} opts.query
|
|
52
|
+
* @param {number} [opts.k=6]
|
|
53
|
+
* @param {boolean} [opts.semanticOnly=false]
|
|
54
|
+
* @returns {Promise<Array>} results with file, title, heading, cosine, score, snippet
|
|
55
|
+
*/
|
|
56
|
+
export async function search(opts) {
|
|
57
|
+
const { indexDir, cacheDir, query, k = 6, semanticOnly = false } = opts;
|
|
58
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
59
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
60
|
+
throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
|
|
61
|
+
}
|
|
62
|
+
const index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
|
|
63
|
+
const model = resolveModel(index.modelAlias || index.model);
|
|
64
|
+
|
|
65
|
+
const [qVec] = await embed([query], 'query', model, cacheDir);
|
|
66
|
+
|
|
67
|
+
const semantic = index.chunks.map((c, idx) => ({ idx, score: cosine(qVec, c.vec) }));
|
|
68
|
+
const cosByIdx = new Map(semantic.map(s => [s.idx, s.score]));
|
|
69
|
+
|
|
70
|
+
let ranked;
|
|
71
|
+
if (semanticOnly) {
|
|
72
|
+
ranked = [...semantic].sort((a, b) => b.score - a.score).slice(0, k)
|
|
73
|
+
.map(s => ({ idx: s.idx, fscore: s.score, cos: s.score }));
|
|
74
|
+
} else {
|
|
75
|
+
const kw = keywordScores(index.chunks, query).map((score, idx) => ({ idx, score }));
|
|
76
|
+
const fused = rrf([semantic, kw]);
|
|
77
|
+
ranked = [...fused.entries()]
|
|
78
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
|
|
79
|
+
.sort((a, b) => b.fscore - a.fscore)
|
|
80
|
+
.slice(0, k);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return ranked.map(r => {
|
|
84
|
+
const c = index.chunks[r.idx];
|
|
85
|
+
return {
|
|
86
|
+
file: c.file,
|
|
87
|
+
title: c.title,
|
|
88
|
+
heading: c.heading,
|
|
89
|
+
cosine: +r.cos.toFixed(3),
|
|
90
|
+
score: +r.fscore.toFixed(4),
|
|
91
|
+
snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|