chunks_router.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import json
  2. import logging
  3. import textwrap
  4. from copy import copy
  5. from typing import Any, Optional
  6. from uuid import UUID
  7. from fastapi import Body, Depends, Path, Query
  8. from core.base import (
  9. ChunkResponse,
  10. ChunkSearchSettings,
  11. GraphSearchSettings,
  12. R2RException,
  13. RunType,
  14. SearchSettings,
  15. UnprocessedChunk,
  16. UpdateChunk,
  17. select_search_filters,
  18. )
  19. from core.base.api.models import (
  20. GenericBooleanResponse,
  21. WrappedBooleanResponse,
  22. WrappedChunkResponse,
  23. WrappedChunksResponse,
  24. WrappedVectorSearchResponse,
  25. )
  26. from core.providers import (
  27. HatchetOrchestrationProvider,
  28. SimpleOrchestrationProvider,
  29. )
  30. from core.utils import generate_id
  31. from .base_router import BaseRouterV3
  32. logger = logging.getLogger()
  33. MAX_CHUNKS_PER_REQUEST = 1024 * 100
  34. class ChunksRouter(BaseRouterV3):
  35. def __init__(
  36. self,
  37. providers,
  38. services,
  39. orchestration_provider: (
  40. HatchetOrchestrationProvider | SimpleOrchestrationProvider
  41. ),
  42. run_type: RunType = RunType.INGESTION,
  43. ):
  44. super().__init__(providers, services, orchestration_provider, run_type)
  45. def _setup_routes(self):
  46. @self.router.post(
  47. "/chunks/search",
  48. summary="Search Chunks",
  49. openapi_extra={
  50. "x-codeSamples": [
  51. {
  52. "lang": "Python",
  53. "source": textwrap.dedent(
  54. """
  55. from r2r import R2RClient
  56. client = R2RClient("http://localhost:7272")
  57. response = client.chunks.search(
  58. query="search query",
  59. search_settings={
  60. "limit": 10
  61. }
  62. )
  63. """
  64. ),
  65. }
  66. ]
  67. },
  68. )
  69. @self.base_endpoint
  70. async def search_chunks(
  71. query: str = Body(...),
  72. search_settings: SearchSettings = Body(
  73. default_factory=SearchSettings,
  74. ),
  75. auth_user=Depends(self.providers.auth.auth_wrapper),
  76. ) -> WrappedVectorSearchResponse: # type: ignore
  77. # TODO - Deduplicate this code by sharing the code on the retrieval router
  78. """
  79. Perform a semantic search query over all stored chunks.
  80. This endpoint allows for complex filtering of search results using PostgreSQL-based queries.
  81. Filters can be applied to various fields such as document_id, and internal metadata values.
  82. Allowed operators include `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `in`, and `nin`.
  83. """
  84. search_settings.filters = select_search_filters(
  85. auth_user, search_settings
  86. )
  87. search_settings.graph_settings = GraphSearchSettings(enabled=False)
  88. results = await self.services["retrieval"].search(
  89. query=query,
  90. search_settings=search_settings,
  91. )
  92. return results["chunk_search_results"]
  93. @self.router.get(
  94. "/chunks/{id}",
  95. summary="Retrieve Chunk",
  96. openapi_extra={
  97. "x-codeSamples": [
  98. {
  99. "lang": "Python",
  100. "source": textwrap.dedent(
  101. """
  102. from r2r import R2RClient
  103. client = R2RClient("http://localhost:7272")
  104. response = client.chunks.retrieve(
  105. id="b4ac4dd6-5f27-596e-a55b-7cf242ca30aa"
  106. )
  107. """
  108. ),
  109. },
  110. {
  111. "lang": "JavaScript",
  112. "source": textwrap.dedent(
  113. """
  114. const { r2rClient } = require("r2r-js");
  115. const client = new r2rClient("http://localhost:7272");
  116. function main() {
  117. const response = await client.chunks.retrieve({
  118. id: "b4ac4dd6-5f27-596e-a55b-7cf242ca30aa"
  119. });
  120. }
  121. main();
  122. """
  123. ),
  124. },
  125. ]
  126. },
  127. )
  128. @self.base_endpoint
  129. async def retrieve_chunk(
  130. id: UUID = Path(...),
  131. auth_user=Depends(self.providers.auth.auth_wrapper),
  132. ) -> WrappedChunkResponse:
  133. """
  134. Get a specific chunk by its ID.
  135. Returns the chunk's content, metadata, and associated document/collection information.
  136. Users can only retrieve chunks they own or have access to through collections.
  137. """
  138. chunk = await self.services["ingestion"].get_chunk(id)
  139. if not chunk:
  140. raise R2RException("Chunk not found", 404)
  141. # # Check access rights
  142. # document = await self.services["management"].get_document(chunk.document_id)
  143. # TODO - Add collection ID check
  144. if not auth_user.is_superuser and str(auth_user.id) != str(
  145. chunk["owner_id"]
  146. ):
  147. raise R2RException("Not authorized to access this chunk", 403)
  148. return ChunkResponse( # type: ignore
  149. id=chunk["id"],
  150. document_id=chunk["document_id"],
  151. owner_id=chunk["owner_id"],
  152. collection_ids=chunk["collection_ids"],
  153. text=chunk["text"],
  154. metadata=chunk["metadata"],
  155. # vector = chunk["vector"] # TODO - Add include vector flag
  156. )
  157. @self.router.post(
  158. "/chunks/{id}",
  159. summary="Update Chunk",
  160. openapi_extra={
  161. "x-codeSamples": [
  162. {
  163. "lang": "Python",
  164. "source": textwrap.dedent(
  165. """
  166. from r2r import R2RClient
  167. client = R2RClient("http://localhost:7272")
  168. response = client.chunks.update(
  169. {
  170. "id": "b4ac4dd6-5f27-596e-a55b-7cf242ca30aa",
  171. "text": "Updated content",
  172. "metadata": {"key": "new value"}
  173. }
  174. )
  175. """
  176. ),
  177. },
  178. {
  179. "lang": "JavaScript",
  180. "source": textwrap.dedent(
  181. """
  182. const { r2rClient } = require("r2r-js");
  183. const client = new r2rClient("http://localhost:7272");
  184. function main() {
  185. const response = await client.chunks.update({
  186. id: "b4ac4dd6-5f27-596e-a55b-7cf242ca30aa",
  187. text: "Updated content",
  188. metadata: {key: "new value"}
  189. });
  190. }
  191. main();
  192. """
  193. ),
  194. },
  195. ]
  196. },
  197. )
  198. @self.base_endpoint
  199. async def update_chunk(
  200. id: UUID = Path(...),
  201. chunk_update: UpdateChunk = Body(...),
  202. # TODO: Run with orchestration?
  203. auth_user=Depends(self.providers.auth.auth_wrapper),
  204. ) -> WrappedChunkResponse:
  205. """
  206. Update an existing chunk's content and/or metadata.
  207. The chunk's vectors will be automatically recomputed based on the new content.
  208. Users can only update chunks they own unless they are superusers.
  209. """
  210. # Get the existing chunk to get its chunk_id
  211. existing_chunk = await self.services["ingestion"].get_chunk(
  212. chunk_update.id
  213. )
  214. if existing_chunk is None:
  215. raise R2RException(f"Chunk {chunk_update.id} not found", 404)
  216. workflow_input = {
  217. "document_id": str(existing_chunk["document_id"]),
  218. "id": str(chunk_update.id),
  219. "text": chunk_update.text,
  220. "metadata": chunk_update.metadata
  221. or existing_chunk["metadata"],
  222. "user": auth_user.model_dump_json(),
  223. }
  224. logger.info("Running chunk ingestion without orchestration.")
  225. from core.main.orchestration import simple_ingestion_factory
  226. # TODO - CLEAN THIS UP
  227. simple_ingestor = simple_ingestion_factory(
  228. self.services["ingestion"]
  229. )
  230. await simple_ingestor["update-chunk"](workflow_input)
  231. return ChunkResponse( # type: ignore
  232. id=chunk_update.id,
  233. document_id=existing_chunk["document_id"],
  234. owner_id=existing_chunk["owner_id"],
  235. collection_ids=existing_chunk["collection_ids"],
  236. text=chunk_update.text,
  237. metadata=chunk_update.metadata or existing_chunk["metadata"],
  238. # vector = existing_chunk.get('vector')
  239. )
  240. @self.router.delete(
  241. "/chunks/{id}",
  242. summary="Delete Chunk",
  243. openapi_extra={
  244. "x-codeSamples": [
  245. {
  246. "lang": "Python",
  247. "source": textwrap.dedent(
  248. """
  249. from r2r import R2RClient
  250. client = R2RClient("http://localhost:7272")
  251. response = client.chunks.delete(
  252. id="b4ac4dd6-5f27-596e-a55b-7cf242ca30aa"
  253. )
  254. """
  255. ),
  256. },
  257. {
  258. "lang": "JavaScript",
  259. "source": textwrap.dedent(
  260. """
  261. const { r2rClient } = require("r2r-js");
  262. const client = new r2rClient("http://localhost:7272");
  263. function main() {
  264. const response = await client.chunks.delete({
  265. id: "b4ac4dd6-5f27-596e-a55b-7cf242ca30aa"
  266. });
  267. }
  268. main();
  269. """
  270. ),
  271. },
  272. ]
  273. },
  274. )
  275. @self.base_endpoint
  276. async def delete_chunk(
  277. id: UUID = Path(...),
  278. auth_user=Depends(self.providers.auth.auth_wrapper),
  279. ) -> WrappedBooleanResponse:
  280. """
  281. Delete a specific chunk by ID.
  282. This permanently removes the chunk and its associated vector embeddings.
  283. The parent document remains unchanged. Users can only delete chunks they
  284. own unless they are superusers.
  285. """
  286. # Get the existing chunk to get its chunk_id
  287. existing_chunk = await self.services["ingestion"].get_chunk(id)
  288. if existing_chunk is None:
  289. raise R2RException(
  290. message=f"Chunk {id} not found", status_code=404
  291. )
  292. filters = {
  293. "$and": [
  294. {"owner_id": {"$eq": str(auth_user.id)}},
  295. {"chunk_id": {"$eq": str(id)}},
  296. ]
  297. }
  298. await self.services["management"].delete(filters=filters)
  299. return GenericBooleanResponse(success=True) # type: ignore
  300. @self.router.get(
  301. "/chunks",
  302. summary="List Chunks",
  303. openapi_extra={
  304. "x-codeSamples": [
  305. {
  306. "lang": "Python",
  307. "source": textwrap.dedent(
  308. """
  309. from r2r import R2RClient
  310. client = R2RClient("http://localhost:7272")
  311. response = client.chunks.list(
  312. metadata_filter={"key": "value"},
  313. include_vectors=False,
  314. offset=0,
  315. limit=10,
  316. )
  317. """
  318. ),
  319. },
  320. {
  321. "lang": "JavaScript",
  322. "source": textwrap.dedent(
  323. """
  324. const { r2rClient } = require("r2r-js");
  325. const client = new r2rClient("http://localhost:7272");
  326. function main() {
  327. const response = await client.chunks.list({
  328. metadataFilter: {key: "value"},
  329. includeVectors: false,
  330. offset: 0,
  331. limit: 10,
  332. });
  333. }
  334. main();
  335. """
  336. ),
  337. },
  338. ]
  339. },
  340. )
  341. @self.base_endpoint
  342. async def list_chunks(
  343. metadata_filter: Optional[str] = Query(
  344. None, description="Filter by metadata"
  345. ),
  346. include_vectors: bool = Query(
  347. False, description="Include vector data in response"
  348. ),
  349. offset: int = Query(
  350. 0,
  351. ge=0,
  352. description="Specifies the number of objects to skip. Defaults to 0.",
  353. ),
  354. limit: int = Query(
  355. 100,
  356. ge=1,
  357. le=1000,
  358. description="Specifies a limit on the number of objects to return, ranging between 1 and 100. Defaults to 100.",
  359. ),
  360. auth_user=Depends(self.providers.auth.auth_wrapper),
  361. ) -> WrappedChunksResponse:
  362. """
  363. List chunks with pagination support.
  364. Returns a paginated list of chunks that the user has access to.
  365. Results can be filtered and sorted based on various parameters.
  366. Vector embeddings are only included if specifically requested.
  367. Regular users can only list chunks they own or have access to through
  368. collections. Superusers can list all chunks in the system.
  369. """ # Build filters
  370. filters = {}
  371. # Add user access control filter
  372. if not auth_user.is_superuser:
  373. filters["owner_id"] = {"$eq": str(auth_user.id)}
  374. # Add metadata filters if provided
  375. if metadata_filter:
  376. metadata_filter = json.loads(metadata_filter)
  377. # Get chunks using the vector handler's list_chunks method
  378. results = await self.services["ingestion"].list_chunks(
  379. filters=filters,
  380. include_vectors=include_vectors,
  381. offset=offset,
  382. limit=limit,
  383. )
  384. # Convert to response format
  385. chunks = [
  386. ChunkResponse(
  387. id=chunk["id"],
  388. document_id=chunk["document_id"],
  389. owner_id=chunk["owner_id"],
  390. collection_ids=chunk["collection_ids"],
  391. text=chunk["text"],
  392. metadata=chunk["metadata"],
  393. vector=chunk.get("vector") if include_vectors else None,
  394. )
  395. for chunk in results["results"]
  396. ]
  397. return (chunks, results["page_info"]) # type: ignore