huri-module-rag 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- huri_module_rag-0.1.0.dist-info/METADATA +53 -0
- huri_module_rag-0.1.0.dist-info/RECORD +15 -0
- huri_module_rag-0.1.0.dist-info/WHEEL +5 -0
- huri_module_rag-0.1.0.dist-info/entry_points.txt +3 -0
- huri_module_rag-0.1.0.dist-info/top_level.txt +1 -0
- huri_rag/__init__.py +0 -0
- huri_rag/ingestion.py +589 -0
- huri_rag/manifests/qag/huri_module.toml +7 -0
- huri_rag/manifests/rag/huri_module.toml +7 -0
- huri_rag/memory_inspect.py +114 -0
- huri_rag/memory_maintenance.py +154 -0
- huri_rag/qdrant_utils.py +29 -0
- huri_rag/question_aggregator.py +58 -0
- huri_rag/rag.py +901 -0
- huri_rag/semantic_chunker.py +251 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: huri-module-rag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HuRI module: Retrieval-augmented answers with long-term memory (Qdrant + an OpenAI-compatible LLM), and the question aggregator
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: huri-core[server]<0.5,>=0.4
|
|
8
|
+
Requires-Dist: huri-events<0.2,>=0.1
|
|
9
|
+
Provides-Extra: rag
|
|
10
|
+
Requires-Dist: numpy<2,>=1.26; extra == "rag"
|
|
11
|
+
Requires-Dist: httpx<0.28,>=0.27.2; extra == "rag"
|
|
12
|
+
Requires-Dist: qdrant-client<1.19,>=1.18; extra == "rag"
|
|
13
|
+
Requires-Dist: sentence-transformers<3.3,>=3.2.1; extra == "rag"
|
|
14
|
+
Requires-Dist: pypdf<5.2,>=5.1; extra == "rag"
|
|
15
|
+
Requires-Dist: semantic_chunker==0.2.0; extra == "rag"
|
|
16
|
+
Requires-Dist: pydantic>=2; extra == "rag"
|
|
17
|
+
|
|
18
|
+
# huri-module-rag
|
|
19
|
+
|
|
20
|
+
Retrieval-augmented answers with long-term memory (Qdrant + an OpenAI-compatible LLM), and the question aggregator.
|
|
21
|
+
|
|
22
|
+
| Module | Topics | What it does |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `rag` | `huri.question` → `huri.token` | Answers questions from memory with an LLM, streaming tokens |
|
|
25
|
+
| `qag` | `huri.partial_question` → `huri.question` | Joins a transcript and its emotion into a question |
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install -e 'packages/huri-module-rag[rag]' # from a huri-modules checkout, next to huri-core and huri-events
|
|
29
|
+
HURI_MODULES=rag,qag ... # enable it on the server
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`rag` and the ingestion and memory tools (`python -m huri_rag.ingestion`,
|
|
33
|
+
`memory_inspect`, `memory_maintenance`) need the `rag` extra (Qdrant client,
|
|
34
|
+
sentence-transformers, httpx, pypdf, semantic_chunker); `qag` only joins events
|
|
35
|
+
and needs none of it, so a host that runs `qag` alone installs the package
|
|
36
|
+
without the extra (`[tool.huri-modules.extras]` in `pyproject.toml`; `huri hub
|
|
37
|
+
install` and `scripts/install_local.sh` add it). Hardware pins of `rag`:
|
|
38
|
+
`requirements-amd.rag.txt` (install it before the package).
|
|
39
|
+
|
|
40
|
+
A distribution of
|
|
41
|
+
[huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
|
|
42
|
+
`pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
|
|
43
|
+
`huri-events`, which are not on PyPI: without a checkout, install them from git
|
|
44
|
+
in the same pip command, so pip never looks them up on an index:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
ref=0.1.0 # a huri-modules release tag
|
|
48
|
+
core=0.4.0 # HURI_CORE_REF in that release's versions.env
|
|
49
|
+
pip install \
|
|
50
|
+
"huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
|
|
51
|
+
"huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
|
|
52
|
+
"huri-module-rag[rag] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-rag"
|
|
53
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
huri_rag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
huri_rag/ingestion.py,sha256=vKpr-cDqBXbWPQUqnmP9K8X9y43CQ1RWmHLksw4eaxg,17914
|
|
3
|
+
huri_rag/memory_inspect.py,sha256=LLCSaJVxJiRLV5khpioyqEdGbthRyVIYcaHgF5Bi3cY,3494
|
|
4
|
+
huri_rag/memory_maintenance.py,sha256=271MHDhd3GErHHIFOtDphwP8TpUCzAZQzD2x3h1hHzM,5164
|
|
5
|
+
huri_rag/qdrant_utils.py,sha256=C-pSO8WYlevwO5-6b1ZapRVobfKg6sjbBJLAujum3cc,1079
|
|
6
|
+
huri_rag/question_aggregator.py,sha256=q8xPQmsnWmb9SFFoRtOJxxAf41rezYvqoIDspbC4kgg,2015
|
|
7
|
+
huri_rag/rag.py,sha256=X5kr1N3ujQwBYjAvJDBHf9fqfEk4klCEKQu_edJgodw,34117
|
|
8
|
+
huri_rag/semantic_chunker.py,sha256=QkEIXmwyiqMMVniXPnqrZN836HEx1ZWBhSik4nPNiuE,8554
|
|
9
|
+
huri_rag/manifests/qag/huri_module.toml,sha256=q84S7pgFkUjzdT0PvAefq0gQm1nPV-7Vyz4DRblCFG4,355
|
|
10
|
+
huri_rag/manifests/rag/huri_module.toml,sha256=Do3qVVL2Y1udu3H9oPAtwQ1XBkmHDl31K07WF7EMcc8,339
|
|
11
|
+
huri_module_rag-0.1.0.dist-info/METADATA,sha256=UxsMpxxrVW6oV8bk5F_iDvOI2nCB81E_gEWt5URXqFI,2586
|
|
12
|
+
huri_module_rag-0.1.0.dist-info/WHEEL,sha256=YLJXdYXQ2FQ0Uqn2J-6iEIC-3iOey8lH3xCtvFLkd8Q,91
|
|
13
|
+
huri_module_rag-0.1.0.dist-info/entry_points.txt,sha256=UWXo9g5_yFZNxy2fktKbVuB72oq9QSUMurn-hpzD2cg,77
|
|
14
|
+
huri_module_rag-0.1.0.dist-info/top_level.txt,sha256=jguIEsHI7dpw6zcYsVkMWKfUibgTwjYQXhyiK65_56A,9
|
|
15
|
+
huri_module_rag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
huri_rag
|
huri_rag/__init__.py
ADDED
|
File without changes
|
huri_rag/ingestion.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, List
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import numpy as np
|
|
12
|
+
from qdrant_client import QdrantClient
|
|
13
|
+
from qdrant_client.models import (
|
|
14
|
+
Distance,
|
|
15
|
+
FieldCondition,
|
|
16
|
+
Filter,
|
|
17
|
+
MatchValue,
|
|
18
|
+
PointStruct,
|
|
19
|
+
VectorParams,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sentence_transformers import SentenceTransformer
|
|
24
|
+
|
|
25
|
+
USER_ID_FILE = os.path.expanduser("~/.huri_user_id")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RemoteEmbedder:
|
|
29
|
+
"""Embed via an OpenAI-compatible ``/v1/embeddings`` endpoint (e.g. llama.cpp).
|
|
30
|
+
|
|
31
|
+
Drop-in for the subset of ``SentenceTransformer`` this tool uses: a single
|
|
32
|
+
``.encode(text, normalize_embeddings=...)`` returning a 1-D numpy array, so
|
|
33
|
+
the existing ``.tolist()`` / ``len(...)`` call sites keep working unchanged.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, url: str, model_name: str):
|
|
37
|
+
self.url = url.rstrip("/")
|
|
38
|
+
self.model_name = model_name
|
|
39
|
+
self._client = httpx.Client(timeout=60.0, verify=False)
|
|
40
|
+
|
|
41
|
+
def encode(self, text: str, normalize_embeddings: bool = True) -> np.ndarray:
|
|
42
|
+
resp = self._client.post(
|
|
43
|
+
f"{self.url}/v1/embeddings",
|
|
44
|
+
json={"model": self.model_name, "input": str(text)},
|
|
45
|
+
)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
vec = np.asarray(resp.json()["data"][0]["embedding"], dtype=np.float32)
|
|
48
|
+
if normalize_embeddings:
|
|
49
|
+
norm = np.linalg.norm(vec)
|
|
50
|
+
if norm > 0:
|
|
51
|
+
vec = vec / norm
|
|
52
|
+
return vec
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _split_sentences(text: str) -> list[str]:
|
|
56
|
+
"""Simple sentence splitter."""
|
|
57
|
+
result: List = []
|
|
58
|
+
sentences = re.split(r"(?<=[.!?])\s+", text)
|
|
59
|
+
|
|
60
|
+
for s in sentences:
|
|
61
|
+
parts = s.split("\n\n")
|
|
62
|
+
result.extend(parts)
|
|
63
|
+
return [s.strip() for s in result if s.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
|
|
67
|
+
"""
|
|
68
|
+
Fallback: fixed-size chunking by sentences.
|
|
69
|
+
Used when --chunking=fixed.
|
|
70
|
+
"""
|
|
71
|
+
chunks: List = []
|
|
72
|
+
current_chunk: List = []
|
|
73
|
+
current_length: int = 0
|
|
74
|
+
sentences = _split_sentences(text)
|
|
75
|
+
|
|
76
|
+
for sentence in sentences:
|
|
77
|
+
sentence = sentence.strip()
|
|
78
|
+
if not sentence:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
sentence_length = len(sentence.split())
|
|
82
|
+
|
|
83
|
+
if current_length + sentence_length > chunk_size and current_chunk:
|
|
84
|
+
overlap_words: int = 0
|
|
85
|
+
overlap_sentences: List = []
|
|
86
|
+
chunks.append(" ".join(current_chunk))
|
|
87
|
+
|
|
88
|
+
for s in reversed(current_chunk):
|
|
89
|
+
overlap_words += len(s.split())
|
|
90
|
+
overlap_sentences.insert(0, s)
|
|
91
|
+
if overlap_words >= overlap:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
current_chunk = overlap_sentences
|
|
95
|
+
current_length = overlap_words
|
|
96
|
+
|
|
97
|
+
current_chunk.append(sentence)
|
|
98
|
+
current_length += sentence_length
|
|
99
|
+
|
|
100
|
+
if current_chunk:
|
|
101
|
+
chunks.append(" ".join(current_chunk))
|
|
102
|
+
|
|
103
|
+
return chunks
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_text_from_pdf(pdf_path: str) -> str:
|
|
107
|
+
"""Extract text from a PDF file."""
|
|
108
|
+
try:
|
|
109
|
+
from pypdf import PdfReader
|
|
110
|
+
|
|
111
|
+
reader = PdfReader(pdf_path)
|
|
112
|
+
text = ""
|
|
113
|
+
for page in reader.pages:
|
|
114
|
+
text += page.extract_text() + "\n"
|
|
115
|
+
return text.strip()
|
|
116
|
+
except ImportError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
print("ERROR: Install a PDF library: pip install pymupdf OR pip install pypdf")
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_user_id(provided_id: str | None = None) -> str:
|
|
124
|
+
if provided_id:
|
|
125
|
+
return provided_id
|
|
126
|
+
if os.path.exists(USER_ID_FILE):
|
|
127
|
+
with open(USER_ID_FILE) as f:
|
|
128
|
+
uid = f.read().strip()
|
|
129
|
+
if uid:
|
|
130
|
+
return uid
|
|
131
|
+
new_id = str(uuid.uuid4())
|
|
132
|
+
with open(USER_ID_FILE, "w") as f:
|
|
133
|
+
f.write(new_id)
|
|
134
|
+
print(f"Generated new user_id: {new_id}")
|
|
135
|
+
return new_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_collection(client: QdrantClient, collection: str, vector_size: int):
|
|
139
|
+
collections = [c.name for c in client.get_collections().collections]
|
|
140
|
+
if collection not in collections:
|
|
141
|
+
client.create_collection(
|
|
142
|
+
collection_name=collection,
|
|
143
|
+
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
|
|
144
|
+
)
|
|
145
|
+
print(f"Created collection: {collection}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ingest_chunks(
|
|
149
|
+
client: QdrantClient,
|
|
150
|
+
model: Any,
|
|
151
|
+
collection: str,
|
|
152
|
+
chunks: list[str],
|
|
153
|
+
_user_id: str,
|
|
154
|
+
source: str,
|
|
155
|
+
doc_type: str = "document",
|
|
156
|
+
):
|
|
157
|
+
"""Embed chunks and upsert into Qdrant."""
|
|
158
|
+
points = []
|
|
159
|
+
timestamp = datetime.now().isoformat()
|
|
160
|
+
|
|
161
|
+
for i, chunk in enumerate(chunks):
|
|
162
|
+
vector = model.encode(chunk, normalize_embeddings=True).tolist()
|
|
163
|
+
points.append(
|
|
164
|
+
PointStruct(
|
|
165
|
+
id=str(uuid.uuid4()),
|
|
166
|
+
vector=vector,
|
|
167
|
+
payload={
|
|
168
|
+
"text": chunk,
|
|
169
|
+
"_user_id": _user_id,
|
|
170
|
+
"source": source,
|
|
171
|
+
"type": doc_type,
|
|
172
|
+
"chunk_index": i,
|
|
173
|
+
"timestamp": timestamp,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if points:
|
|
179
|
+
# Upsert in batches of 100
|
|
180
|
+
batch_size = 100
|
|
181
|
+
for i in range(0, len(points), batch_size):
|
|
182
|
+
batch = points[i : i + batch_size]
|
|
183
|
+
client.upsert(collection_name=collection, points=batch)
|
|
184
|
+
|
|
185
|
+
return len(points)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def chunk_strat(text: str, args, model: Any) -> list[str] | Any:
|
|
189
|
+
"""Pick the right chunking strategy based on args."""
|
|
190
|
+
if args.chunking == "semantic":
|
|
191
|
+
# Thomas: I need to import here, bceause it takes too much time earlier, or
|
|
192
|
+
# use a jupyter notebook to do it instead
|
|
193
|
+
from .semantic_chunker import SemanticChunker
|
|
194
|
+
|
|
195
|
+
chunker = SemanticChunker(
|
|
196
|
+
model=model,
|
|
197
|
+
strategy=args.semantic_strategy,
|
|
198
|
+
)
|
|
199
|
+
return chunker.chunk(text)
|
|
200
|
+
else:
|
|
201
|
+
return chunk_text(text, chunk_size=args.chunk_size, overlap=args.overlap)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_pdf(args, client, model, _user_id):
|
|
205
|
+
"""Ingest PDF files."""
|
|
206
|
+
files: List[Path] = []
|
|
207
|
+
for path in args.files:
|
|
208
|
+
p = Path(path)
|
|
209
|
+
if p.is_dir():
|
|
210
|
+
files.extend(p.glob("**/*.pdf"))
|
|
211
|
+
elif p.suffix.lower() == ".pdf":
|
|
212
|
+
files.append(p)
|
|
213
|
+
else:
|
|
214
|
+
print(f"Skipping non-PDF: {path}")
|
|
215
|
+
|
|
216
|
+
if not files:
|
|
217
|
+
print("No PDF files found.")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
sample = model.encode("test", normalize_embeddings=True)
|
|
221
|
+
ensure_collection(client, args.collection, len(sample))
|
|
222
|
+
|
|
223
|
+
total = 0
|
|
224
|
+
for pdf_path in files:
|
|
225
|
+
print(f"\nProcessing: {pdf_path}")
|
|
226
|
+
text = extract_text_from_pdf(str(pdf_path))
|
|
227
|
+
|
|
228
|
+
if not text.strip():
|
|
229
|
+
print(f" WARNING: No text extracted from {pdf_path}")
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
chunks = chunk_strat(text, args, model)
|
|
233
|
+
count = ingest_chunks(
|
|
234
|
+
client,
|
|
235
|
+
model,
|
|
236
|
+
args.collection,
|
|
237
|
+
chunks,
|
|
238
|
+
_user_id,
|
|
239
|
+
source=pdf_path.name,
|
|
240
|
+
doc_type="pdf",
|
|
241
|
+
)
|
|
242
|
+
print(f" -> {count} chunks ingested")
|
|
243
|
+
total += count
|
|
244
|
+
|
|
245
|
+
print(f"\nDone. Total: {total} chunks from {len(files)} PDF(s)")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_text(args, client, model, _user_id):
|
|
249
|
+
"""Ingest text files."""
|
|
250
|
+
sample = model.encode("test", normalize_embeddings=True)
|
|
251
|
+
ensure_collection(client, args.collection, len(sample))
|
|
252
|
+
|
|
253
|
+
total = 0
|
|
254
|
+
for file_path in args.files:
|
|
255
|
+
p = Path(file_path)
|
|
256
|
+
if not p.exists():
|
|
257
|
+
print(f"File not found: {file_path}")
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
print(f"\nProcessing: {file_path}")
|
|
261
|
+
text = p.read_text(encoding="utf-8")
|
|
262
|
+
|
|
263
|
+
if not text.strip():
|
|
264
|
+
print(f" WARNING: File is empty: {file_path}")
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
chunks = chunk_strat(text, args, model)
|
|
268
|
+
count = ingest_chunks(
|
|
269
|
+
client,
|
|
270
|
+
model,
|
|
271
|
+
args.collection,
|
|
272
|
+
chunks,
|
|
273
|
+
_user_id,
|
|
274
|
+
source=p.name,
|
|
275
|
+
doc_type="text",
|
|
276
|
+
)
|
|
277
|
+
print(f" -> {count} chunks ingested")
|
|
278
|
+
total += count
|
|
279
|
+
|
|
280
|
+
print(f"\nDone. Total: {total} chunks from {len(args.files)} file(s)")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_write(args, client, model, _user_id):
|
|
284
|
+
"""Write text interactively and ingest it."""
|
|
285
|
+
title = args.title or f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
286
|
+
|
|
287
|
+
print(f"Write your text below (title: '{title}')")
|
|
288
|
+
print("Press Ctrl+D (Linux/Mac) or Ctrl+Z then Enter (Windows) when done.")
|
|
289
|
+
print("-" * 40)
|
|
290
|
+
|
|
291
|
+
lines = []
|
|
292
|
+
try:
|
|
293
|
+
while True:
|
|
294
|
+
line = input()
|
|
295
|
+
lines.append(line)
|
|
296
|
+
except EOFError:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
text = "\n".join(lines).strip()
|
|
300
|
+
|
|
301
|
+
if not text:
|
|
302
|
+
print("Nothing to ingest.")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
print(f"\n{'-' * 40}")
|
|
306
|
+
print(f"Received {len(text)} characters")
|
|
307
|
+
|
|
308
|
+
sample = model.encode("test", normalize_embeddings=True)
|
|
309
|
+
ensure_collection(client, args.collection, len(sample))
|
|
310
|
+
|
|
311
|
+
chunks = chunk_strat(text, args, model)
|
|
312
|
+
count = ingest_chunks(
|
|
313
|
+
client,
|
|
314
|
+
model,
|
|
315
|
+
args.collection,
|
|
316
|
+
chunks,
|
|
317
|
+
_user_id,
|
|
318
|
+
source=title,
|
|
319
|
+
doc_type="manual",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
print(f"Done. Ingested {count} chunks as '{title}'")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_profile(args, client, model, _user_id):
|
|
326
|
+
"""Store always-on profile facts about the user (name, etc.).
|
|
327
|
+
|
|
328
|
+
Unlike regular documents, profile facts are NOT retrieved by vector
|
|
329
|
+
similarity. The RAG handle pulls them by filter (_user_id + type=profile)
|
|
330
|
+
on every query and injects them into the system prompt, so the character
|
|
331
|
+
always knows them.
|
|
332
|
+
"""
|
|
333
|
+
sample = model.encode("test", normalize_embeddings=True)
|
|
334
|
+
ensure_collection(client, args.collection, len(sample))
|
|
335
|
+
|
|
336
|
+
facts: List[str] = []
|
|
337
|
+
if args.name:
|
|
338
|
+
facts.append(f"The user's name is {args.name}.")
|
|
339
|
+
for fact in args.fact or []:
|
|
340
|
+
facts.append(fact)
|
|
341
|
+
|
|
342
|
+
if not facts:
|
|
343
|
+
print("Nothing to store. Use --name and/or --fact 'some fact'.")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Replace the existing profile so facts don't pile up across runs.
|
|
347
|
+
client.delete(
|
|
348
|
+
collection_name=args.collection,
|
|
349
|
+
points_selector=Filter(
|
|
350
|
+
must=[
|
|
351
|
+
FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
|
|
352
|
+
FieldCondition(key="type", match=MatchValue(value="profile")),
|
|
353
|
+
]
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
count = ingest_chunks(
|
|
358
|
+
client,
|
|
359
|
+
model,
|
|
360
|
+
args.collection,
|
|
361
|
+
facts,
|
|
362
|
+
_user_id,
|
|
363
|
+
source="profile",
|
|
364
|
+
doc_type="profile",
|
|
365
|
+
)
|
|
366
|
+
print(f"Stored {count} profile fact(s) for user {_user_id}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cmd_list(args, client, model, _user_id):
|
|
370
|
+
"""List what's in the database for this user."""
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
info = client.get_collection(args.collection)
|
|
374
|
+
print(f"Collection: {args.collection}")
|
|
375
|
+
print(f"Total points: {info.points_count}")
|
|
376
|
+
except Exception:
|
|
377
|
+
print(f"Collection '{args.collection}' doesn't exist.")
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
results = client.scroll(
|
|
381
|
+
collection_name=args.collection,
|
|
382
|
+
scroll_filter=Filter(
|
|
383
|
+
must=[
|
|
384
|
+
FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
|
|
385
|
+
]
|
|
386
|
+
),
|
|
387
|
+
limit=100,
|
|
388
|
+
with_payload=True,
|
|
389
|
+
with_vectors=False,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
points = results[0]
|
|
393
|
+
if not points:
|
|
394
|
+
print(f"No documents found for user {_user_id}")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
sources = {}
|
|
398
|
+
for p in points:
|
|
399
|
+
source = p.payload.get("source", "unknown")
|
|
400
|
+
doc_type = p.payload.get("type", "unknown")
|
|
401
|
+
if source not in sources:
|
|
402
|
+
sources[source] = {"count": 0, "type": doc_type}
|
|
403
|
+
sources[source]["count"] += 1
|
|
404
|
+
|
|
405
|
+
print(f"\nDocuments for user {_user_id}:")
|
|
406
|
+
print(f"{'Source':<40} {'Type':<10} {'Chunks':<8}")
|
|
407
|
+
print("-" * 60)
|
|
408
|
+
for source, info in sorted(sources.items()):
|
|
409
|
+
print(f"{source:<40} {info['type']:<10} {info['count']:<8}")
|
|
410
|
+
print(f"\nTotal: {len(points)} chunks across {len(sources)} sources")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def cmd_delete(args, client, model, _user_id):
|
|
414
|
+
"""Delete documents by source name."""
|
|
415
|
+
|
|
416
|
+
if not args.source:
|
|
417
|
+
print("Specify --source to delete. Use 'list' command to see sources.")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
filter_conditions: Any = [
|
|
421
|
+
FieldCondition(key="_user_id", match=MatchValue(value=_user_id)),
|
|
422
|
+
FieldCondition(key="source", match=MatchValue(value=args.source)),
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
client.delete(
|
|
426
|
+
collection_name=args.collection,
|
|
427
|
+
points_selector=Filter(must=filter_conditions),
|
|
428
|
+
)
|
|
429
|
+
print(f"Deleted all chunks from source '{args.source}' for user {_user_id}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main():
|
|
433
|
+
parser = argparse.ArgumentParser(description="HuRI RAG Ingestion Tool")
|
|
434
|
+
parser.add_argument("--user-id", type=str, default=None)
|
|
435
|
+
parser.add_argument("--collection", type=str, default="documents")
|
|
436
|
+
parser.add_argument("--qdrant-url", type=str, default="http://localhost:6333")
|
|
437
|
+
parser.add_argument(
|
|
438
|
+
"--no-verify-ssl",
|
|
439
|
+
action="store_true",
|
|
440
|
+
default=False,
|
|
441
|
+
help="Disable SSL certificate verification (needed for self-signed LAN certs).",
|
|
442
|
+
)
|
|
443
|
+
parser.add_argument("--embedding-model", type=str, default="BAAI/bge-large-en-v1.5")
|
|
444
|
+
parser.add_argument(
|
|
445
|
+
"--embedding-url",
|
|
446
|
+
type=str,
|
|
447
|
+
default="",
|
|
448
|
+
help=(
|
|
449
|
+
"OpenAI-compatible embedding endpoint (e.g. llama.cpp at "
|
|
450
|
+
"http://localhost:8080). When set, embeddings are computed remotely "
|
|
451
|
+
"instead of with a local SentenceTransformer. Requires --chunking fixed."
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument(
|
|
455
|
+
"--chunk-size",
|
|
456
|
+
type=int,
|
|
457
|
+
default=500,
|
|
458
|
+
help="Target chunk size in words (fixed mode)",
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
"--overlap",
|
|
462
|
+
type=int,
|
|
463
|
+
default=50,
|
|
464
|
+
help="Overlap between chunks in words (fixed mode)",
|
|
465
|
+
)
|
|
466
|
+
parser.add_argument(
|
|
467
|
+
"--chunking",
|
|
468
|
+
type=str,
|
|
469
|
+
default="fixed",
|
|
470
|
+
choices=["semantic", "fixed"],
|
|
471
|
+
help="Chunking strategy: 'semantic' (default) or 'fixed'",
|
|
472
|
+
)
|
|
473
|
+
parser.add_argument(
|
|
474
|
+
"--semantic-strategy",
|
|
475
|
+
type=str,
|
|
476
|
+
default="percentile",
|
|
477
|
+
choices=["percentile", "threshold", "stddev"],
|
|
478
|
+
help="Semantic chunking strategy (default: percentile)",
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
482
|
+
|
|
483
|
+
p_pdf = subparsers.add_parser("pdf", help="Ingest PDF files")
|
|
484
|
+
p_pdf.add_argument("files", nargs="+", help="PDF files or directories")
|
|
485
|
+
|
|
486
|
+
p_text = subparsers.add_parser("text", help="Ingest text files (.txt, .md)")
|
|
487
|
+
p_text.add_argument("files", nargs="+", help="Text files")
|
|
488
|
+
|
|
489
|
+
p_write = subparsers.add_parser("write", help="Write text interactively")
|
|
490
|
+
p_write.add_argument("--title", type=str, default=None, help="Title/source name")
|
|
491
|
+
|
|
492
|
+
p_profile = subparsers.add_parser(
|
|
493
|
+
"profile", help="Store always-on profile facts (name, etc.)"
|
|
494
|
+
)
|
|
495
|
+
p_profile.add_argument("--name", type=str, default=None, help="User's name")
|
|
496
|
+
p_profile.add_argument(
|
|
497
|
+
"--fact",
|
|
498
|
+
action="append",
|
|
499
|
+
help="A fact about the user, e.g. --fact 'Likes cheese' (repeatable)",
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
subparsers.add_parser("list", help="List ingested documents")
|
|
503
|
+
|
|
504
|
+
p_delete = subparsers.add_parser("delete", help="Delete documents by source")
|
|
505
|
+
p_delete.add_argument(
|
|
506
|
+
"--source", type=str, required=True, help="Source name to delete"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
args = parser.parse_args()
|
|
510
|
+
|
|
511
|
+
if args.embedding_url and args.chunking == "semantic":
|
|
512
|
+
parser.error(
|
|
513
|
+
"--chunking semantic needs a local SentenceTransformer model and "
|
|
514
|
+
"cannot run over --embedding-url. Use --chunking fixed."
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
_user_id = get_user_id(args.user_id)
|
|
518
|
+
print(f"User: {_user_id}")
|
|
519
|
+
|
|
520
|
+
verify_ssl = not args.no_verify_ssl
|
|
521
|
+
try:
|
|
522
|
+
from .qdrant_utils import make_qdrant_client
|
|
523
|
+
except ImportError:
|
|
524
|
+
from qdrant_utils import make_qdrant_client # type: ignore[no-redef]
|
|
525
|
+
client = make_qdrant_client(args.qdrant_url, verify_ssl)
|
|
526
|
+
|
|
527
|
+
# Lazy-load the model only if the command needs embeddings.
|
|
528
|
+
# Commands that don't need it: list, delete, profile (doesn't use embeddings).
|
|
529
|
+
model: RemoteEmbedder | SentenceTransformer | None
|
|
530
|
+
needs_embeddings = args.command in ("pdf", "text", "write", "profile")
|
|
531
|
+
|
|
532
|
+
if needs_embeddings:
|
|
533
|
+
if args.embedding_url:
|
|
534
|
+
print(
|
|
535
|
+
f"Embedding remotely via {args.embedding_url} "
|
|
536
|
+
f"(model={args.embedding_model})"
|
|
537
|
+
)
|
|
538
|
+
model = RemoteEmbedder(args.embedding_url, args.embedding_model)
|
|
539
|
+
else:
|
|
540
|
+
from sentence_transformers import SentenceTransformer
|
|
541
|
+
|
|
542
|
+
model = SentenceTransformer(args.embedding_model)
|
|
543
|
+
else:
|
|
544
|
+
model = None
|
|
545
|
+
|
|
546
|
+
commands = {
|
|
547
|
+
"pdf": cmd_pdf,
|
|
548
|
+
"text": cmd_text,
|
|
549
|
+
"write": cmd_write,
|
|
550
|
+
"profile": cmd_profile,
|
|
551
|
+
"list": cmd_list,
|
|
552
|
+
"delete": cmd_delete,
|
|
553
|
+
}
|
|
554
|
+
commands[args.command](args, client, model, _user_id)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
if __name__ == "__main__":
|
|
558
|
+
"""
|
|
559
|
+
Ingestion tool for HuRI RAG.
|
|
560
|
+
|
|
561
|
+
Usage:
|
|
562
|
+
# Ingest a PDF
|
|
563
|
+
python ingestion.py pdf report.pdf
|
|
564
|
+
|
|
565
|
+
# Ingest multiple PDFs
|
|
566
|
+
python ingestion.py pdf doc1.pdf doc2.pdf doc3.pdf
|
|
567
|
+
|
|
568
|
+
# Ingest a whole folder of PDFs
|
|
569
|
+
# TODO: To verify and to add the support of hole paths
|
|
570
|
+
python ingestion.py pdf ./my_documents/
|
|
571
|
+
|
|
572
|
+
# Write text interactively (type, then Ctrl+D to save)
|
|
573
|
+
python ingestion.py write --title "My meeting notes"
|
|
574
|
+
|
|
575
|
+
# Ingest a text file
|
|
576
|
+
python ingestion.py text notes.txt story.md
|
|
577
|
+
|
|
578
|
+
# Specify a user ID (otherwise reads from ~/.huri_user_id)
|
|
579
|
+
python ingestion.py --user-id "abc-123" pdf report.pdf
|
|
580
|
+
|
|
581
|
+
# Use a different collection
|
|
582
|
+
python ingestion.py --collection "my_docs" pdf report.pdf
|
|
583
|
+
|
|
584
|
+
# Use a different ingestion strategy
|
|
585
|
+
python huri_rag/ingestion.py \
|
|
586
|
+
--chunking semantic --semantic-strategy threshold pdf "EN.pdf"
|
|
587
|
+
|
|
588
|
+
"""
|
|
589
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
name = "qag"
|
|
2
|
+
summary = "Joins a transcript and its emotion into a question"
|
|
3
|
+
input = { topic = "huri.partial_question", event = "huri_events.question:PartialQuestion" }
|
|
4
|
+
output = { topic = "huri.question", event = "huri_events.question:RAGQuestion" }
|
|
5
|
+
events = ["huri-events>=0.1,<0.2"]
|
|
6
|
+
requires = { gpu = false, actuation = false }
|
|
7
|
+
huri_core = ">=0.4,<0.5"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
name = "rag"
|
|
2
|
+
summary = "Answers questions from memory with an LLM, streaming tokens"
|
|
3
|
+
input = { topic = "huri.question", event = "huri_events.question:RAGQuestion" }
|
|
4
|
+
output = { topic = "huri.token", event = "huri_events.text:Token" }
|
|
5
|
+
events = ["huri-events>=0.1,<0.2"]
|
|
6
|
+
requires = { gpu = false, actuation = false }
|
|
7
|
+
huri_core = ">=0.4,<0.5"
|