Vineetiitg commited on
Commit
2d34555
·
1 Parent(s): 73ee76f

feat: add multi-format ingestion and index lifecycle

Browse files
app/engine/chunking.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ from langchain_core.documents import Document
4
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
5
+
6
+ from app.core.config import settings
7
+
8
+
9
+ def chunk_documents(documents: list[Document]) -> list[Document]:
10
+ splitter = RecursiveCharacterTextSplitter(
11
+ chunk_size=settings.CHUNK_SIZE,
12
+ chunk_overlap=settings.CHUNK_OVERLAP,
13
+ )
14
+ chunks = splitter.split_documents(documents)
15
+ for index, chunk in enumerate(chunks):
16
+ source = chunk.metadata.get("source", "unknown")
17
+ chunk.metadata["chunk_index"] = index
18
+ chunk.metadata["chunk_id"] = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{source}:{index}:{chunk.page_content[:80]}"))
19
+ return chunks
app/engine/document_registry.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ REGISTRY_PATH = Path("data/document_registry.json")
9
+
10
+
11
+ @dataclass
12
+ class DocumentRecord:
13
+ doc_id: str
14
+ source: str
15
+ source_path: str
16
+ content_hash: str
17
+ chunk_count: int
18
+ ingested_at: str
19
+
20
+
21
+ def file_hash(path: Path) -> str:
22
+ digest = hashlib.sha256()
23
+ with path.open("rb") as handle:
24
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
25
+ digest.update(block)
26
+ return digest.hexdigest()
27
+
28
+
29
+ def load_registry(path: Path = REGISTRY_PATH) -> dict[str, dict]:
30
+ if not path.exists():
31
+ return {}
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def save_registry(registry: dict[str, dict], path: Path = REGISTRY_PATH) -> None:
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ path.write_text(json.dumps(registry, indent=2, sort_keys=True), encoding="utf-8")
38
+
39
+
40
+ def hash_exists(content_hash: str, registry: dict[str, dict]) -> bool:
41
+ return any(record["content_hash"] == content_hash for record in registry.values())
42
+
43
+
44
+ def upsert_record(
45
+ doc_id: str,
46
+ source: str,
47
+ source_path: str,
48
+ content_hash: str,
49
+ chunk_count: int,
50
+ registry: dict[str, dict],
51
+ ) -> None:
52
+ registry[doc_id] = asdict(
53
+ DocumentRecord(
54
+ doc_id=doc_id,
55
+ source=source,
56
+ source_path=source_path,
57
+ content_hash=content_hash,
58
+ chunk_count=chunk_count,
59
+ ingested_at=datetime.now(timezone.utc).isoformat(),
60
+ )
61
+ )
app/engine/indexer.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_community.embeddings import FastEmbedEmbeddings
2
+ from langchain_qdrant import FastEmbedSparse, QdrantVectorStore, RetrievalMode
3
+ from qdrant_client import models
4
+
5
+ from app.core.config import settings
6
+ from app.core.dependencies import get_qdrant_client
7
+
8
+
9
+ def qdrant_store_options() -> dict:
10
+ if settings.QDRANT_URL:
11
+ return {"url": settings.QDRANT_URL}
12
+ return {"path": settings.QDRANT_LOCATION}
13
+
14
+
15
+ def retrieval_mode() -> RetrievalMode:
16
+ mode = settings.RETRIEVAL_MODE.lower()
17
+ if mode == "dense":
18
+ return RetrievalMode.DENSE
19
+ if mode == "sparse":
20
+ return RetrievalMode.SPARSE
21
+ return RetrievalMode.HYBRID
22
+
23
+
24
+ def dense_embeddings() -> FastEmbedEmbeddings:
25
+ return FastEmbedEmbeddings(model_name=settings.DENSE_EMBEDDING_MODEL)
26
+
27
+
28
+ def sparse_embeddings() -> FastEmbedSparse:
29
+ return FastEmbedSparse(model_name=settings.SPARSE_EMBEDDING_MODEL)
30
+
31
+
32
+ def collection_exists() -> bool:
33
+ client = get_qdrant_client()
34
+ return any(collection.name == settings.COLLECTION_NAME for collection in client.get_collections().collections)
35
+
36
+
37
+ def open_vector_store(validate_collection_config: bool = True) -> QdrantVectorStore:
38
+ return QdrantVectorStore(
39
+ client=get_qdrant_client(),
40
+ collection_name=settings.COLLECTION_NAME,
41
+ embedding=dense_embeddings(),
42
+ sparse_embedding=sparse_embeddings(),
43
+ retrieval_mode=retrieval_mode(),
44
+ validate_collection_config=validate_collection_config,
45
+ )
46
+
47
+
48
+ def index_documents(documents, force_recreate: bool = False) -> None:
49
+ if force_recreate or not collection_exists():
50
+ QdrantVectorStore.from_documents(
51
+ documents,
52
+ embedding=dense_embeddings(),
53
+ sparse_embedding=sparse_embeddings(),
54
+ collection_name=settings.COLLECTION_NAME,
55
+ retrieval_mode=retrieval_mode(),
56
+ force_recreate=force_recreate,
57
+ **qdrant_store_options(),
58
+ )
59
+ return
60
+
61
+ store = open_vector_store()
62
+ store.add_documents(documents)
63
+
64
+
65
+ def reset_collection() -> None:
66
+ client = get_qdrant_client()
67
+ if collection_exists():
68
+ client.delete_collection(settings.COLLECTION_NAME)
69
+
70
+
71
+ def delete_document(doc_id: str) -> None:
72
+ client = get_qdrant_client()
73
+ if not collection_exists():
74
+ return
75
+ client.delete(
76
+ collection_name=settings.COLLECTION_NAME,
77
+ points_selector=models.FilterSelector(
78
+ filter=models.Filter(
79
+ must=[
80
+ models.FieldCondition(
81
+ key="metadata.doc_id",
82
+ match=models.MatchValue(value=doc_id),
83
+ )
84
+ ]
85
+ )
86
+ ),
87
+ )
app/engine/ingestion.py CHANGED
@@ -1,46 +1,133 @@
1
- import os
2
- from langchain_community.document_loaders import DirectoryLoader, TextLoader
3
- from langchain_text_splitters import RecursiveCharacterTextSplitter
4
- from langchain_community.embeddings import FastEmbedEmbeddings
5
- from langchain_qdrant import FastEmbedSparse, QdrantVectorStore, RetrievalMode
6
 
