Skip to content

Commit 0d8c3f6

Browse files
author
ellmos-ai Release Snapshot
committed
Filter stopwords and short tokens in files backend search
1 parent a3c279c commit 0d8c3f6

3 files changed

Lines changed: 112 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,21 @@ Future work is tracked against observable acceptance criteria:
1010
- Publish signed artifacts after the release repository and reporting channel
1111
have been verified.
1212

13+
## Follow-ups (2026-07-31)
14+
15+
- Normalize the files-backend ranking (for example `score / max_score` or
16+
length normalization) so that `min_rank` becomes meaningful again; the
17+
current `score / (score + 3)` saturation renders everything above ~300
18+
as 1.00.
19+
- Support individual file paths in the files backend; today only directories
20+
are scanned recursively and an explicit file list is silently ignored.
21+
- Add an FTS5 backend built on the stdlib `sqlite3` module (unicode61
22+
tokenizer, bm25 mapped into (0, 1]); the backend protocol already
23+
anticipates FTS5-bm25 ranking. This addresses stopword inflation, score
24+
saturation and size bias structurally without new dependencies.
25+
- Add a forgetfulness sidecar to the index (`last_accessed` / `access_count`
26+
with decay and a review threshold instead of hard deletion).
27+
- Optionally add an embedding-based hybrid backend that reports
28+
`available() = False` when the optional packages are not installed.
29+
1330
Roadmap items are not promises and may change.

memoryhooker/backends/files.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,45 @@
88

99
from __future__ import annotations
1010

11+
import re
1112
from pathlib import Path
1213

1314
from ..protocol import Hit
1415

1516
_SNIPPET_RADIUS = 120
1617

18+
# Mindestlaenge fuer Suchterme; kuerzere Tokens sind fast immer Rauschen.
19+
_MIN_TERM_LENGTH = 3
20+
21+
# Wortzeichen (inkl. Umlaute) und Bindestrich -- Satzzeichen fallen so
22+
# beim Tokenisieren automatisch weg (".TOPICS," -> "TOPICS").
23+
_TOKEN_PATTERN = re.compile(r"[\w-]+", re.UNICODE)
24+
25+
# Stopwoerter Deutsch + Englisch: Fuellwoerter ohne Eigeninfo. Als Suchterm
26+
# treffen sie per Substr-Match fast jeden Text ("fuer" in "Verfuegung")
27+
# und dominieren das Ranking. Wartbar als Modul-Konstante.
28+
_STOPWORDS = frozenset(
29+
{
30+
# Deutsch
31+
"aber", "alle", "als", "am", "an", "auch", "auf", "aus", "bei",
32+
"beim", "bin", "bis", "da", "dann", "das", "dass", "dem", "den",
33+
"der", "des", "die", "dies", "diese", "dieser", "du", "durch",
34+
"ein", "eine", "einem", "einen", "einer", "er", "es", "für",
35+
"fuer", "habe", "haben", "hat", "ich", "ihm", "ihn", "ihr",
36+
"ihre", "im", "in", "ins", "ist", "kein", "keine", "man", "mein",
37+
"meine", "mit", "nach", "nicht", "noch", "nur", "ob", "oder",
38+
"ohne", "sein", "seine", "sich", "sie", "sind", "so", "über",
39+
"ueber", "um", "und", "uns", "vom", "von", "vor", "war", "was",
40+
"weil", "wenn", "wie", "wir", "wird", "wo", "zu", "zum", "zur",
41+
# Englisch
42+
"the", "and", "for", "with", "this", "that", "from", "have",
43+
"has", "are", "was", "were", "not", "but", "all", "can", "you",
44+
"your", "its", "our", "their", "them", "they", "what", "which",
45+
"when", "where", "how", "then", "there", "here", "into", "only",
46+
"very", "just",
47+
}
48+
)
49+
1750

