-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrag_engine.py
More file actions
1645 lines (1460 loc) · 64.6 KB
/
Copy pathrag_engine.py
File metadata and controls
1645 lines (1460 loc) · 64.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
RAG (Retrieval-Augmented Generation) Engine for FoxAI Studio.
This module handles the RAG pipeline:
1. PROCESSING: Load various file formats (PDF, DOCX, Code, ZIM)
2. CHUNKING: Split documents into manageable chunks
3. EMBEDDING: Convert text chunks into vector representations
4. INDEXING: Store vectors in FAISS for fast similarity search
5. RETRIEVAL: Find relevant chunks given a user query
KEY CONCEPTS FOR LEARNING:
- Embeddings: Numerical vector representations of text. Similar texts have similar vectors.
- FAISS: Facebook AI Search Similarity - fast nearest-neighbor search on vectors
- Chunking: Splitting large documents into smaller pieces for better retrieval
- L2 Distance: How we measure "closeness" of vectors in embedding space
USAGE:
from rag_engine import RAGEngine
engine = RAGEngine()
engine.ingest_documents(["path/to/file.pdf", "path/to/code.py"])
context = engine.query("What is this project about?")
"""
import os
import glob
import json
import hashlib
import time
import numpy as np
from typing import List, Dict, Any, Optional
from rag import chunk_text as shared_chunk_text
from rag.query_service import build_context_block
from rag.state_store import build_file_state, content_hash_for_path
# FAISS for vector similarity search
try:
import faiss
HAS_FAISS = True
except ImportError:
faiss = None
HAS_FAISS = False
print("Warning: faiss not installed. Run: pip install faiss-cpu")
SentenceTransformer = None
HAS_SENTENCE_TRANSFORMERS = False
# PDF processing library - extracts text from PDF files
try:
import fitz # PyMuPDF - reads PDFs and extracts text/images
HAS_PYMUPDF = True
except ImportError:
HAS_PYMUPDF = False
print("Warning: PyMuPDF not installed. Run: pip install pymupdf")
# DOCX processing library - extracts text from Word documents
try:
import docx # python-docx - reads .docx files
HAS_DOCX = True
except ImportError:
HAS_DOCX = False
print("Warning: python-docx not installed. Run: pip install python-docx")
# Image OCR (optional) - extracts text from images (JPG/PNG/etc.)
try:
from PIL import Image # pillow
HAS_PIL = True
except ImportError:
Image = None
HAS_PIL = False
print("Warning: pillow not installed. Run: pip install pillow")
try:
import pytesseract # requires system tesseract installed
HAS_TESSERACT = True
except ImportError:
pytesseract = None
HAS_TESSERACT = False
print("Warning: pytesseract not installed. Run: pip install pytesseract (also requires 'tesseract' installed on your OS)")
try:
# Centralized path handling + override support (LOKUMAI_HOME / LOKUMAI_RAG_DIR)
from lokum_paths import rag_dir as _rag_dir, ensure_dir as _ensure_dir # type: ignore
DEFAULT_RAG_DIR = str(_ensure_dir(_rag_dir()))
except Exception:
# Fallback (kept for robustness in case lokum_paths is missing)
DEFAULT_RAG_DIR = os.path.join(os.path.expanduser("~"), ".lokumai", "rag")
DEFAULT_INDEX_NAME = "faiss_index.bin"
DEFAULT_DOCS_NAME = "docs_metadata.npy"
DEFAULT_META_NAME = "rag_meta.json"
DEFAULT_CHUNKS_META_NAME = "chunks_meta.npy"
DEFAULT_STATE_NAME = "rag_state.json"
DEFAULT_STAGING_DIRNAME = "staging"
DEFAULT_CHUNK_SIZE = 800
DEFAULT_CHUNK_OVERLAP = 100
# ============================================================================
# RAG ENGINE CLASS
# ============================================================================
class RAGEngine:
"""
Main RAG engine class. Handles:
- Loading files in various formats
- Chunking text into manageable pieces
- Creating embeddings using sentence-transformers
- Storing and searching vectors using FAISS
STREAMLINED VERSION WITHOUT LANGCHAIN:
We use raw FAISS + sentence-transformers instead of Langchain because:
- Fewer dependencies to manage
- More control over the pipeline
- Faster for our specific use case
"""
def __init__(self, storage_dir: str | None = None):
# Check if we have all required dependencies
global SentenceTransformer, HAS_SENTENCE_TRANSFORMERS
if not HAS_SENTENCE_TRANSFORMERS:
try:
from sentence_transformers import SentenceTransformer as _SentenceTransformer
SentenceTransformer = _SentenceTransformer
HAS_SENTENCE_TRANSFORMERS = True
except Exception as e:
HAS_SENTENCE_TRANSFORMERS = False
SentenceTransformer = None
print(f"Warning: sentence-transformers not available ({e}). Install: pip install sentence-transformers")
self.enabled = bool(HAS_SENTENCE_TRANSFORMERS and HAS_FAISS)
if not self.enabled:
return
self.storage_dir = os.path.abspath(storage_dir or DEFAULT_RAG_DIR)
os.makedirs(self.storage_dir, exist_ok=True)
self.index_path = os.path.join(self.storage_dir, DEFAULT_INDEX_NAME)
self.docs_path = os.path.join(self.storage_dir, DEFAULT_DOCS_NAME)
self.meta_path = os.path.join(self.storage_dir, DEFAULT_META_NAME)
self.chunks_meta_path = os.path.join(self.storage_dir, DEFAULT_CHUNKS_META_NAME)
self.state_path = os.path.join(self.storage_dir, DEFAULT_STATE_NAME)
self.staging_dir = os.path.join(self.storage_dir, DEFAULT_STAGING_DIRNAME)
os.makedirs(self.staging_dir, exist_ok=True)
self.indexed_folder: str = ""
self.embed_device = self._select_embed_device()
self.embed_batch_size = self._select_embed_batch_size(self.embed_device)
# Load the embedding model
# This model is downloaded on first use (~90MB) and cached
# 'all-MiniLM-L6-v2' creates 384-dimensional vectors
# It maps any text to a point in 384D space where similar texts are close
try:
self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2', device=self.embed_device)
except TypeError:
self.embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
try:
if hasattr(self.embedding_model, "to"):
self.embedding_model.to(self.embed_device)
except Exception:
pass
# FAISS index - will be initialized when we have vectors
# None means no index loaded yet
self.index: Optional[faiss.Index] = None
# Store original text chunks so we can return them on query
# This list is parallel to the FAISS index:
# - self.documents[0] corresponds to vector at index 0 in FAISS
self.documents: List[str] = []
self.chunk_meta: List[Dict[str, Any]] = []
self.state: Dict[str, Any] = {"version": 1, "files": {}}
self.last_error: str = ""
self._abort = False
# Load existing index if available (persistent across restarts)
self.load_index()
self._load_state()
self._validate_or_quarantine_existing_store()
def _select_embed_device(self) -> str:
val = (os.environ.get("LOKUMAI_EMBED_DEVICE") or "").strip().lower()
if val in ("cpu", "mps"):
return val
try:
import torch # type: ignore
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
def _select_embed_batch_size(self, device: str) -> int:
raw = (os.environ.get("LOKUMAI_EMBED_BATCH") or "").strip()
if raw:
try:
v = int(raw)
if 1 <= v <= 2048:
return v
except Exception:
pass
if device == "mps":
return 256
return 32
def _checkpoint_policy(self) -> tuple[int, float]:
raw_chunks = (os.environ.get("LOKUMAI_RAG_CHECKPOINT_CHUNKS") or "").strip()
raw_secs = (os.environ.get("LOKUMAI_RAG_CHECKPOINT_SECS") or "").strip()
chunks_default = 20000 if getattr(self, "embed_device", "cpu") == "mps" else 5000
secs_default = 120.0 if getattr(self, "embed_device", "cpu") == "mps" else 30.0
chunks = chunks_default
secs = secs_default
if raw_chunks:
try:
v = int(raw_chunks)
if 100 <= v <= 500000:
chunks = v
except Exception:
pass
if raw_secs:
try:
v = float(raw_secs)
if 1.0 <= v <= 3600.0:
secs = v
except Exception:
pass
return int(chunks), float(secs)
def request_abort(self) -> None:
try:
self._abort = True
except Exception:
pass
def clear_abort(self) -> None:
try:
self._abort = False
except Exception:
pass
def _check_abort(self) -> None:
if bool(getattr(self, "_abort", False)):
raise RuntimeError("RAG operation aborted")
def _file_id_for(self, path: str) -> str:
p = os.path.abspath(path or "")
return hashlib.sha256(p.encode("utf-8", errors="ignore")).hexdigest()
def mark_deleted(self, source_path: str, deleted: bool = True) -> bool:
self._load_state()
if not isinstance(self.state, dict) or not isinstance(self.state.get("files"), dict):
self.state = {"version": 1, "files": {}}
p = os.path.abspath(source_path or "")
if not p:
return False
fid = self._file_id_for(p)
rec = self.state["files"].get(fid)
if not isinstance(rec, dict):
rec = {"source_path": p}
self.state["files"][fid] = rec
rec["deleted"] = bool(deleted)
rec["deleted_at"] = time.time() if deleted else None
try:
self._atomic_write_json(self.state_path, self.state)
except Exception:
pass
return True
def _is_file_deleted(self, file_id: str) -> bool:
try:
files = (self.state or {}).get("files") if isinstance(self.state, dict) else None
rec = files.get(file_id) if isinstance(files, dict) else None
return bool(isinstance(rec, dict) and rec.get("deleted"))
except Exception:
return False
def _set_last_error(self, msg: str) -> None:
try:
self.last_error = (msg or "").strip()
except Exception:
pass
def _load_state(self) -> None:
try:
if os.path.exists(self.state_path):
with open(self.state_path, "r", encoding="utf-8") as f:
obj = json.load(f)
if isinstance(obj, dict):
files = obj.get("files")
if isinstance(files, dict):
self.state = obj
return
except Exception:
pass
self.state = {"version": 1, "files": {}}
def _atomic_write_json(self, path: str, obj: Any) -> None:
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _atomic_write_npy(self, path: str, arr: Any) -> None:
tmp_base = f"{path}.tmp"
np.save(tmp_base, arr)
tmp_path = tmp_base if tmp_base.endswith(".npy") else f"{tmp_base}.npy"
if not os.path.exists(tmp_path) and os.path.exists(tmp_base):
tmp_path = tmp_base
os.replace(tmp_path, path)
def _atomic_write_faiss(self, path: str, index_obj: Any) -> None:
tmp_path = f"{path}.tmp"
faiss.write_index(index_obj, tmp_path)
os.replace(tmp_path, path)
def validate_store(self) -> Dict[str, Any]:
ok = True
problems: List[str] = []
if self.index is None:
if self.documents or self.chunk_meta:
ok = False
problems.append("Index missing but documents/meta are not empty.")
else:
try:
ntotal = int(getattr(self.index, "ntotal", 0))
except Exception:
ntotal = 0
if ntotal != len(self.documents):
ok = False
problems.append(f"FAISS ntotal={ntotal} does not match documents={len(self.documents)}.")
if self.chunk_meta and len(self.chunk_meta) != len(self.documents):
ok = False
problems.append(f"chunks_meta={len(self.chunk_meta)} does not match documents={len(self.documents)}.")
st = self.state if isinstance(self.state, dict) else {}
files = st.get("files") if isinstance(st, dict) else None
if isinstance(files, dict) and self.documents:
for fid, rec in list(files.items())[:5000]:
if not isinstance(rec, dict):
continue
cs = rec.get("chunk_start")
ce = rec.get("chunk_end")
if cs is None or ce is None:
continue
try:
cs_i = int(cs)
ce_i = int(ce)
except Exception:
ok = False
problems.append(f"Invalid chunk range for {fid}.")
continue
if cs_i < 0 or ce_i < cs_i or ce_i > len(self.documents):
ok = False
problems.append(f"Out-of-bounds chunk range for {fid}: [{cs_i},{ce_i}).")
return {"ok": ok, "problems": problems}
def _quarantine_store_files(self, reason: str) -> None:
ts = time.strftime("%Y%m%d-%H%M%S")
suffix = f".corrupt.{ts}"
for p in (self.index_path, self.docs_path, self.chunks_meta_path, self.meta_path, self.state_path):
try:
if os.path.exists(p):
os.replace(p, p + suffix)
except Exception:
pass
self.index = None
self.documents = []
self.chunk_meta = []
self.indexed_folder = ""
self.state = {"version": 1, "files": {}}
self._set_last_error(f"RAG store was quarantined: {reason}")
def _validate_or_quarantine_existing_store(self) -> None:
try:
res = self.validate_store()
if not res.get("ok", True):
self._quarantine_store_files(" | ".join(res.get("problems") or [])[:300])
except Exception:
pass
def load_index(self) -> None:
"""
Load previously saved FAISS index from disk.
HOW IT WORKS:
- FAISS index contains all the vectors for our documents
- numpy file contains the original text (needed to return context)
- Both files must exist and match for valid loading
WHY PERSIST?
- Creating embeddings is slow (one forward pass per chunk)
- We only need to do it once, then reuse the index
"""
meta_folder = ""
try:
if os.path.exists(self.meta_path):
with open(self.meta_path, "r", encoding="utf-8") as f:
meta = json.load(f) or {}
meta_folder = str(meta.get("folder") or "").strip()
except Exception:
meta_folder = ""
if os.path.exists(self.index_path) and os.path.exists(self.docs_path):
try:
# Read FAISS index from binary file
self.index = faiss.read_index(self.index_path)
# Read document chunks from numpy file
# allow_pickle=True needed because numpy can't store plain lists
self.documents = np.load(self.docs_path, allow_pickle=True).tolist()
if os.path.exists(self.chunks_meta_path):
try:
self.chunk_meta = np.load(self.chunks_meta_path, allow_pickle=True).tolist()
except Exception:
self.chunk_meta = []
self.indexed_folder = meta_folder
print(f"[RAG] Loaded index with {len(self.documents)} chunks.")
except Exception as e:
# If loading fails, do NOT silently "look empty" while the broken files
# remain in place forever. Quarantine them so the user can recover them,
# and surface a clear error.
self._quarantine_store_files(f"load_index failed: {e}")
print(f"[RAG] Error loading index: {e}")
def save_index(self) -> None:
"""
Save current FAISS index and documents to disk.
FILES CREATED:
- faiss_index.bin: Binary FAISS index file
- docs_metadata.npy: NumPy file with original text chunks
NOTE: This overwrites any existing index!
Call this after adding new documents to persist them.
"""
if self.index is not None:
# Write FAISS index to binary file
self._atomic_write_faiss(self.index_path, self.index)
# Save document chunks as numpy array
# dtype=object needed for list of strings
self._atomic_write_npy(self.docs_path, np.array(self.documents, dtype=object))
if self.chunk_meta:
self._atomic_write_npy(self.chunks_meta_path, np.array(self.chunk_meta, dtype=object))
try:
self._atomic_write_json(self.meta_path, {"folder": self.indexed_folder})
except Exception:
pass
try:
if isinstance(self.state, dict):
self._atomic_write_json(self.state_path, self.state)
except Exception:
pass
print(f"[RAG] Saved {len(self.documents)} chunks to index.")
def chunk_text(
self,
text: str,
chunk_size: int = DEFAULT_CHUNK_SIZE,
overlap: int = DEFAULT_CHUNK_OVERLAP
) -> List[str]:
"""
Split long text into overlapping chunks.
ARGS:
text: The full text to chunk
chunk_size: Target size of each chunk (in characters)
overlap: How many characters to overlap between chunks
RETURNS:
List of text chunks
WHY OVERLAP?
- Ensures context isn't cut mid-sentence
- If a concept spans two chunks, overlap helps capture it
EXAMPLE:
chunk_text("Hello world test", chunk_size=6, overlap=2)
-> ["Hello world", "world test"]
"""
chunk_size = max(1, int(chunk_size))
overlap = max(0, int(overlap))
if overlap >= chunk_size:
overlap = max(0, chunk_size // 4)
return [chunk.text for chunk in shared_chunk_text(text, chunk_size=chunk_size, overlap=overlap)]
def _extract_content(self, file_path: str) -> str:
try:
ext = os.path.splitext(file_path)[1].lower()
if ext == '.pdf':
return self.extract_from_pdf(file_path)
if ext in ['.docx', '.doc']:
return self.extract_from_docx(file_path)
if ext == '.zim':
return self.extract_from_zim(file_path)
if ext in ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tif', '.tiff']:
return self.extract_from_image(file_path)
if ext in [
'.py', '.cpp', '.c', '.h', '.hpp', '.js', '.ts',
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.txt', '.md', '.markdown', '.rst',
'.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg',
'.sh', '.bash', '.zsh', '.csh', '.ps1',
'.r', '.java', '.kt', '.swift', '.go', '.rs', '.rb',
'.php', '.pl', '.pm', '.lua', '.scala', '.clj', '.ex', '.exs',
'.sql', '.graphql', '.gql',
'.vim', '.editorconfig', '.gitignore', '.dockerfile',
'.makefile', '.cmake',
]:
return self.extract_from_code(file_path)
return self.extract_from_code(file_path)
except Exception as e:
self._set_last_error(f"Error processing {file_path}: {e}")
print(f"[RAG] Error processing {file_path}: {e}")
return ""
# =========================================================================
# FILE FORMAT HANDLERS
# Each format needs its own extraction method
# =========================================================================
def extract_from_pdf(self, file_path: str) -> str:
"""
Extract all text from a PDF file.
HOW PDFs WORK:
- PDFs store text as positioned characters or as embedded images
- PyMuPDF (fitz) extracts visible text, ignoring formatting
- Images containing text (scanned PDFs) won't be extracted
ARGS:
file_path: Path to .pdf file
RETURNS:
Extracted text as string, or empty string if failed
"""
if not HAS_PYMUPDF:
return ""
try:
text_parts = []
# Open PDF with PyMuPDF
doc = fitz.open(file_path)
# Iterate through each page
for page_num, page in enumerate(doc):
# Get text from this page
page_text = page.get_text()
text_parts.append(page_text)
doc.close() # Always close the document
# Join all pages with page break markers
full_text = "\n\n--- Page Break ---\n\n".join(text_parts)
return full_text
except Exception as e:
print(f"[RAG] PDF extraction error for {file_path}: {e}")
return ""
def extract_from_docx(self, file_path: str) -> str:
"""
Extract all text from a Word (.docx) document.
HOW DOCX WORKS:
- DOCX is a ZIP archive containing XML files
- python-docx parses the XML to extract paragraphs
- It ignores images, formatting (mostly), and some complex elements
ARGS:
file_path: Path to .docx file
RETURNS:
Extracted text as string, or empty string if failed
"""
if not HAS_DOCX:
return ""
try:
doc = docx.Document(file_path)
paragraphs = []
# Each paragraph is a text element in Word
for para in doc.paragraphs:
text = para.text.strip()
if text:
paragraphs.append(text)
# Join paragraphs with double newlines (like in the original doc)
return "\n\n".join(paragraphs)
except Exception as e:
print(f"[RAG] DOCX extraction error for {file_path}: {e}")
return ""
def extract_from_zim(self, file_path: str) -> str:
"""
Extract text content from a ZIM archive.
WHAT IS ZIM?
- ZIM is a format for storing Wikipedia and other offline content
- Used by Kiwix to create offline knowledge bases
- Contains article text, images, and metadata
HOW IT WORKS:
- ZIM files are organized as article entries
- Each entry has a URL, title, and content
- We iterate through entries and extract text
NOTE: This requires the 'zim' library. If not available, returns a
placeholder message. See: https://github.com/openzim/python-zim
ARGS:
file_path: Path to .zim file
RETURNS:
Extracted text or placeholder message
"""
text_parts = []
try:
pyzim_err = None
pyzim_mod = None
try:
import pyzim as _pyzim # published on PyPI as python-zim
pyzim_mod = _pyzim
except Exception as e:
pyzim_err = str(e)
libzim_err = None
LibZimArchive = None
try:
from libzim.reader import Archive as _LibZimArchive
LibZimArchive = _LibZimArchive
except Exception as e:
libzim_err = str(e)
if LibZimArchive is not None:
try:
zf = LibZimArchive(file_path)
scanned = 0
kept = 0
skipped_ns = 0
skipped_nontext = 0
skipped_resource = 0
read_fail = 0
def is_article_entry(entry) -> bool:
ns = getattr(entry, "namespace", None)
if isinstance(ns, str) and ns:
return ns.upper() in ("A", "C")
return True
it = None
try:
if hasattr(zf, "iterByPath"):
it = zf.iterByPath()
elif hasattr(zf, "iter_by_path"):
it = zf.iter_by_path()
elif hasattr(zf, "iterByUrl"):
it = zf.iterByUrl()
elif hasattr(zf, "iter_by_url"):
it = zf.iter_by_url()
elif hasattr(zf, "iter_entries"):
it = zf.iter_entries()
elif hasattr(zf, "entries"):
it = getattr(zf, "entries")
if callable(it):
it = it()
if it is not None:
iter(it)
except Exception:
it = None
def iter_entries():
nonlocal scanned, read_fail
if it is not None:
try:
it_iter = iter(it)
first = next(it_iter, None)
if first is not None:
yield first
for entry in it_iter:
yield entry
return
except Exception:
pass
n = getattr(zf, "entry_count", None)
if callable(n):
n = n()
if not isinstance(n, int):
n = getattr(zf, "article_count", None)
if callable(n):
n = n()
if not isinstance(n, int):
n = 0
max_scan = min(max(500, n), 20000) if n > 0 else 20000
for i in range(max_scan):
try:
entry = None
getter = None
for name in (
"get_entry_by_id",
"getEntryById",
"_get_entry_by_id",
"get_entry",
"getEntry",
"get_article_by_id",
"getArticleById",
"get_article",
"getArticle",
):
if hasattr(zf, name):
getter = getattr(zf, name)
break
if callable(getter):
entry = getter(i)
if entry is None:
continue
yield entry
except Exception:
read_fail += 1
continue
for entry in iter_entries():
try:
scanned += 1
title = (getattr(entry, "title", "") or "").strip()
if not title:
continue
if not is_article_entry(entry):
skipped_ns += 1
continue
mimetype = (getattr(entry, "mimetype", "") or "").lower()
if title.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp")):
skipped_resource += 1
continue
if mimetype and not mimetype.startswith("text/") and "xml" not in mimetype and "html" not in mimetype:
skipped_nontext += 1
continue
content = ""
try:
if hasattr(entry, "read"):
raw = entry.read()
if isinstance(raw, bytes):
content = raw.decode("utf-8", errors="ignore").strip()
else:
content = str(raw or "").strip()
else:
item = entry.get_item() if hasattr(entry, "get_item") else None
raw = bytes(item.content) if (item is not None and hasattr(item, "content")) else b""
content = raw.decode("utf-8", errors="ignore").strip()
except Exception:
read_fail += 1
content = ""
if content:
text_parts.append(f"## {title}\n\n{content}")
kept += 1
if len(text_parts) >= 200:
break
except Exception:
read_fail += 1
continue
if not text_parts and scanned == 0 and hasattr(zf, "get_random_entry"):
try:
seen = set()
for _ in range(5000):
try:
entry = zf.get_random_entry()
except Exception:
read_fail += 1
continue
if entry is None:
continue
scanned += 1
title = (getattr(entry, "title", "") or "").strip()
if not title:
continue
path_val = getattr(entry, "path", None)
if isinstance(path_val, str) and path_val:
if path_val in seen:
continue
seen.add(path_val)
if not is_article_entry(entry):
skipped_ns += 1
continue
mimetype = (getattr(entry, "mimetype", "") or "").lower()
if title.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp")):
skipped_resource += 1
continue
if mimetype and not mimetype.startswith("text/") and "xml" not in mimetype and "html" not in mimetype:
skipped_nontext += 1
continue
content = ""
try:
if hasattr(entry, "read"):
raw = entry.read()
if isinstance(raw, bytes):
content = raw.decode("utf-8", errors="ignore").strip()
else:
content = str(raw or "").strip()
else:
item = entry.get_item() if hasattr(entry, "get_item") else None
raw = bytes(item.content) if (item is not None and hasattr(item, "content")) else b""
content = raw.decode("utf-8", errors="ignore").strip()
except Exception:
read_fail += 1
content = ""
if content:
text_parts.append(f"## {title}\n\n{content}")
kept += 1
if len(text_parts) >= 200:
break
except Exception:
pass
out = "\n\n".join(text_parts).strip()
if out:
return out
ec = None
ac = None
aec = None
try:
ec = getattr(zf, "entry_count", None)
if callable(ec):
ec = ec()
except Exception:
ec = None
try:
ac = getattr(zf, "article_count", None)
if callable(ac):
ac = ac()
except Exception:
ac = None
try:
aec = getattr(zf, "all_entry_count", None)
if callable(aec):
aec = aec()
except Exception:
aec = None
self._set_last_error(
f"ZIM (libzim) extracted 0 text entries (scanned={scanned}, skipped_ns={skipped_ns}, skipped_nontext={skipped_nontext}, skipped_resource={skipped_resource}, read_fail={read_fail}, entry_count={ec}, article_count={ac}, all_entry_count={aec})."
)
return ""
except Exception as e:
self._set_last_error(f"ZIM (libzim) read failed: {e}")
return ""
if pyzim_mod is not None:
try:
with pyzim_mod.Zim.open(file_path) as zf:
it = None
if hasattr(zf, "iter_entries"):
it = zf.iter_entries()
elif hasattr(zf, "iter_content_entries"):
it = zf.iter_content_entries()
elif hasattr(zf, "entries"):
it = getattr(zf, "entries")
if it is None:
self._set_last_error("ZIM (pyzim) could not iterate entries (unsupported API).")
return ""
scanned = 0
kept = 0
skipped_ns = 0
skipped_nontext = 0
skipped_resource = 0
read_fail = 0
def is_article_entry(entry) -> bool:
ns = getattr(entry, "namespace", None)
if isinstance(ns, str) and ns:
return ns.upper() in ("A", "C")
return True
for entry in it:
scanned += 1
title = (getattr(entry, "title", "") or "").strip()
mimetype = (getattr(entry, "mimetype", "") or "").lower()
if not title:
continue
if not is_article_entry(entry):
skipped_ns += 1
continue
if title.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp")):
skipped_resource += 1
continue
if mimetype and not mimetype.startswith("text/"):
skipped_nontext += 1
continue
try:
content = entry.read()
if isinstance(content, bytes):
content = content.decode("utf-8", errors="ignore")
content = (content or "").strip()
except Exception:
read_fail += 1
content = ""
if content:
text_parts.append(f"## {title}\n\n{content}")
kept += 1
if len(text_parts) >= 200:
break
out = "\n\n".join(text_parts).strip()
if out:
return out
self._set_last_error(
f"ZIM (pyzim) extracted 0 text entries (scanned={scanned}, skipped_ns={skipped_ns}, skipped_nontext={skipped_nontext}, skipped_resource={skipped_resource}, read_fail={read_fail})."
)
return ""
except Exception as e:
self._set_last_error(f"ZIM (pyzim) read failed: {e}")
return ""
detail = []
if libzim_err:
detail.append(f"libzim import error: {libzim_err}")
if pyzim_err:
detail.append(f"pyzim import error: {pyzim_err}")
msg = "ZIM support not available. Install: pip install libzim OR pip install 'python-zim[all]'."
if detail:
msg += " " + " | ".join(detail)
self._set_last_error(msg)
return ""
except Exception as e:
self._set_last_error(f"ZIM extraction error: {e}")
print(f"[RAG] ZIM extraction error for {file_path}: {e}")
return ""
def extract_from_image(self, file_path: str) -> str:
"""
Extract text from images using OCR.
Supported formats:
- .jpg, .jpeg, .png, .webp, .bmp, .tif, .tiff
Requirements:
- pip install pillow pytesseract
- Install 'tesseract' on the system (macOS: brew install tesseract)
"""
if not (HAS_PIL and HAS_TESSERACT):
return ""
try:
img = Image.open(file_path)
txt = pytesseract.image_to_string(img)
return (txt or "").strip()
except Exception as e:
print(f"[RAG] Image OCR error for {file_path}: {e}")
return ""
def extract_from_code(self, file_path: str) -> str:
"""
Extract text from code/source files.
SUPPORTED FORMATS:
- .py (Python)
- .cpp, .c (C/C++)
- .h, .hpp (Header files)
- .js (JavaScript)
- .html, .htm (HTML)
- .css (Stylesheets)
- .txt, .md (Plain text/Markdown)
- .json (JSON - treat as text)
- .xml (XML)
- .yaml, .yml (YAML)
- .sh (Shell scripts)
- Any other text-based file
WHY TREAT CODE DIFFERENTLY?
- Code has its own structure (functions, classes)
- We preserve line breaks to maintain code structure
- Comments can be valuable for understanding intent
ARGS:
file_path: Path to code file
RETURNS:
File content as string, or empty string if failed
"""
try:
# Try common encodings
for encoding in ['utf-8', 'latin-1', 'cp1252']:
try:
with open(file_path, 'r', encoding=encoding) as f:
return f.read()
except UnicodeDecodeError:
continue
# If all encodings fail
print(f"[RAG] Could not decode file: {file_path}")
return ""
except Exception as e:
print(f"[RAG] Code extraction error for {file_path}: {e}")
return ""
# =========================================================================