7
- from app.core.config import settings
8
 
9
- def ingest_documents(data_dir: str = "data/docs"):
 
 
 
 
 
 
 
 
 
 
 
 
10
  print(f"Loading documents from {data_dir}...")
11
- if not os.path.exists(data_dir):
12
- os.makedirs(data_dir)
13
-
14
- loader = DirectoryLoader(data_dir, glob="**/*.txt", loader_cls=TextLoader)
15
- documents = loader.load()
16
-
17
- if not documents:
18
- print("No documents found. Please place some text documents into data/docs first.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  return
20
 
21
- text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
22
- chunks = text_splitter.split_documents(documents)
23
  print(f"Split documents into {len(chunks)} chunks.")
24
 
25
- dense_embeddings = FastEmbedEmbeddings(model_name=settings.DENSE_EMBEDDING_MODEL)
26
- sparse_embeddings = FastEmbedSparse(model_name=settings.SPARSE_EMBEDDING_MODEL)
27
-
28
- store_options = {
29
- "url": settings.QDRANT_URL,
30
- } if settings.QDRANT_URL else {
31
- "path": settings.QDRANT_LOCATION,
32
- }
33
-
34
- QdrantVectorStore.from_documents(
35
- chunks,
36
- embedding=dense_embeddings,
37
- sparse_embedding=sparse_embeddings,
38
- collection_name=settings.COLLECTION_NAME,
39
- retrieval_mode=RetrievalMode.HYBRID,
40
- force_recreate=True,
41
- **store_options,
42
- )
43
- print("Ingestion complete! Hybrid index is built.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  if __name__ == "__main__":
46
- ingest_documents()
 
1
+ import argparse
2
+ import uuid
3
+ from collections import Counter
4
+ from pathlib import Path
 
5
 
6
+ from langchain_core.documents import Document
7
 
8
+ from app.engine.chunking import chunk_documents
9
+ from app.engine.document_registry import (
10
+ file_hash,
11
+ hash_exists,
12
+ load_registry,
13
+ save_registry,
14
+ upsert_record,
15
+ )
16
+ from app.engine.indexer import delete_document, index_documents, reset_collection
17
+ from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
18
+
19
+
20
+ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
21
  print(f"Loading documents from {data_dir}...")
22
+ root = Path(data_dir)
23
+ root.mkdir(parents=True, exist_ok=True)
24
+
25
+ registry = {} if force else load_registry()
26
+ source_documents: list[Document] = []
27
+ doc_id_by_source: dict[str, str] = {}
28
+
29
+ if force:
30
+ reset_collection()
31
+
32
+ for path in sorted(root.rglob("*")):
33
+ if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
34
+ continue
35
+
36
+ content_hash = file_hash(path)
37
+ if not force and hash_exists(content_hash, registry):
38
+ print(f"Skipping unchanged document: {path.name}")
39
+ continue
40
+
41
+ doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
42
+ loaded = load_document(path)
43
+ for document in loaded:
44
+ document.metadata["doc_id"] = doc_id
45
+ document.metadata["content_hash"] = content_hash
46
+ source_documents.extend(loaded)
47
+ doc_id_by_source[str(path)] = doc_id
48
+
49
+ if not source_documents:
50
+ print("No new documents found.")
51
  return
52
 
53
+ chunks = chunk_documents(source_documents)
 
54
  print(f"Split documents into {len(chunks)} chunks.")
55
 
56
+ index_documents(chunks, force_recreate=force)
57
+
58
+ chunk_counts = Counter(chunk.metadata["doc_id"] for chunk in chunks)
59
+ for source_path, doc_id in doc_id_by_source.items():
60
+ path = Path(source_path)
61
+ upsert_record(
62
+ doc_id=doc_id,
63
+ source=path.name,
64
+ source_path=str(path),
65
+ content_hash=file_hash(path),
66
+ chunk_count=chunk_counts[doc_id],
67
+ registry=registry,
68
+ )
69
+ save_registry(registry)
70
+ print("Ingestion complete. Hybrid index is built.")
71
+
72
+
73
+ def list_documents() -> None:
74
+ registry = load_registry()
75
+ if not registry:
76
+ print("No indexed documents found.")
77
+ return
78
+ for record in registry.values():
79
+ print(f"{record['doc_id']} | {record['source']} | chunks={record['chunk_count']}")
80
+
81
+
82
+ def delete_indexed_document(doc_id: str) -> None:
83
+ registry = load_registry()
84
+ if doc_id not in registry:
85
+ print(f"Document not found: {doc_id}")
86
+ return
87
+ delete_document(doc_id)
88
+ del registry[doc_id]
89
+ save_registry(registry)
90
+ print(f"Deleted document: {doc_id}")
91
+
92
+
93
+ def reset_index() -> None:
94
+ reset_collection()
95
+ save_registry({})
96
+ print("Vector collection and document registry reset.")
97
+
98
+
99
+ def build_parser() -> argparse.ArgumentParser:
100
+ parser = argparse.ArgumentParser(description="Manage support document ingestion.")
101
+ subparsers = parser.add_subparsers(dest="command")
102
+
103
+ ingest_parser = subparsers.add_parser("ingest", help="Ingest documents into Qdrant.")
104
+ ingest_parser.add_argument("--data-dir", default="data/docs")
105
+ ingest_parser.add_argument("--force", action="store_true", help="Recreate the collection before ingesting.")
106
+
107
+ subparsers.add_parser("list", help="List indexed documents.")
108
+
109
+ delete_parser = subparsers.add_parser("delete", help="Delete one indexed document.")
110
+ delete_parser.add_argument("--doc-id", required=True)
111
+
112
+ subparsers.add_parser("reset", help="Delete the vector collection and registry.")
113
+ return parser
114
+
115
+
116
+ def main() -> None:
117
+ parser = build_parser()
118
+ args = parser.parse_args()
119
+
120
+ if args.command in {None, "ingest"}:
121
+ ingest_documents(data_dir=getattr(args, "data_dir", "data/docs"), force=getattr(args, "force", False))
122
+ elif args.command == "list":
123
+ list_documents()
124
+ elif args.command == "delete":
125
+ delete_indexed_document(args.doc_id)
126
+ elif args.command == "reset":
127
+ reset_index()
128
+ else:
129
+ parser.print_help()
130
+
131
 
132
  if __name__ == "__main__":
133
+ main()
app/engine/loaders.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from langchain_core.documents import Document
4
+
5
+
6
+ SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".html", ".htm"}
7
+
8
+
9
+ def load_documents(data_dir: str) -> list[Document]:
10
+ root = Path(data_dir)
11
+ root.mkdir(parents=True, exist_ok=True)
12
+
13
+ documents: list[Document] = []
14
+ for path in sorted(root.rglob("*")):
15
+ if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
16
+ continue
17
+ documents.extend(load_document(path))
18
+ return documents
19
+
20
+
21
+ def load_document(path: Path) -> list[Document]:
22
+ suffix = path.suffix.lower()
23
+ if suffix in {".txt", ".md"}:
24
+ return [_text_document(path)]
25
+ if suffix == ".pdf":
26
+ return _pdf_documents(path)
27
+ if suffix == ".docx":
28
+ return [_docx_document(path)]
29
+ if suffix in {".html", ".htm"}:
30
+ return [_html_document(path)]
31
+ return []
32
+
33
+
34
+ def _base_metadata(path: Path) -> dict:
35
+ return {
36
+ "source": path.name,
37
+ "source_path": str(path),
38
+ "file_type": path.suffix.lower().lstrip("."),
39
+ }
40
+
41
+
42
+ def _text_document(path: Path) -> Document:
43
+ return Document(page_content=path.read_text(encoding="utf-8", errors="ignore"), metadata=_base_metadata(path))
44
+
45
+
46
+ def _pdf_documents(path: Path) -> list[Document]:
47
+ try:
48
+ from pypdf import PdfReader
49
+ except ImportError as exc:
50
+ raise RuntimeError("Install pypdf to ingest PDF files.") from exc
51
+
52
+ reader = PdfReader(str(path))
53
+ documents: list[Document] = []
54
+ for index, page in enumerate(reader.pages, start=1):
55
+ metadata = _base_metadata(path)
56
+ metadata["page"] = index
57
+ documents.append(Document(page_content=page.extract_text() or "", metadata=metadata))
58
+ return documents
59
+
60
+
61
+ def _docx_document(path: Path) -> Document:
62
+ try:
63
+ from docx import Document as DocxDocument
64
+ except ImportError as exc:
65
+ raise RuntimeError("Install python-docx to ingest DOCX files.") from exc
66
+
67
+ doc = DocxDocument(str(path))
68
+ text = "\n".join(paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip())
69
+ return Document(page_content=text, metadata=_base_metadata(path))
70
+
71
+
72
+ def _html_document(path: Path) -> Document:
73
+ try:
74
+ from bs4 import BeautifulSoup
75
+ except ImportError as exc:
76
+ raise RuntimeError("Install beautifulsoup4 to ingest HTML files.") from exc
77
+
78
+ soup = BeautifulSoup(path.read_text(encoding="utf-8", errors="ignore"), "html.parser")
79
+ for tag in soup(["script", "style"]):
80
+ tag.decompose()
81
+ return Document(page_content=soup.get_text("\n", strip=True), metadata=_base_metadata(path))
requirements.txt CHANGED
@@ -19,3 +19,7 @@ jinja2==3.1.3
19
  tabulate==0.9.0
20
  streamlit==1.32.2
21
  requests==2.34.2
 
 
 
 
 
19
  tabulate==0.9.0
20
  streamlit==1.32.2
21
  requests==2.34.2
22
+ pypdf==4.3.1
23
+ python-docx==1.1.2
24
+ beautifulsoup4==4.12.3
25
+ pytest==8.2.2