Skip to content

Commit aee124c

Browse files
committed
feat(keyword): add local FTS5 keyword retrieval
Add an opt-in per-account SQLite FTS5 keyword sidecar that provides search-time BM25 recall for deployments without a remote VikingDB full-text index. - grep gains local/vikingdb engine modes and falls back from remote BM25 to the local sidecar to filesystem scan - KeywordQueue/KeywordProcessor keep the sidecar in sync alongside the embedding pipeline, including rm/mv/restore paths - find/search gain an opt-in hybrid mode fusing dense results with keyword candidates via RRF or weighted blend - /api/v1/observer/keyword exposes sidecar health
1 parent 421c73b commit aee124c

34 files changed

Lines changed: 2735 additions & 55 deletions

docs/design/local-keyword-fts5-sidecar-design.md

Lines changed: 352 additions & 0 deletions
Large diffs are not rendered by default.

docs/en/api/06-retrieval.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ OpenViking provides multiple retrieval methods, including simple vector similari
1212
| Default Limit | 10 | 10 |
1313
| Use Case | Simple queries | Conversational search |
1414

15+
> **Local keyword hybrid (experimental config).** When `keyword.enabled` is on
16+
> and the local FTS5 sidecar is built, set `retrieval.hybrid.enabled: true` (or
17+
> pass `hybrid: true` on a request) so `find`/`search` fuse search-time BM25
18+
> candidates with the dense results. This improves exact-token recall for code
19+
> names, acronyms, tickers and version strings. Without a remote VikingDB
20+
> full-text index, `grep` also uses the sidecar for BM25 recall (engine `auto`
21+
> / `local`), falling back to a filesystem scan when the sidecar is missing.
22+
1523
## Retrieval Pipeline
1624

1725
The core retrieval pipeline is as follows:

