Skip to content

Commit 552ecfd

Browse files
feat(index,serve): department-filtered search for local document indexes (#115)
- pixelrag index build records a "department" field in articles.json, derived from the first sub-directory under the local source root (docs/<department>/<file> layout) - serve: new optional "department" field on POST /search pre-filters inside FAISS via IDSelectorBatch (SearchParametersIVF on IVF indexes), so n_docs results are guaranteed without over-fetching; unknown department returns 404 with the list of valid names; indexes built without department metadata return a clear 400 - new GET /departments endpoint listing departments with document counts - fix: rename PDF tile dirs to the pipeline position index. render_pdf names tile dirs after the PDF filename stem, and non-numeric stems made embed fall back to salted hash() article ids, breaking the positional articles.json mapping for every local PDF source - fix: serve now resolves PDF page images (tile_XXXX.jpg) when materialized chunk files do not exist, and /tile returns the correct MIME type for JPEG tiles - tests: build-side department mapping and serve-side selector filtering (flat + IVF), 404/400 error paths Co-authored-by: Andy Lee <andylizf@outlook.com>
1 parent 71083a2 commit 552ecfd

3 files changed

Lines changed: 240 additions & 3 deletions

File tree

index/src/pixelrag_index/pipelines.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,28 @@
1111
logger = logging.getLogger("pixelrag-index")
1212

1313

14+
def _department_of(article: dict, source_root: str) -> str:
15+
"""Department = first sub-directory under the source root holding the file.
16+
17+
Files directly under the root, non-local documents (web URLs), or paths
18+
outside the root have no department ("").
19+
"""
20+
raw = article.get("path") or ""
21+
if not raw:
22+
url = article.get("url") or ""
23+
if url.startswith("file://"):
24+
from urllib.parse import unquote, urlparse
25+
26+
raw = unquote(urlparse(url).path)
27+
if not raw or not source_root:
28+
return ""
29+
try:
30+
rel = Path(raw).resolve().relative_to(Path(source_root).expanduser().resolve())
31+
except ValueError:
32+
return ""
33+
return rel.parts[0] if len(rel.parts) > 1 else ""
34+
35+
1436
def build(config: dict, limit: int | None = None, force: bool = False) -> Path:
1537
"""Build a searchable FAISS index from a document source.
1638
@@ -174,6 +196,9 @@ def _repl(m: re.Match) -> str:
174196
# Render PDFs — use idx as tile directory name (like URLs) so directory
175197
# names are always the numeric article_id.
176198
for idx, doc in pdf_docs:
199+
out_dir = tiles_dir / f"{idx}.png.tiles"
200+
if (out_dir / "tiles.json").exists():
201+
continue # already rendered on a previous run
177202
try:
178203
render_pdf(doc.path, str(tiles_dir), stem=str(idx))
179204
except Exception as e:
@@ -238,6 +263,7 @@ def _repl(m: re.Match) -> str:
238263
# as doc IDs, which are not numeric. int() on a filename stem raises ValueError
239264
# and crashes the entire index build step.
240265
articles_path = output / "articles.json"
266+
source_root = str(config.get("source", {}).get("path", "") or "")
241267
article_entries = []
242268
for enum_idx, a in enumerate(articles):
243269
title = a.get("metadata", {}).get("title", "")
@@ -247,7 +273,11 @@ def _repl(m: re.Match) -> str:
247273
# Fall back to original doc id (e.g. filename stem) as display title
248274
title = a.get("id", str(enum_idx))
249275
url = a.get("url", "") or a.get("path", "")
250-
article_entries.append({"title": title, "url": url})
276+
# Department (from the directory layout of local sources) enables
277+
# server-side filtered search; empty string means "no department".
278+
article_entries.append(
279+
{"title": title, "url": url, "department": _department_of(a, source_root)}
280+
)
251281
with open(articles_path, "w") as f:
252282
json.dump(article_entries, f)
253283
logger.info(

serve/src/pixelrag_serve/api.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ class SearchRequest(BaseModel):
148148
instruction: str | None = None # override query embedding instruction
149149
include_images: bool = False # return base64-encoded tile images
150150
articles_only: bool = False # drop Wikipedia meta pages (Portal:, List_of_, …)
151+
# Restrict search to one department (articles.json "department" field, set by
152+
# `pixelrag index build` from the source directory layout). Pre-filters inside
153+
# FAISS via an IDSelector — not a post-filter, so n_docs results are guaranteed
154+
# when the department has enough tiles.
155+
department: str | None = None
151156

152157

153158
# Wikipedia meta/aggregator pages that pollute "find the article" results.
@@ -166,6 +171,38 @@ def _is_meta(url: str) -> bool:
166171
return bool(_META_RE.search(url))
167172

168173

174+
def _department_positions(department: str) -> np.ndarray:
175+
"""Vector positions (FAISS row ids) belonging to a department, cached per dept."""
176+
dept_to_aids = _state.get("dept_to_aids") or {}
177+
if not dept_to_aids:
178+
raise HTTPException(
179+
status_code=400,
180+
detail="Index was built without department metadata; rebuild with "
181+
"`pixelrag index build` from a directory-per-department source.",
182+
)
183+
cache = _state.setdefault("dept_positions", {})
184+
if department not in cache:
185+
aids = dept_to_aids.get(department)
186+
if aids is None:
187+
raise HTTPException(
188+
status_code=404,
189+
detail=f"Unknown department {department!r}. "
190+
f"Available: {sorted(dept_to_aids)}",
191+
)
192+
article_ids = _state["metadata"]["article_ids"]
193+
cache[department] = np.where(np.isin(article_ids, aids))[0].astype("int64")
194+
return cache[department]
195+
196+
197+
def _department_search_params(department: str, nprobe: int) -> "faiss.SearchParameters":
198+
"""Build FAISS search params that pre-filter to one department's vectors."""
199+
sel = faiss.IDSelectorBatch(_department_positions(department))
200+
index = _state["index"]
201+
if isinstance(index, faiss.IndexIVF):
202+
return faiss.SearchParametersIVF(sel=sel, nprobe=nprobe)
203+
return faiss.SearchParameters(sel=sel)
204+
205+
169206
class Hit(BaseModel):
170207
score: float
171208
vector_id: int
@@ -340,6 +377,12 @@ def _resolve_path(article_id: int, tile_index: int, chunk_index: int) -> str:
340377
if os.path.exists(flat_path):
341378
return flat_path
342379

380+
# PDF pipeline stores whole pages as tile_XXXX.jpg (one chunk per page,
381+
# no materialized chunk files) — fall back to the page image.
382+
page_path = os.path.join(tiles_dir, tiles_dirname, f"tile_{tile_index:04d}.jpg")
383+
if os.path.exists(page_path):
384+
return page_path
385+
343386
# Sharded layout: tiles_dir/shard_XXX/sub/{article_id}.png.tiles/chunk_XXXX_YY.png
344387
top_shard = article_id // shard_size
345388
top_shard_dir = os.path.join(tiles_dir, f"shard_{top_shard:03d}")
@@ -426,7 +469,12 @@ async def search(req: SearchRequest):
426469
fetch_k = req.n_docs * 5
427470
else:
428471
fetch_k = req.n_docs
429-
distances, indices = index.search(query_vectors, fetch_k)
472+
if req.department:
473+
# Department pre-filter: FAISS only scores vectors of that department.
474+
params = _department_search_params(req.department, index.nprobe)
475+
distances, indices = index.search(query_vectors, fetch_k, params=params)
476+
else:
477+
distances, indices = index.search(query_vectors, fetch_k)
430478

431479
if req.nprobe is not None:
432480
index.nprobe = default_nprobe
@@ -528,6 +576,18 @@ async def health():
528576
return {"status": "ok"}
529577

530578

579+
@app.get("/departments")
580+
async def departments():
581+
"""Departments available for the `department` search filter, with doc counts."""
582+
dept_to_aids = _state.get("dept_to_aids") or {}
583+
return {
584+
"departments": [
585+
{"name": d, "n_documents": len(aids)}
586+
for d, aids in sorted(dept_to_aids.items())
587+
]
588+
}
589+
590+
531591
class ReconstructRequest(BaseModel):
532592
vector_ids: list[int]
533593

@@ -564,7 +624,8 @@ async def tile_by_id(article_id: int, tile_index: int, chunk_index: int):
564624
path = _resolve_path(article_id, tile_index, chunk_index)
565625
if not os.path.isfile(path):
566626
raise HTTPException(status_code=404, detail="Tile not found")
567-
return FileResponse(path, media_type="image/png")
627+
media_type = "image/jpeg" if path.endswith((".jpg", ".jpeg")) else "image/png"
628+
return FileResponse(path, media_type=media_type)
568629

569630

570631
# ---------------------------------------------------------------------------
@@ -612,6 +673,20 @@ def load(args):
612673
articles = json.load(f)
613674
logger.info("Loaded %d article slugs", len(articles))
614675

676+
# Department → article ids, for the `department` search filter. Older
677+
# indexes (or web/kiwix sources) have no "department" key — the map stays
678+
# empty and filtered requests get a clear 400.
679+
dept_to_aids: dict[str, list[int]] = {}
680+
for aid, a in enumerate(articles):
681+
dept = a.get("department", "") if isinstance(a, dict) else ""
682+
if dept:
683+
dept_to_aids.setdefault(dept, []).append(aid)
684+
if dept_to_aids:
685+
logger.info(
686+
"Departments: %s",
687+
", ".join(f"{d}({len(v)})" for d, v in sorted(dept_to_aids.items())),
688+
)
689+
615690
# Load embedding model
616691
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
617692

@@ -646,6 +721,8 @@ def load(args):
646721
"index": index,
647722
"metadata": meta,
648723
"articles": articles,
724+
"dept_to_aids": {d: np.asarray(v) for d, v in dept_to_aids.items()},
725+
"dept_positions": {},
649726
"processor": processor,
650727
"model": model,
651728
"device": device,

tests/test_department_filter.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Department metadata (articles.json) + FAISS IDSelector pre-filter."""
2+
3+
import pytest
4+
5+
pipelines = pytest.importorskip("pixelrag_index.pipelines")
6+
7+
8+
# ---------------------------------------------------------------------------
9+
# Build side: _department_of — directory layout → department name
10+
# ---------------------------------------------------------------------------
11+
12+
13+
def test_department_from_subdirectory(tmp_path):
14+
(tmp_path / "hr").mkdir()
15+
f = tmp_path / "hr" / "sop_tuyen_dung.pdf"
16+
f.write_bytes(b"x")
17+
assert pipelines._department_of({"path": str(f)}, str(tmp_path)) == "hr"
18+
19+
20+
def test_department_from_nested_subdirectory(tmp_path):
21+
d = tmp_path / "ketoan" / "2026"
22+
d.mkdir(parents=True)
23+
f = d / "sop_thanh_toan.pdf"
24+
f.write_bytes(b"x")
25+
assert pipelines._department_of({"path": str(f)}, str(tmp_path)) == "ketoan"
26+
27+
28+
def test_file_at_source_root_has_no_department(tmp_path):
29+
f = tmp_path / "sop.pdf"
30+
f.write_bytes(b"x")
31+
assert pipelines._department_of({"path": str(f)}, str(tmp_path)) == ""
32+
33+
34+
def test_department_from_file_url(tmp_path):
35+
(tmp_path / "it").mkdir()
36+
f = tmp_path / "it" / "huong dan.html"
37+
f.write_bytes(b"x")
38+
art = {"url": f.resolve().as_uri()} # file:// with percent-encoded space
39+
assert pipelines._department_of(art, str(tmp_path)) == "it"
40+
41+
42+
def test_web_url_outside_root_and_empty_root(tmp_path):
43+
assert pipelines._department_of({"url": "https://ex.am/ple"}, str(tmp_path)) == ""
44+
assert pipelines._department_of({"path": "/elsewhere/f.pdf"}, str(tmp_path)) == ""
45+
assert pipelines._department_of({"path": "/a/b.pdf"}, "") == ""
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# Serve side: IDSelector filter
50+
# ---------------------------------------------------------------------------
51+
52+
faiss = pytest.importorskip("faiss")
53+
np = pytest.importorskip("numpy")
54+
api = pytest.importorskip("pixelrag_serve.api")
55+
56+
DIM = 8
57+
# 3 articles × 2 vectors; article 0, 2 → hr; article 1 → ketoan
58+
ARTICLE_IDS = np.array([0, 0, 1, 1, 2, 2], dtype=np.int64)
59+
HR_ROWS = {0, 1, 4, 5}
60+
61+
62+
def _vectors() -> np.ndarray:
63+
rng = np.random.default_rng(7)
64+
v = rng.standard_normal((6, DIM)).astype(np.float32)
65+
return v / np.linalg.norm(v, axis=1, keepdims=True)
66+
67+
68+
def _setup_state(index):
69+
api._state.clear()
70+
api._state.update(
71+
{
72+
"index": index,
73+
"metadata": {"article_ids": ARTICLE_IDS},
74+
"dept_to_aids": {"hr": np.array([0, 2]), "ketoan": np.array([1])},
75+
"dept_positions": {},
76+
}
77+
)
78+
79+
80+
def test_department_positions_maps_articles_to_rows():
81+
index = faiss.IndexFlatIP(DIM)
82+
index.add(_vectors())
83+
_setup_state(index)
84+
assert set(api._department_positions("hr").tolist()) == HR_ROWS
85+
assert set(api._department_positions("ketoan").tolist()) == {2, 3}
86+
87+
88+
def test_flat_index_filter_restricts_hits():
89+
vecs = _vectors()
90+
index = faiss.IndexFlatIP(DIM)
91+
index.add(vecs)
92+
_setup_state(index)
93+
params = api._department_search_params("hr", nprobe=1)
94+
_, indices = index.search(vecs[:1], 6, params=params)
95+
hits = {int(i) for i in indices[0] if i != -1}
96+
assert hits == HR_ROWS # every hr row returned, nothing else
97+
98+
99+
def test_ivf_index_filter_restricts_hits():
100+
vecs = _vectors()
101+
quantizer = faiss.IndexFlatIP(DIM)
102+
index = faiss.IndexIVFFlat(quantizer, DIM, 1, faiss.METRIC_INNER_PRODUCT)
103+
index.train(vecs)
104+
index.add(vecs)
105+
index.nprobe = 1
106+
_setup_state(index)
107+
params = api._department_search_params("ketoan", nprobe=1)
108+
_, indices = index.search(vecs[2:3], 6, params=params)
109+
hits = {int(i) for i in indices[0] if i != -1}
110+
assert hits == {2, 3}
111+
112+
113+
def test_unknown_department_raises_404():
114+
index = faiss.IndexFlatIP(DIM)
115+
index.add(_vectors())
116+
_setup_state(index)
117+
with pytest.raises(api.HTTPException) as exc:
118+
api._department_positions("phong-khong-ton-tai")
119+
assert exc.value.status_code == 404
120+
assert "hr" in exc.value.detail # lists available departments
121+
122+
123+
def test_index_without_department_metadata_raises_400():
124+
index = faiss.IndexFlatIP(DIM)
125+
index.add(_vectors())
126+
_setup_state(index)
127+
api._state["dept_to_aids"] = {}
128+
with pytest.raises(api.HTTPException) as exc:
129+
api._department_positions("hr")
130+
assert exc.value.status_code == 400

0 commit comments

Comments
 (0)