docuquery 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.
docuquery/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("docuquery")
docuquery/chunker.py ADDED
@@ -0,0 +1,87 @@
1
+ from docuquery.constants import CHUNK_SIZE, OVERLAP
2
+
3
+
4
+ def split_text(text_block: str, chunk_size: int, separators: list[str]) -> list[str]:
5
+ """Splits a text block into chunks based on the specified separators and chunk size.
6
+
7
+ Args:
8
+ text_block (str): The text block to be split.
9
+ chunk_size (int): The maximum size of each chunk.
10
+ separators (list[str]): A list of separators to use for splitting.
11
+
12
+ Returns:
13
+ list[str]: A list of text chunks.
14
+ """
15
+ if len(text_block) <= chunk_size:
16
+ return [text_block]
17
+
18
+ for i, separator in enumerate(separators):
19
+ if separator not in text_block:
20
+ continue
21
+ chunks, current = [], ""
22
+
23
+ for part in text_block.split(separator):
24
+ if len(part) > chunk_size:
25
+ if current:
26
+ chunks.append(current)
27
+ current = ""
28
+ chunks.extend(split_text(part, chunk_size, separators[i + 1 :]))
29
+ elif not current:
30
+ current = part
31
+ elif len(current) + len(separator) + len(part) <= chunk_size:
32
+ current += separator + part
33
+ else:
34
+ chunks.append(current)
35
+ current = part
36
+ if current:
37
+ chunks.append(current)
38
+ return chunks
39
+
40
+ # No separator left: cut at chunk_size, so no text is lost
41
+ return [
42
+ text_block[i : i + chunk_size] for i in range(0, len(text_block), chunk_size)
43
+ ]
44
+
45
+
46
+ def overlap_chunked_texts(
47
+ text_chunks: list[str], overlap: int, chunk_size: int
48
+ ) -> list[str]:
49
+ """Applies overlap to a list of text chunks.
50
+
51
+ Args:
52
+ text_chunks (list[str]): The list of text chunks.
53
+ overlap (int): The number of overlapping characters between consecutive chunks.
54
+ chunk_size (int): The maximum size of each chunk.
55
+
56
+ Returns:
57
+ list[str]: A list of text chunks with applied overlap.
58
+ """
59
+ overlapped_chunks = []
60
+ for i, text_chunk in enumerate(text_chunks):
61
+ chunk = text_chunk
62
+ if overlap and i > 0 and len(text_chunk) + overlap <= chunk_size:
63
+ overlap_text_chunk = text_chunks[i - 1][-overlap:]
64
+ chunk = overlap_text_chunk + text_chunk
65
+ overlapped_chunks.append(chunk)
66
+ return overlapped_chunks
67
+
68
+
69
+ def recursive_semantic_chunking(
70
+ text: str, chunk_size: int = CHUNK_SIZE, overlap: int = OVERLAP
71
+ ) -> list[str]:
72
+ """Recursively chunks the input text into smaller segments based on semantic separators.
73
+ If a chunk exceeds the specified chunk size, it will be further split using the next available separator.
74
+
75
+ Args:
76
+ text (str): The input text to be chunked.
77
+ chunk_size (int, optional): The maximum size of each chunk. Defaults to CHUNK_SIZE.
78
+ overlap (int, optional): The number of overlapping characters between consecutive chunks. Defaults to OVERLAP.
79
+
80
+ Returns:
81
+ list[str]: A list of text chunks.
82
+ """
83
+ separators = ["\n\n", "\n", ".", "!", "?", ",", " "]
84
+
85
+ text_chunks = split_text(text, chunk_size, separators)
86
+
87
+ return overlap_chunked_texts(text_chunks, overlap, chunk_size)
docuquery/constants.py ADDED
@@ -0,0 +1,67 @@
1
+ import os
2
+
3
+
4
+ def _int_env(name: str, default: int) -> int:
5
+ value = os.getenv(name, str(default))
6
+ try:
7
+ return int(value)
8
+ except ValueError:
9
+ raise SystemExit(
10
+ f"{name} must be a whole number. The current value is '{value}'."
11
+ ) from None
12
+
13
+
14
+ DOCUQUERY_POSTGRES_URL = os.getenv(
15
+ "DOCUQUERY_POSTGRES_URL",
16
+ "postgresql://postgres:postgres@localhost:5432/docuquery_db",
17
+ )
18
+ DOCUQUERY_OPENAI_MODEL = os.getenv("DOCUQUERY_OPENAI_MODEL", "gpt-5.4-mini")
19
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
20
+ SENTENCE_TRANSFORMER_MODEL = os.getenv(
21
+ "SENTENCE_TRANSFORMER_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
22
+ )
23
+
24
+
25
+ CHUNK_SIZE = _int_env("DOCUQUERY_CHUNK_SIZE", 512)
26
+ OVERLAP = _int_env("DOCUQUERY_OVERLAP", 50)
27
+
28
+ if not 0 <= OVERLAP < CHUNK_SIZE:
29
+ raise SystemExit(
30
+ "DOCUQUERY_OVERLAP must be 0 or more, and less than DOCUQUERY_CHUNK_SIZE."
31
+ )
32
+
33
+ EMBEDDING_DIMENSION = 384 # must match SENTENCE_TRANSFORMER_MODEL's output size
34
+
35
+
36
+ SYSTEM_INSTRUCTIONS = """
37
+ You are an expert technical assistant. Answer the user's question accurately
38
+ using the provided tools. If the answer cannot be found,
39
+ say 'I cannot find the answer in the provided documents.'.
40
+
41
+ If you want to look up the documents, use the search tool. Feel free to do a multiple search
42
+ as needed to get the information you require from the documents.
43
+
44
+ There is a __list_documents tool that allows you to list documents with optional filtering and pagination.
45
+ Feel free to use it whenever you need to find documents. You can go over it, paginate, get more documents as needed.
46
+
47
+ There is a __count_documents tool that allows you to count the total number of documents matching a query.
48
+ Feel free to use it whenever you need to know how many documents match a specific search.
49
+
50
+ When using the search tool, provide a clear and concise query that will help retrieve the most relevant information.
51
+ If the user's question is about a specific document or set of documents, use the __list_documents tool first to identify them.
52
+ Then, use the search tool to find specific information within those documents.
53
+
54
+ User can ask about their session history and try to retrieve previous conversations using the list_sessions tool.
55
+
56
+ When a user requests for the session messages, you can use the get_session_messages tool to retrieve the messages for a specific session ID.
57
+
58
+ When call all theses tools. Ensure that you provide the necessary arguments and handle the responses appropriately. Do NOT provide
59
+ arguments that the functions cannot accept.
60
+
61
+ """
62
+
63
+ USER_PROMPT_TEMPLATE = """
64
+
65
+ CONTEXT:
66
+ {context}
67
+ """
docuquery/database.py ADDED
@@ -0,0 +1,385 @@
1
+ import typing
2
+ from typing import ClassVar
3
+
4
+ import psycopg
5
+ from pgvector.psycopg import register_vector
6
+ from psycopg.types.json import Jsonb
7
+
8
+ from docuquery.constants import DOCUQUERY_POSTGRES_URL, EMBEDDING_DIMENSION
9
+
10
+ DB_SQL_QUERY = f"""
11
+ -- 1. Enable the vector extension
12
+ CREATE EXTENSION IF NOT EXISTS vector;
13
+
14
+ -- 2. Create the documents table
15
+ CREATE TABLE IF NOT EXISTS document_chunks (
16
+ id BIGSERIAL PRIMARY KEY,
17
+ file_name TEXT NOT NULL,
18
+ chunk_index INT NOT NULL,
19
+ content TEXT NOT NULL,
20
+ embedding vector({EMBEDDING_DIMENSION})
21
+ );
22
+
23
+ -- 3. Create an HNSW index for lightning-fast vector searches
24
+ CREATE INDEX IF NOT EXISTS idx_chunks_hnsw
25
+ ON document_chunks
26
+ USING hnsw (embedding vector_cosine_ops)
27
+ WITH (m = 16, ef_construction = 200);
28
+
29
+ -- 4. Enable trigram matching and index file names for fuzzy name search
30
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_chunks_file_name_trgm
33
+ ON document_chunks
34
+ USING gin (file_name gin_trgm_ops);
35
+
36
+ -- 5. Conversations and their messages (stored in OpenAI message format)
37
+ CREATE TABLE IF NOT EXISTS conversations (
38
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
39
+ title TEXT,
40
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS conversation_messages (
45
+ id BIGSERIAL PRIMARY KEY,
46
+ conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
47
+ position INT NOT NULL,
48
+ role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')),
49
+ content TEXT,
50
+ tool_calls JSONB,
51
+ tool_call_id TEXT,
52
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
53
+ UNIQUE (conversation_id, position)
54
+ );
55
+ """
56
+
57
+
58
+ class Database:
59
+ MAX_PAGE_SIZE: ClassVar[int] = 100
60
+
61
+ def __get_connection(self) -> psycopg.Connection:
62
+ # Connect using standard environment variable DOCUQUERY_POSTGRES_URL
63
+ conn = psycopg.connect(DOCUQUERY_POSTGRES_URL)
64
+ # register_vector needs the extension to exist, so it can't run before init
65
+ register_vector(conn)
66
+ return conn
67
+
68
+ def initialize_database(self) -> None:
69
+ with psycopg.connect(DOCUQUERY_POSTGRES_URL) as conn:
70
+ with conn.cursor() as cur:
71
+ cur.execute(DB_SQL_QUERY)
72
+ conn.commit()
73
+
74
+ def replace_document(
75
+ self, file_name: str, chunks: list[str], embeddings: list[list[float]]
76
+ ) -> None:
77
+ with self.__get_connection() as conn:
78
+ with conn.cursor() as cur:
79
+ cur.execute(
80
+ "DELETE FROM document_chunks WHERE file_name = %s", (file_name,)
81
+ )
82
+ cur.executemany(
83
+ "INSERT INTO document_chunks (file_name, chunk_index, content, embedding) VALUES (%s, %s, %s, %s)",
84
+ [
85
+ (file_name, i, c, e)
86
+ for i, (c, e) in enumerate(zip(chunks, embeddings, strict=True))
87
+ ],
88
+ )
89
+ conn.commit()
90
+
91
+ def search_similar_chunks(
92
+ self, query_embedding: list[float], limit: int = 3
93
+ ) -> list[dict]:
94
+ with self.__get_connection() as conn, conn.cursor() as cur:
95
+ cur.execute(
96
+ """
97
+ SELECT file_name, content, 1 - (embedding <=> %s) AS similarity
98
+ FROM document_chunks
99
+ ORDER BY embedding <=> %s
100
+ LIMIT %s
101
+ """,
102
+ (query_embedding, query_embedding, limit),
103
+ )
104
+ rows = cur.fetchall()
105
+ return [
106
+ {"file_name": r[0], "content": r[1], "similarity": float(r[2])}
107
+ for r in rows
108
+ ]
109
+
110
+ # Allowed sort keys mapped to SQL, so user input is never put into the query directly
111
+ DOCUMENT_SORT_COLUMNS: ClassVar[dict[str, str]] = {
112
+ "file_name": "file_name",
113
+ "chunk_count": "chunk_count",
114
+ "total_characters": "total_characters",
115
+ "relevance": "relevance",
116
+ }
117
+
118
+ def __build_documents_query(
119
+ self,
120
+ file_name: str | None = None,
121
+ name_contains: str | None = None,
122
+ content_contains: str | None = None,
123
+ query_embedding: list[float] | None = None,
124
+ min_similarity: float = 0.3,
125
+ ) -> tuple[str, list]:
126
+ """Build the grouped documents query shared by list_documents and count_documents.
127
+
128
+ With `query_embedding`, a document matches when any of its chunks has a cosine
129
+ similarity of at least `min_similarity`, and `relevance` is its best chunk's similarity.
130
+ """
131
+ select_params = []
132
+ where, where_params = [], []
133
+ having, having_params = [], []
134
+
135
+ if query_embedding is not None:
136
+ relevance = "MAX(1 - (embedding <=> %s::vector))"
137
+ select_params.append(query_embedding)
138
+ # Distance is 1 - similarity, so similarity >= min means distance <= 1 - min
139
+ where.append(
140
+ "file_name IN (SELECT file_name FROM document_chunks WHERE embedding <=> %s::vector <= %s)"
141
+ )
142
+ where_params += [query_embedding, 1 - min_similarity]
143
+ else:
144
+ relevance = "NULL::float"
145
+
146
+ if file_name is not None:
147
+ where.append("file_name = %s")
148
+ where_params.append(file_name)
149
+ if name_contains is not None:
150
+ where.append("file_name ILIKE %s")
151
+ where_params.append(f"%{name_contains}%")
152
+ if content_contains is not None:
153
+ # Match documents with any chunk containing the text, but still count all their chunks
154
+ where.append(
155
+ "file_name IN (SELECT file_name FROM document_chunks WHERE content ILIKE %s)"
156
+ )
157
+ where_params.append(f"%{content_contains}%")
158
+
159
+ query = f"""
160
+ SELECT
161
+ file_name,
162
+ COUNT(*) AS chunk_count,
163
+ SUM(LENGTH(content)) AS total_characters,
164
+ {relevance} AS relevance
165
+ FROM document_chunks
166
+ """ # noqa: S608
167
+ if where:
168
+ query += " WHERE " + " AND ".join(where)
169
+ query += " GROUP BY file_name"
170
+ if having:
171
+ query += " HAVING " + " AND ".join(having)
172
+
173
+ return query, select_params + where_params + having_params
174
+
175
+ def list_documents(
176
+ self,
177
+ page: int = 1,
178
+ page_size: int = 20,
179
+ sort_by: str = "file_name",
180
+ descending: bool = False,
181
+ file_name: str | None = None,
182
+ name_contains: str | None = None,
183
+ content_contains: str | None = None,
184
+ query_embedding: list[float] | None = None,
185
+ min_similarity: float = 0.3,
186
+ ) -> list[dict]:
187
+ if page < 1:
188
+ raise ValueError("page must be 1 or greater")
189
+ if not 1 <= page_size <= self.MAX_PAGE_SIZE:
190
+ raise ValueError(f"page_size must be between 1 and {self.MAX_PAGE_SIZE}")
191
+ if sort_by not in self.DOCUMENT_SORT_COLUMNS:
192
+ raise ValueError(
193
+ f"sort_by must be one of {list(self.DOCUMENT_SORT_COLUMNS)}"
194
+ )
195
+ if sort_by == "relevance" and query_embedding is None:
196
+ raise ValueError("sort_by='relevance' requires query_embedding")
197
+
198
+ query, params = self.__build_documents_query(
199
+ file_name,
200
+ name_contains,
201
+ content_contains,
202
+ query_embedding,
203
+ min_similarity,
204
+ )
205
+ direction = "DESC" if descending else "ASC"
206
+ # file_name as a tie-breaker keeps page boundaries stable
207
+ query += f" ORDER BY {self.DOCUMENT_SORT_COLUMNS[sort_by]} {direction}, file_name ASC"
208
+ query += " LIMIT %s OFFSET %s"
209
+ params += [page_size, (page - 1) * page_size]
210
+
211
+ with self.__get_connection() as conn, conn.cursor() as cur:
212
+ cur.execute(query, params)
213
+ rows = cur.fetchall()
214
+ return [
215
+ {
216
+ "file_name": r[0],
217
+ "chunk_count": r[1],
218
+ "total_characters": r[2],
219
+ "relevance": None if r[3] is None else float(r[3]),
220
+ }
221
+ for r in rows
222
+ ]
223
+
224
+ @typing.override
225
+ def count_documents(
226
+ self,
227
+ file_name: str | None = None,
228
+ name_contains: str | None = None,
229
+ content_contains: str | None = None,
230
+ query_embedding: list[float] | None = None,
231
+ min_similarity: float = 0.3,
232
+ ) -> int:
233
+ query, params = self.__build_documents_query(
234
+ file_name,
235
+ name_contains,
236
+ content_contains,
237
+ query_embedding,
238
+ min_similarity,
239
+ )
240
+ with self.__get_connection() as conn, conn.cursor() as cur:
241
+ cur.execute(f"SELECT COUNT(*) FROM ({query}) AS documents", params) # noqa: S608
242
+ return cur.fetchone()[0]
243
+
244
+ with self.__get_connection() as conn, conn.cursor() as cur:
245
+ cur.execute(
246
+ """
247
+ SELECT
248
+ file_name,
249
+ COUNT(*) AS chunk_count,
250
+ SUM(LENGTH(content)) AS total_characters,
251
+ json_agg(
252
+ json_build_object('chunk_index', chunk_index, 'content', content)
253
+ ORDER BY chunk_index
254
+ ) AS chunks
255
+ FROM document_chunks
256
+ WHERE file_name ILIKE %(contains)s OR %(name)s <%% file_name
257
+ GROUP BY file_name
258
+ ORDER BY
259
+ LOWER(file_name) = LOWER(%(name)s) DESC,
260
+ file_name ILIKE %(prefix)s DESC,
261
+ file_name ASC
262
+ LIMIT %(limit)s
263
+ """,
264
+ params,
265
+ )
266
+ rows = cur.fetchall()
267
+ return [
268
+ {
269
+ "file_name": r[0],
270
+ "chunk_count": r[1],
271
+ "total_characters": r[2],
272
+ "chunks": r[3],
273
+ }
274
+ for r in rows
275
+ ]
276
+
277
+ def create_conversation(self, title: str | None = None) -> str:
278
+ with self.__get_connection() as conn:
279
+ with conn.cursor() as cur:
280
+ cur.execute(
281
+ "INSERT INTO conversations (title) VALUES (%s) RETURNING id",
282
+ (title,),
283
+ )
284
+ conversation_id = cur.fetchone()[0]
285
+ conn.commit()
286
+ return str(conversation_id)
287
+
288
+ def add_messages(self, conversation_id: str, messages: list[dict]) -> None:
289
+ """Append OpenAI-format messages to a conversation, after any already saved."""
290
+ with self.__get_connection() as conn:
291
+ with conn.cursor() as cur:
292
+ # Lock the conversation so concurrent appends can't take the same positions
293
+ cur.execute(
294
+ "UPDATE conversations SET updated_at = now() WHERE id = %s RETURNING id",
295
+ (conversation_id,),
296
+ )
297
+ if cur.fetchone() is None:
298
+ raise ValueError(f"Conversation {conversation_id} not found")
299
+
300
+ cur.execute(
301
+ "SELECT COALESCE(MAX(position), -1) FROM conversation_messages WHERE conversation_id = %s",
302
+ (conversation_id,),
303
+ )
304
+ next_position = cur.fetchone()[0] + 1
305
+
306
+ cur.executemany(
307
+ """
308
+ INSERT INTO conversation_messages
309
+ (conversation_id, position, role, content, tool_calls, tool_call_id)
310
+ VALUES (%s, %s, %s, %s, %s, %s)
311
+ """,
312
+ [
313
+ (
314
+ conversation_id,
315
+ next_position + i,
316
+ m["role"],
317
+ m.get("content"),
318
+ Jsonb(m["tool_calls"]) if m.get("tool_calls") else None,
319
+ m.get("tool_call_id"),
320
+ )
321
+ for i, m in enumerate(messages)
322
+ ],
323
+ )
324
+ conn.commit()
325
+
326
+ def get_conversation_by_id(self, conversation_id: str) -> dict | None:
327
+ """Return the conversation with its messages in order, ready to pass back to OpenAI."""
328
+ with self.__get_connection() as conn, conn.cursor() as cur:
329
+ cur.execute(
330
+ """
331
+ SELECT
332
+ c.id,
333
+ c.title,
334
+ c.created_at,
335
+ c.updated_at,
336
+ COALESCE(
337
+ json_agg(
338
+ json_strip_nulls(json_build_object(
339
+ 'role', m.role,
340
+ 'content', m.content,
341
+ 'tool_calls', m.tool_calls,
342
+ 'tool_call_id', m.tool_call_id
343
+ ))
344
+ ORDER BY m.position
345
+ ) FILTER (WHERE m.id IS NOT NULL),
346
+ '[]'
347
+ ) AS messages
348
+ FROM conversations c
349
+ LEFT JOIN conversation_messages m ON m.conversation_id = c.id
350
+ WHERE c.id = %s
351
+ GROUP BY c.id
352
+ """,
353
+ (conversation_id,),
354
+ )
355
+ row = cur.fetchone()
356
+
357
+ if row is None:
358
+ return None
359
+ return {
360
+ "id": str(row[0]),
361
+ "title": row[1],
362
+ "created_at": row[2],
363
+ "updated_at": row[3],
364
+ "messages": row[4],
365
+ }
366
+
367
+ def list_conversations(self, page: int = 1, page_size: int = 20) -> list[dict]:
368
+ """List conversations, most recently active first."""
369
+ if page < 1:
370
+ raise ValueError("page must be 1 or greater")
371
+ if not 1 <= page_size <= self.MAX_PAGE_SIZE:
372
+ raise ValueError(f"page_size must be between 1 and {self.MAX_PAGE_SIZE}")
373
+ with self.__get_connection() as conn:
374
+ with conn.cursor() as cur:
375
+ cur.execute(
376
+ """
377
+ SELECT id, title
378
+ FROM conversations
379
+ ORDER BY updated_at DESC, id
380
+ LIMIT %s OFFSET %s
381
+ """,
382
+ (page_size, (page - 1) * page_size),
383
+ )
384
+ rows = cur.fetchall()
385
+ return [{"id": str(r[0]), "title": r[1]} for r in rows]
docuquery/ingest.py ADDED
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ from pypdf import PdfReader
5
+ from sentence_transformers import SentenceTransformer
6
+
7
+ from docuquery.chunker import recursive_semantic_chunking
8
+ from docuquery.constants import SENTENCE_TRANSFORMER_MODEL
9
+ from docuquery.database import Database
10
+
11
+
12
+ class DocumentIngestor:
13
+ def __init__(self) -> None:
14
+ self.embedder = SentenceTransformer(SENTENCE_TRANSFORMER_MODEL)
15
+ self.db = Database()
16
+
17
+ def load_text(self, path: str) -> str:
18
+ if Path(path).suffix.lower() == ".pdf":
19
+ reader = PdfReader(path)
20
+ return "\n\n".join(page.extract_text() or "" for page in reader.pages)
21
+ try:
22
+ return Path(path).read_text(encoding="utf-8")
23
+ except UnicodeDecodeError:
24
+ raise click.ClickException(
25
+ f"'{path}' is not a PDF or a UTF-8 text file"
26
+ ) from None
27
+
28
+ def process_and_store_document(self, document_path: str) -> None:
29
+ """Process a document, chunk it, and store in database."""
30
+ click.echo(f"[*] Processing document: {document_path}")
31
+
32
+ content = self.load_text(document_path)
33
+
34
+ file_name = Path(document_path).name
35
+
36
+ if not content.strip():
37
+ raise click.ClickException(
38
+ f"'{document_path}' has no text. If it is a scanned PDF, use OCR first."
39
+ )
40
+
41
+ click.echo("[*] Chunking content...")
42
+
43
+ # Chunk the content
44
+ chunks = recursive_semantic_chunking(content)
45
+
46
+ click.echo(f"[*] Ingesting '{file_name}': Split into {len(chunks)} chunks.")
47
+ click.echo(f"[*] Generated {len(chunks)} chunks.")
48
+
49
+ embeddings = self.embedder.encode(chunks, show_progress_bar=True)
50
+ self.db.replace_document(file_name, chunks, embeddings)
51
+
52
+ click.secho(f"[+] Successfully indexed {file_name}!", fg="green")