docs/en/configuration/01-server.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ Optional sections use their defaults when omitted. Unknown fields are rejected.
4848
| `vlm` | object | empty config | Content understanding, summaries, and memory extraction; configure a working model before using these capabilities |
4949
| `query_planner` | object / `null` | `null` | Retrieval intent model; falls back to `vlm` |
5050
| `rerank` | object | disabled | Retrieval result reranking |
51-
| `retrieval` | object | see below | Ranking and intent-analysis behavior |
52-
| `grep` | object | built-in defaults | Text search engine |
51+
| `retrieval` | object | see below | Ranking, intent-analysis, and keyword/dense hybrid fusion |
52+
| `grep` | object | built-in defaults | Text search engine (`auto` / `fs` / `local` / `vikingdb`) |
53+
| `keyword` | object | disabled | Local SQLite FTS5 keyword sidecar for search-time BM25 recall |
5354
| `storage` | object | local | Workspace, file system, and vector database |
5455
| `queue_workers` | object | see below | Runtime concurrency for QueueFS consumer workers |
5556
| `server` | object | local development | HTTP, authentication, uploads, and observability |
@@ -154,7 +155,14 @@ Rerank has no separate `enabled` field. It becomes available when the required p
154155
"retrieval": {
155156
"hotness_alpha": 0,
156157
"score_propagation_alpha": 1,
157-
"enable_intent": true
158+
"enable_intent": true,
159+
"hybrid": {
160+
"enabled": false,
161+
"fusion": "rrf",
162+
"rrf_k": 60,
163+
"keyword_weight": 0.3,
164+
"min_token_query_len": 2
165+
}
158166
}
159167
}
160168
```
@@ -166,9 +174,45 @@ Rerank has no separate `enabled` field. It becomes available when the required p
166174
| `hotness_alpha` | number, `0``1` | `0` | Hotness score weight; `0` disables it |
167175
| `score_propagation_alpha` | number, `0``1` | `1` | Child-result score weight in hierarchical retrieval |
168176
| `enable_intent` | boolean | `true` | Run intent analysis/query planning when `session_id` is present |
177+
| `hybrid` | object | disabled | Keyword/dense fusion for `find`/`search` (see below) |
169178

170179
Search and Find requests default to `limit: 10`; override the limit on each API or SDK request. `retrieval.enable_intent` controls LLM query planning for session-aware Search, while result reranking is enabled only when `rerank` has a usable provider configuration.
171180

181+
When `keyword.enabled` is on and the sidecar is built, set `retrieval.hybrid.enabled: true` to fuse keyword candidates into `find`/`search` results (exact tokens like code names, acronyms, or version strings that dense retrieval handles poorly). `fusion: "rrf"` uses Reciprocal Rank Fusion; `"weighted"` blends normalized BM25 with the dense score using `keyword_weight`. A request may override this with the `hybrid` boolean field.
182+
183+
### `keyword`
184+
185+
```json
186+
{
187+
"keyword": {
188+
"enabled": false,
189+
"tokenizer": "auto",
190+
"content_source": "content",
191+
"max_doc_bytes": 65536,
192+
"cjk_mode": "char",
193+
"respect_encryption": true
194+
}
195+
}
196+
```
197+
198+
| Field | Type / values | Default | Purpose |
199+
|---|---|---|---|
200+
| `enabled` | boolean | `false` | Master switch for the local FTS5 keyword sidecar |
201+
| `tokenizer` | `auto` / `char` / `jieba` | `auto` | CJK tokenization: `auto` uses optional `jieba`, falls back to char splitting |
202+
| `content_source` | `content` / `summary` / `both` | `content` | Text source indexed (currently indexes the same text that gets embedded) |
203+
| `max_doc_bytes` | integer | `65536` | Skip documents whose indexed text exceeds this size |
204+
| `cjk_mode` | `char` / `bigram` | `char` | CJK granularity when a word tokenizer is not used |
205+
| `respect_encryption` | boolean | `true` | Disable the sidecar when at-rest encryption is enabled (plaintext index) |
206+
207+
The keyword sidecar is off by default. When enabled, leaf documents are indexed
208+
asynchronously alongside embedding, and `grep` (engine `auto` or `local`) uses
209+
search-time BM25 recall from the sidecar instead of a full filesystem scan on
210+
deployments without a remote VikingDB full-text index. The sidecar is a recall
211+
accelerator: final `grep` matching still runs against the on-disk content, and
212+
the index falls back to a filesystem scan when missing or incomplete. Sidecar
213+
databases live under `<workspace>/_system/keyword/<account>.sqlite3`. Health is
214+
exposed through `GET /api/v1/observer/keyword`.
215+
172216
## Storage Settings
173217

174218
```json
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
2+
# SPDX-License-Identifier: AGPL-3.0
3+
"""Keyword/dense score fusion for find/search.
4+
5+
The local FTS5 sidecar provides search-time BM25 recall for exact tokens (code
6+
names, acronyms, tickers, version strings) that dense retrieval handles poorly.
7+
This module merges dense ``MatchedContext`` results with keyword candidates
8+
using Reciprocal Rank Fusion (robust, no score calibration) or a weighted blend
9+
of normalized scores.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import replace
15+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple
16+
17+
from openviking_cli.retrieve.types import MatchedContext
18+
19+
ReadAbstract = Callable[[str], Awaitable[str]]
20+
21+
22+
class HybridKeywordRecaller:
23+
"""Fuse keyword-sidecar recall into a dense retrieval result list."""
24+
25+
def __init__(
26+
self,
27+
keyword_fs: Any,
28+
hybrid_config: Any,
29+
keyword_config: Optional[Any] = None,
30+
):
31+
self._keyword_fs = keyword_fs
32+
self._config = hybrid_config
33+
self._keyword_config = keyword_config
34+
35+
def enabled(self, ctx: Any = None) -> bool:
36+
if self._keyword_fs is None or self._config is None:
37+
return False
38+
if not getattr(self._config, "enabled", False):
39+
return False
40+
account_id = getattr(ctx, "account_id", None) or "default"
41+
try:
42+
return self._keyword_fs.is_ready(account_id)
43+
except Exception:
44+
return False
45+
46+
async def enhance(
47+
self,
48+
query: str,
49+
dense: Sequence[MatchedContext],
50+
scope_uris: Sequence[str],
51+
ctx: Any,
52+
limit: int,
53+
exclude_uri: str = "",
54+
read_abstract: Optional[ReadAbstract] = None,
55+
) -> List[MatchedContext]:
56+
"""Merge keyword candidates into ``dense`` and return the fused top ``limit``."""
57+
if not self.enabled(ctx) or not query:
58+
return list(dense)
59+
candidates = await self._recall(query, scope_uris, exclude_uri, ctx, limit)
60+
if not candidates:
61+
return list(dense)
62+
fused = self._fuse(dense, candidates, limit)
63+
# Enrich keyword-only hits with best-effort abstract text.
64+
dense_uris = {m.uri for m in dense}
65+
for mc in fused:
66+
if mc.uri in dense_uris or not mc.abstract:
67+
if read_abstract is not None and not mc.abstract:
68+
try:
69+
mc.abstract = await read_abstract(mc.uri)
70+
except Exception:
71+
pass
72+
return fused
73+
74+
async def _recall(
75+
self,
76+
query: str,
77+
scope_uris: Sequence[str],
78+
exclude_uri: str,
79+
ctx: Any,
80+
limit: int,
81+
) -> List[Tuple[str, float]]:
82+
account_id = getattr(ctx, "account_id", None) or "default"
83+
collected: Dict[str, float] = {}
84+
scopes = list(scope_uris) or [""]
85+
for scope in scopes:
86+
try:
87+
hits = self._keyword_fs.lookup(
88+
account_id=account_id,
89+
query=query,
90+
scope_uri=scope,
91+
exclude_uri=exclude_uri,
92+
limit=max(limit * 3, 30),
93+
)
94+
except Exception:
95+
continue
96+
for uri, score in hits:
97+
# Keep the best (lowest) bm25 score for a URI across scopes.
98+
if uri not in collected or score < collected[uri]:
99+
collected[uri] = score
100+
ranked = sorted(collected.items(), key=lambda x: x[1])
101+
return ranked[: max(limit * 3, 30)]
102+
103+
def _fuse(
104+
self,
105+
dense: Sequence[MatchedContext],
106+
candidates: Sequence[Tuple[str, float]],
107+
limit: int,
108+
) -> List[MatchedContext]:
109+
fusion = getattr(self._config, "fusion", "rrf")
110+
if fusion == "weighted":
111+
return self._fuse_weighted(dense, candidates, limit)
112+
return self._fuse_rrf(dense, candidates, limit)
113+
114+
def _fuse_rrf(
115+
self,
116+
dense: Sequence[MatchedContext],
117+
candidates: Sequence[Tuple[str, float]],
118+
limit: int,
119+
) -> List[MatchedContext]:
120+
k = float(getattr(self._config, "rrf_k", 60.0) or 60.0)
121+
dense_rank = {m.uri: i for i, m in enumerate(dense)}
122+
kw_rank = {uri: i for i, (uri, _s) in enumerate(candidates)}
123+
scores: Dict[str, float] = {}
124+
for uri in dense_rank:
125+
scores[uri] = 1.0 / (k + dense_rank[uri] + 1)
126+
for uri in kw_rank:
127+
scores[uri] = scores.get(uri, 0.0) + 1.0 / (k + kw_rank[uri] + 1)
128+
129+
by_uri = {m.uri: m for m in dense}
130+
ordered_uris = sorted(scores.keys(), key=lambda u: (-scores[u], dense_rank.get(u, 10**9)))
131+
out: List[MatchedContext] = []
132+
for uri in ordered_uris:
133+
mc = by_uri.get(uri)
134+
if mc is None:
135+
mc = self._make_keyword_context(uri, candidates)
136+
out.append(replace(mc, score=scores[uri]))
137+
if len(out) >= limit:
138+
break
139+
# Always keep the dense ordering for ties handled above; append leftover
140+
# dense results only if there is still headroom.
141+
if len(out) < limit:
142+
seen = {m.uri for m in out}
143+
for m in dense:
144+
if m.uri not in seen:
145+
out.append(m)
146+
seen.add(m.uri)
147+
if len(out) >= limit:
148+
break
149+
return out
150+
151+
def _fuse_weighted(
152+
self,
153+
dense: Sequence[MatchedContext],
154+
candidates: Sequence[Tuple[str, float]],
155+
limit: int,
156+
) -> List[MatchedContext]:
157+
w = float(getattr(self._config, "keyword_weight", 0.3) or 0.3)
158+
raw_scores = {uri: score for uri, score in candidates}
159+
if raw_scores:
160+
lo = min(raw_scores.values())
161+
hi = max(raw_scores.values())
162+
else:
163+
lo = hi = 0.0
164+
165+
def norm(uri: str) -> float:
166+
if hi > lo:
167+
return (raw_scores[uri] - lo) / (hi - lo)
168+
return 0.5
169+
170+
by_uri = {m.uri: m for m in dense}
171+
scores: Dict[str, float] = {}
172+
for m in dense:
173+
kw = norm(m.uri) if m.uri in raw_scores else 0.0
174+
scores[m.uri] = (1 - w) * m.score + w * kw
175+
for uri in raw_scores:
176+
if uri not in scores:
177+
scores[uri] = w * norm(uri)
178+
179+
ordered = sorted(scores.keys(), key=lambda u: -scores[u])
180+
out: List[MatchedContext] = []
181+
for uri in ordered:
182+
mc = by_uri.get(uri)
183+
if mc is None:
184+
mc = self._make_keyword_context(uri, candidates)
185+
out.append(replace(mc, score=scores[uri]))
186+
if len(out) >= limit:
187+
break
188+
return out
189+
190+
def _make_keyword_context(self, uri: str, candidates: Sequence[Tuple[str, float]]) -> MatchedContext:
191+
from openviking.core.context import ContextType
192+
from openviking.core.namespace import context_type_for_uri
193+
194+
score = 0.0
195+
for u, s in candidates:
196+
if u == uri:
197+
score = s
198+
break
199+
ctype = context_type_for_uri(uri)
200+
try:
201+
context_type = ContextType(ctype)
202+
except ValueError:
203+
context_type = ContextType.RESOURCE
204+
return MatchedContext(
205+
uri=uri,
206+
context_type=context_type,
207+
level=2,
208+
abstract="",
209+
category="",
210+
score=score,
211+
match_reason="keyword",
212+
)

openviking/server/routers/observer.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ async def observer_retrieval(
9292
return Response(status="ok", result=_component_to_dict(component))
9393

9494

95+
@router.get("/keyword")
96+
async def observer_keyword(
97+
_ctx: RequestContext = Depends(get_request_context),
98+
):
99+
"""Get local keyword (FTS5) sidecar status."""
100+
service = get_service()
101+
component = service.debug.observer.keyword
102+
return Response(status="ok", result=_component_to_dict(component))
103+
104+
95105
@router.get("/filesystem")
96106
async def observer_filesystem(
97107
_ctx: RequestContext = Depends(get_request_context),

openviking/server/routers/search.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ async def find(
316316
filter=effective_filter,
317317
level=_resolve_levels(request.level) or None,
318318
image_url=resolved_image_url,
319+
hybrid=request.hybrid,
319320
),
320321
)
321322
result = execution.result
@@ -428,6 +429,7 @@ async def _search():
428429
filter=effective_filter,
429430
level=_resolve_levels(request.level) or None,
430431
image_url=resolved_image_url,
432+
hybrid=request.hybrid,
431433
)
432434

433435
execution = await run_operation(

0 commit comments

Comments
 (0)