1851
class FilesBackend:
1952
"""Sucht in ``*.md``-Dateien unterhalb eines oder mehrerer Wurzeln."""
@@ -38,7 +71,7 @@ def search(self, query: str, limit: int = 5) -> list[Hit]:
3871
if not self.available():
3972
return []
4073

41-
terms = [t.lower() for t in query.split() if t.strip()]
74+
terms = _terms_from_query(query)
4275
if not terms:
4376
return []
4477

@@ -81,6 +114,20 @@ def search(self, query: str, limit: int = 5) -> list[Hit]:
81114
return hits[:limit]
82115

83116

117+
def _terms_from_query(query: str) -> list[str]:
118+
"""Normalisiert eine Query zu Suchtermen.
119+
120+
Kleinschreibung, Satzzeichen gestrippt, Mindestlaenge 3, Stopwoerter
121+
raus. Bleibt kein Term uebrig (leere oder reine Fuellwort-Query),
122+
liefert ``search()`` keine Treffer statt Vollrauschen.
123+
"""
124+
return [
125+
term
126+
for term in _TOKEN_PATTERN.findall(query.lower())
127+
if len(term) >= _MIN_TERM_LENGTH and term not in _STOPWORDS
128+
]
129+
130+
84131
def _snippet(text: str, lowered: str, terms: list[str]) -> str:
85132
for term in terms:
86133
idx = lowered.find(term)

tests/test_backends_files.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,50 @@ def test_search_recurses_into_subdirectories(tmp_path: Path):
6262
backend = FilesBackend(tmp_path)
6363
hits = backend.search("gardener")
6464
assert hits and hits[0].source == str(Path("sub") / "nested.md")
65+
66+
67+
def test_search_ignores_stopwords(tmp_path: Path):
68+
# "für" steckt als Substr in "Verfügung"/"dafür": ohne Stopwortfilter
69+
# wuerde die Fuellwort-Query hier faelschlich treffen.
70+
(tmp_path / "a.md").write_text(
71+
"Hinweis zur Verfügung: dafür ist der Maintainer zuständig.", encoding="utf-8"
72+
)
73+
74+
backend = FilesBackend(tmp_path)
75+
assert backend.search("Rezept für Tomatensuppe") == []
76+
77+
78+
def test_search_with_keywords_still_hits(tmp_path: Path):
79+
(tmp_path / "a.md").write_text(
80+
"Tomatensuppe: Zwiebeln anschwitzen, Tomaten dazu.", encoding="utf-8"
81+
)
82+
(tmp_path / "b.md").write_text("Anderes Thema.", encoding="utf-8")
83+
84+
backend = FilesBackend(tmp_path)
85+
hits = backend.search("Rezept für Tomatensuppe")
86+
assert len(hits) == 1
87+
assert hits[0].source == "a.md"
88+
89+
90+
def test_search_stopword_only_query_returns_nothing(tmp_path: Path):
91+
(tmp_path / "a.md").write_text("Für das und mit dem ist alles getan.", encoding="utf-8")
92+
93+
backend = FilesBackend(tmp_path)
94+
assert backend.search("für das und mit") == []
95+
96+
97+
def test_search_ignores_terms_shorter_than_three_chars(tmp_path: Path):
98+
(tmp_path / "a.md").write_text("py py py everywhere", encoding="utf-8")
99+
100+
backend = FilesBackend(tmp_path)
101+
assert backend.search("py") == []
102+
103+
104+
def test_search_strips_punctuation_from_terms(tmp_path: Path):
105+
(tmp_path / "a.md").write_text("Die Datei heisst memoryhooker.toml", encoding="utf-8")
106+
107+
backend = FilesBackend(tmp_path)
108+
# "?" und "." duerfen den Term nicht veraendern: "memoryhooker.toml?"
109+
# als Ganzes kaeme im Text nie vor, die Tokens schon.
110+
hits = backend.search("Was ist memoryhooker.toml?")
111+
assert hits and hits[0].source == "a.md"

0 commit comments

Comments
 (0)