multi-embedding and chuncking (#25)
* ✨(backend) add evaluate-search-engine command I want to automize the search evaluation. This new command computes performance metrics. * ✨(backend) improve evaluation I add more data to my evaluations. * 📝(backend) add changelog add changelog and various fixes. * ✨(backend) add evaluation data from service-public.fr I need better data with longer content to work on chuncking * ✨(backend) handle multi-embedding I breack document content into peaces and embed each peace separatly. Search is them based on the mest match. * 📝(docs) add documentation I add documentation about chunking * 🚨(backend) fix things thigs were broken. I fixed this. * 📝(backend) documentation I document the documentation of it * 🚨(backend) fix rebase the rebase has messed things up. I fixed those things. * ♻️(backend) refactor language code handling and improve test cases I fix things to fix things * ♻️(backend) refactor I am doing refactoooooooooooor
This commit is contained in:
committed by
GitHub
parent
8b4566bd46
commit
8e491074ac
@@ -11,6 +11,8 @@ and this project adheres to
|
||||
## Added
|
||||
|
||||
- ✨(backend) add semantic search
|
||||
- ✨(backend) add multi-embedding and chunking
|
||||
- ✨(backend) add evaluation command
|
||||
- ✨(backend) add analyzers to full-text search
|
||||
- ✨(backend) handle french, english, german and dutch
|
||||
- ✨(backend) add evaluation command
|
||||
|
||||
@@ -13,6 +13,8 @@ These are the environment variables you can set for the `find-backend` container
|
||||
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| CHUNK_SIZE | approximate number of characters of document content chunks | 512 |
|
||||
| CHUNK_OVERLAP | approximate number of characters of document content overlapping | 50 |
|
||||
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_HOST | Host of the database | localhost |
|
||||
| DB_NAME | Name of the database | impress |
|
||||
|
||||
@@ -55,6 +55,8 @@ HYBRID_SEARCH_ENABLED = True
|
||||
HYBRID_SEARCH_WEIGHTS = 0.7,0.3
|
||||
|
||||
# Embedding
|
||||
CHUNK_SIZE=512
|
||||
CHUNK_OVERLAP=50
|
||||
EMBEDDING_API_PATH = https://embedding.api.example.com/full/path/
|
||||
EMBEDDING_API_KEY = your-embedding-api-key
|
||||
EMBEDDING_REQUEST_TIMEOUT = 10
|
||||
@@ -66,6 +68,14 @@ The hybrid search computes a score for full-text and semantic search and combine
|
||||
|
||||
You need to use an embedding api similar to https://albert.api.etalab.gouv.fr/documentation#tag/Embeddings/operation/embeddings_v1_embeddings_post.
|
||||
|
||||
### document chunking
|
||||
|
||||
The indexing process embeds documents by converting their content into vector representations (embeddings). When a document exceeds the character dimension defined by CHUNK_SIZE, it's divided into smaller segments (chunks), with each chunk embedded independently. Each chunk must be smaller than the embedding model's context window .
|
||||
|
||||
The chunking algorithm works recursively. It attempts to create the largest possible segments first, then subdivides them further if they still exceed the size limit defined by CHUNK_SIZE. Segments can share overlapping content between them (set CHUNK_OVERLAP=0 to disable overlapping).
|
||||
|
||||
During the search, the matching of a document is the matching of its best chunk.
|
||||
|
||||
## trigrams
|
||||
|
||||
Find uses trigrams to improve the robustness of the full text search engine to spelling variations and errors. It can be configured by two environment variables.
|
||||
|
||||
@@ -55,3 +55,7 @@ OIDC_RS_ENCRYPTION_KEY_TYPE="RSA"
|
||||
HYBRID_SEARCH_ENABLED=True
|
||||
EMBEDDING_API_KEY=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
EMBEDDING_API_PATH=https://albert.api.etalab.gouv.fr/v1/embeddings
|
||||
|
||||
## Multi-embedding: chunk documents and embed each chunk
|
||||
CHUNK_SIZE=512
|
||||
CHUNK_OVERLAP=50
|
||||
|
||||
+2052
File diff suppressed because one or more lines are too long
+6
@@ -0,0 +1,6 @@
|
||||
queries = [
|
||||
{
|
||||
"q": "comment isoler sa maison ?",
|
||||
"expected_document_ids": [23],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,387 @@
|
||||
documents = [
|
||||
# Cours d'histoire-géo
|
||||
{
|
||||
"id": 1,
|
||||
"title": "La Révolution Française 1789",
|
||||
"content": "La Révolution française débute le 14 juillet 1789 avec la prise de la Bastille, symbole de l'absolutisme royal. Les causes sont multiples : crise financière, famine, influence des Lumières et inégalités sociales criantes. L'Assemblée Constituante abolit les privilèges le 4 août et adopte la Déclaration des Droits de l'Homme le 26 août. Le roi Louis XVI est exécuté en 1793 après la proclamation de la République. Cette révolution transforme profondément la France et influence l'Europe entière.",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "La Première Guerre Mondiale",
|
||||
"content": "La Première Guerre mondiale (1914-1918) oppose les Alliés (France, Royaume-Uni, Russie) aux Empires centraux (Allemagne, Autriche-Hongrie). L'assassinat de l'archiduc François-Ferdinand à Sarajevo déclenche le conflit par le jeu des alliances. La guerre de tranchées caractérise le front occidental avec Verdun et la Somme. Les nouvelles armes (gaz, tanks, aviation) causent 18 millions de morts. Le traité de Versailles en 1919 redessine la carte de l'Europe.",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "La Décolonisation en Afrique",
|
||||
"content": "La décolonisation de l'Afrique s'accélère après 1945, affaiblie par la Seconde Guerre mondiale. Le Ghana obtient son indépendance en 1957, suivi de 17 pays en 1960, 'l'année de l'Afrique'. Le processus varie : négociation pacifique (Afrique francophone) ou luttes armées (Algérie, Kenya). Les frontières héritées de la colonisation créent des tensions ethniques. Les nouveaux États font face à des défis économiques et politiques majeurs.",
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Le Réchauffement Climatique",
|
||||
"content": "Le réchauffement climatique désigne l'augmentation de la température moyenne terrestre causée par les émissions de gaz à effet de serre. Depuis l'ère industrielle, la température a augmenté de 1,2°C. Les conséquences incluent fonte des glaciers, montée des eaux, événements météorologiques extrêmes et migrations climatiques. Les accords de Paris en 2015 visent à limiter le réchauffement à 1,5°C. La transition énergétique et les énergies renouvelables sont essentielles.",
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "L'Empire Romain",
|
||||
"content": "L'Empire romain domine le bassin méditerranéen pendant cinq siècles, de 27 avant J.-C. à 476 après J.-C. Auguste, premier empereur, établit la Pax Romana qui apporte prospérité et stabilité. Rome construit routes, aqueducs et monuments grandioses comme le Colisée. Le christianisme devient religion d'État en 380. Les invasions barbares et les crises internes provoquent la chute de l'Empire d'Occident en 476.",
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "La Renaissance Européenne",
|
||||
"content": "La Renaissance (XIVe-XVIe siècles) marque un renouveau artistique, scientifique et intellectuel en Europe. Née en Italie avec Florence et les Médicis, elle redécouvre l'Antiquité gréco-romaine. Léonard de Vinci, Michel-Ange et Raphaël révolutionnent l'art. Gutenberg invente l'imprimerie en 1450, facilitant la diffusion des savoirs. Les grandes découvertes ouvrent le monde avec Colomb et Magellan. L'humanisme place l'homme au centre de la réflexion.",
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "La Guerre Froide",
|
||||
"content": "La Guerre Froide (1947-1991) oppose les États-Unis capitalistes à l'URSS communiste sans conflit direct. Le rideau de fer divise l'Europe entre blocs occidental et oriental. Les crises majeures incluent Berlin (1948, 1961), Cuba (1962) et le Vietnam. La course aux armements nucléaires menace l'humanité. La détente des années 1970 laisse place aux tensions des années 1980. La chute du mur de Berlin en 1989 symbolise la fin de la Guerre Froide.",
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "L'Égypte Ancienne",
|
||||
"content": "L'Égypte ancienne prospère pendant 3000 ans le long du Nil, de 3100 avant J.-C. à 30 avant J.-C. Les pharaons sont considérés comme des dieux vivants. Les pyramides de Gizeh, notamment celle de Khéops, témoignent de prouesses architecturales. L'écriture hiéroglyphique permet l'administration et la transmission des savoirs. Le culte des morts et la momification préparent à la vie après la mort. Cléopâtre VII est la dernière souveraine avant la conquête romaine.",
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "La Mondialisation",
|
||||
"content": "La mondialisation désigne l'intégration croissante des économies et sociétés à l'échelle planétaire. Accélérée depuis 1990, elle s'appuie sur les nouvelles technologies, les transports et la libéralisation des échanges. Les firmes multinationales organisent la production mondiale. Les flux commerciaux, financiers et migratoires s'intensifient. Ce processus crée des interdépendances mais accentue aussi les inégalités entre pays développés et en développement.",
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "La Conquête Spatiale",
|
||||
"content": "La conquête spatiale débute en 1957 avec le satellite soviétique Spoutnik. Youri Gagarine devient le premier homme dans l'espace en 1961. Les Américains répondent avec le programme Apollo : Neil Armstrong marche sur la Lune en 1969. La Station Spatiale Internationale symbolise la coopération internationale depuis 1998. Aujourd'hui, les ambitions incluent Mars, et des entreprises privées comme SpaceX révolutionnent l'accès à l'espace.",
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Les Métropoles Mondiales",
|
||||
"content": "Les métropoles mondiales comme New York, Londres, Tokyo et Paris concentrent pouvoir économique, politique et culturel. Elles abritent sièges sociaux des multinationales, bourses financières et institutions internationales. Ces villes sont des nœuds de transport et communication globaux. Elles attirent flux migratoires et talents créant diversité mais aussi gentrification. Les mégapoles du Sud (Mumbai, São Paulo) connaissent une urbanisation rapide avec des défis d'infrastructures.",
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "La Seconde Guerre Mondiale",
|
||||
"content": "La Seconde Guerre mondiale (1939-1945) est le conflit le plus meurtrier de l'histoire avec 60 millions de morts. Hitler envahit la Pologne en septembre 1939, déclenchant la guerre. La France capitule en 1940 tandis que le Royaume-Uni résiste. L'Allemagne attaque l'URSS en 1941 et le Japon Pearl Harbor. Le débarquement en Normandie en 1944 libère l'Europe. La Shoah extermine 6 millions de Juifs. Le conflit se termine avec les bombes atomiques sur Hiroshima et Nagasaki.",
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Le Développement Durable",
|
||||
"content": "Le développement durable vise à concilier croissance économique, protection environnementale et équité sociale. Défini au Sommet de Rio en 1992, il répond aux besoins présents sans compromettre ceux des générations futures. Les 17 Objectifs de Développement Durable de l'ONU incluent éradication de la pauvreté, éducation, égalité des genres et action climatique. Les énergies renouvelables, l'économie circulaire et la consommation responsable sont des leviers essentiels.",
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "La Chine contemporaine",
|
||||
"content": "La Chine est devenue la deuxième économie mondiale grâce aux réformes de Deng Xiaoping en 1978. L'ouverture au capitalisme maintient le régime communiste du Parti unique. Le pays est 'l'usine du monde' avec des zones économiques spéciales. Les nouvelles routes de la soie étendent son influence internationale. Avec 1,4 milliard d'habitants, la Chine fait face à des défis environnementaux, démographiques et sociaux majeurs.",
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Les Grandes Découvertes",
|
||||
"content": "Les grandes découvertes (XVe-XVIe siècles) voient les Européens explorer le monde. Christophe Colomb atteint l'Amérique en 1492, croyant trouver les Indes. Vasco de Gama ouvre la route des Indes en 1498. Magellan réalise le premier tour du monde en 1522. Ces expéditions sont motivées par la recherche d'épices, d'or et l'évangélisation. Elles inaugurent la colonisation européenne et l'échange colombien qui transforment les civilisations.",
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "L'Union Européenne",
|
||||
"content": "L'Union européenne naît du désir de paix après 1945. Le traité de Rome en 1957 crée la CEE avec six membres fondateurs. L'UE compte aujourd'hui 27 États après le Brexit. Le marché unique permet libre circulation des biens, services, capitaux et personnes. L'euro est adopté par 20 pays. L'UE fait face à des défis : crise migratoire, euroscepticisme et divergences économiques entre Nord et Sud.",
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"title": "La Révolution Industrielle",
|
||||
"content": "La révolution industrielle débute en Angleterre au XVIIIe siècle avec la machine à vapeur de Watt. L'industrie textile mécanisée précède la métallurgie et les chemins de fer. Le charbon et la vapeur fournissent l'énergie nécessaire. L'urbanisation s'accélère avec l'exode rural vers les usines. Les conditions ouvrières misérables suscitent les mouvements sociaux et le syndicalisme. Cette révolution transforme radicalement l'économie et la société européennes.",
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"title": "Les Inégalités Nord-Sud",
|
||||
"content": "Les inégalités Nord-Sud opposent pays développés et pays en développement. L'Indice de Développement Humain mesure richesse, éducation et santé. Les pays du Nord concentrent richesses et technologies tandis que le Sud subit pauvreté et dépendance économique. L'héritage colonial, l'endettement et les termes de l'échange inégaux perpétuent ces écarts. Les pays émergents (BRICS) remettent en question cette division binaire.",
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "Le Siècle des Lumières",
|
||||
"content": "Le Siècle des Lumières (XVIIIe siècle) est un mouvement intellectuel européen promouvant raison, science et progrès. Voltaire critique l'intolérance religieuse, Montesquieu théorise la séparation des pouvoirs, Rousseau défend la souveraineté populaire. L'Encyclopédie de Diderot et d'Alembert diffuse les savoirs. Ces philosophes remettent en cause l'absolutisme et les privilèges. Leurs idées inspirent les révolutions américaine et française.",
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "Les Ressources Énergétiques",
|
||||
"content": "Les ressources énergétiques sont inégalement réparties sur la planète. Les énergies fossiles (pétrole, gaz, charbon) dominent mais sont non renouvelables et polluantes. Le Moyen-Orient concentre 48% des réserves pétrolières mondiales. La Russie utilise le gaz comme arme géopolitique. Les énergies renouvelables (solaire, éolien, hydraulique) progressent face au réchauffement climatique. La transition énergétique est un défi majeur du XXIe siècle pour assurer souveraineté et durabilité.",
|
||||
},
|
||||
# Animaux
|
||||
{
|
||||
"id": 21,
|
||||
"title": "Le Lion d'Afrique",
|
||||
"content": "Le lion est le plus grand félin d'Afrique et vit en groupe social appelé troupe. Les mâles se distinguent par leur majestueuse crinière qui protège leur cou lors des combats. Les lions chassent principalement au crépuscule, les lionnes étant les principales chasseuses. Ils peuvent rugir jusqu'à 8 kilomètres de distance pour marquer leur territoire. Malheureusement, leur population a diminué de 43% en 20 ans.",
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"title": "Le Dauphin",
|
||||
"content": "Les dauphins sont parmi les animaux les plus intelligents de la planète. Ils communiquent par des clics et sifflements complexes, chaque individu possédant sa propre signature sonore. Vivant en groupes sociaux sophistiqués, ils chassent en coordination et s'entraident. Les dauphins peuvent nager jusqu'à 30 km/h et plonger à 300 mètres de profondeur. Leur cerveau possède plus de circonvolutions que celui de l'homme.",
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"title": "L'Éléphant d'Asie",
|
||||
"content": "L'éléphant d'Asie est légèrement plus petit que son cousin africain mais tout aussi majestueux. Ces herbivores peuvent consommer jusqu'à 150 kg de végétation par jour et boire 140 litres d'eau. Leur trompe contient 40 000 muscles et leur sert à manger, boire, se laver et communiquer. Les éléphants vivent en matriarcat dirigé par la femelle la plus âgée. Leur mémoire exceptionnelle leur permet de se souvenir des points d'eau sur de vastes territoires.",
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"title": "Le Colibri",
|
||||
"content": "Le colibri est le plus petit oiseau du monde, certaines espèces ne pesant que 2 grammes. Ses ailes battent jusqu'à 80 fois par seconde, lui permettant de faire du vol stationnaire et même de voler en arrière. Son métabolisme est si rapide qu'il doit manger jusqu'à deux fois son poids en nectar quotidiennement. Le colibri peut visiter 1000 fleurs par jour. Ses couleurs iridescentes changent selon l'angle de la lumière.",
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"title": "L'Ours polaire",
|
||||
"content": "L'ours polaire est le plus grand carnivore terrestre, pouvant peser jusqu'à 800 kg. Sa fourrure blanche et sa peau noire lui permettent d'absorber la chaleur du soleil. Son odorat est si développé qu'il peut détecter un phoque sous 1 mètre de glace à 1,5 km. Le réchauffement climatique menace gravement son habitat.",
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"title": "Le Caméléon",
|
||||
"content": "Le caméléon possède la capacité extraordinaire de changer de couleur selon son humeur, la température et la communication sociale. Ses yeux peuvent bouger indépendamment l'un de l'autre à 360 degrés. Sa langue peut s'étendre jusqu'à deux fois la longueur de son corps pour capturer des insectes. Certaines espèces mesurent seulement 3 cm tandis que d'autres atteignent 70 cm. Ils se déplacent lentement et de manière saccadée pour imiter le mouvement des feuilles.",
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"title": "Le Manchot Empereur",
|
||||
"content": "Le manchot empereur survit aux conditions les plus extrêmes de la planète en Antarctique. Les mâles couvent l'œuf unique pendant 64 jours dans des températures atteignant -40°C sans manger. Ils se regroupent en tortue pour se protéger du froid, tournant régulièrement pour partager la chaleur. Ces oiseaux peuvent plonger jusqu'à 500 mètres de profondeur et retenir leur respiration 22 minutes. Leur parade nuptiale comprend des chants complexes et synchronisés.",
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"title": "Le Requin Blanc",
|
||||
"content": "Le grand requin blanc est l'un des prédateurs marins les plus redoutés et fascinants. Il peut atteindre 6 mètres de long et peser plus de 2 tonnes. Ses dents triangulaires et dentelées se renouvellent constamment tout au long de sa vie. Il peut détecter une goutte de sang dans 100 litres d'eau grâce à son odorat exceptionnel. Contrairement à sa réputation, il attaque rarement l'homme, préférant les phoques et les otaries.",
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"title": "L'Abeille",
|
||||
"content": "Les abeilles jouent un rôle crucial dans la pollinisation de 80% des plantes à fleurs. Une ruche peut contenir jusqu'à 60 000 abeilles organisées en société matriarcale autour de la reine. Les ouvrières communiquent la localisation des fleurs par une danse complexe en forme de huit. Une abeille produit environ 1/12 de cuillère à café de miel dans sa vie. Leur déclin inquiétant menace notre sécurité alimentaire.",
|
||||
},
|
||||
{
|
||||
"id": 30,
|
||||
"title": "Le Guépard",
|
||||
"content": "Le guépard est l'animal terrestre le plus rapide, capable d'atteindre 110 km/h en quelques secondes. Contrairement aux autres félins, il ne peut pas rétracter complètement ses griffes, ce qui lui donne une meilleure adhérence. Sa queue longue lui sert de balancier lors des changements de direction. Son corps élancé et ses poumons surdimensionnés sont optimisés pour la course. Il chasse le jour pour éviter les autres prédateurs plus puissants.",
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"title": "La Pieuvre",
|
||||
"content": "La pieuvre possède trois cœurs et un sang bleu à base de cuivre. Son intelligence remarquable lui permet de résoudre des problèmes complexes et d'utiliser des outils. Chacun de ses huit bras contient des neurones, lui donnant une forme de pensée décentralisée. Elle peut changer de couleur et de texture instantanément pour se camoufler. Certaines espèces peuvent passer à travers des ouvertures de la taille d'une pièce de monnaie.",
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"title": "Le Koala",
|
||||
"content": "Le koala dort jusqu'à 20 heures par jour pour économiser l'énergie nécessaire à digérer l'eucalyptus toxique. Il se nourrit exclusivement de feuilles d'eucalyptus, pouvant distinguer entre 600 espèces différentes. Son système digestif unique contient des bactéries spéciales pour neutraliser les toxines. Les bébés koalas naissent de la taille d'un haricot et restent 6 mois dans la poche maternelle. Ils ne boivent presque jamais, tirant l'eau des feuilles qu'ils consomment.",
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"title": "Le Gorille des Montagnes",
|
||||
"content": "Le gorille des montagnes est l'un de nos plus proches parents, partageant 98% de notre ADN. Les mâles silverback peuvent peser jusqu'à 200 kg et sont d'une force exceptionnelle. Malgré leur puissance, ce sont des animaux pacifiques et végétariens. Ils vivent en groupes familiaux dirigés par un mâle dominant et communiquent par 25 vocalisations différentes. Moins de 1000 individus survivent dans les forêts d'Afrique centrale.",
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"title": "Le Papillon Monarque",
|
||||
"content": "Le papillon monarque effectue une migration annuelle de 4000 km entre le Canada et le Mexique. Aucun individu ne complète le voyage entier, il faut 4 générations pour accomplir le cycle. Ils utilisent le soleil comme boussole et possèdent une horloge circadienne sophistiquée. Leur couleur orange vif avertit les prédateurs de leur toxicité acquise en mangeant de l'asclépiade. Des millions de papillons se regroupent dans quelques forêts mexicaines en hiver.",
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"title": "Le Loup Gris",
|
||||
"content": "Le loup gris vit en meute organisée hiérarchiquement autour d'un couple alpha. Ces chasseurs coopératifs peuvent traquer des proies bien plus grandes qu'eux grâce à leur coordination. Ils communiquent par hurlements pouvant porter jusqu'à 10 km dans les forêts. Un loup peut parcourir 70 km en une nuit à la recherche de nourriture. Leur réintroduction dans certains écosystèmes a démontré leur rôle crucial dans l'équilibre naturel.",
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"title": "Le Kangourou Roux",
|
||||
"content": "Le kangourou roux est le plus grand marsupial au monde, pouvant atteindre 2 mètres de haut. Il peut sauter jusqu'à 9 mètres de long et maintenir une vitesse de 50 km/h. Sa queue musclée lui sert de trépied au repos et de balancier en mouvement. Les femelles peuvent retarder le développement d'un embryon si les conditions sont défavorables. Parfaitement adapté au climat aride australien, il peut passer des mois sans boire.",
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"title": "La Baleine à Bosse",
|
||||
"content": "Les baleines à bosse produisent des chants complexes pouvant durer 20 minutes et s'entendre à des centaines de kilomètres. Les mâles d'une même région partagent le même chant qui évolue chaque année. Malgré leur masse de 30 tonnes, elles peuvent sauter entièrement hors de l'eau. Elles migrent sur 25 000 km par an entre zones d'alimentation et de reproduction. Leurs nageoires pectorales sont les plus longues de tous les cétacés.",
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"title": "Le Serpent Python",
|
||||
"content": "Le python réticulé peut atteindre 9 mètres de long, ce qui en fait l'un des plus longs serpents. Il tue sa proie par constriction, resserrant son étreinte à chaque expiration de la victime. Ces serpents peuvent passer des mois sans manger après avoir avalé une grande proie. Leurs organes sensoriels thermiques leur permettent de détecter la chaleur corporelle dans l'obscurité. Contrairement aux idées reçues, ils ne peuvent pas avaler un humain adulte.",
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"title": "Le Hibou Grand-Duc",
|
||||
"content": "Le hibou grand-duc est le plus grand rapace nocturne d'Europe avec une envergure de 2 mètres. Son vol silencieux est rendu possible par la structure spéciale de ses plumes. Il peut tourner sa tête à 270 degrés grâce à 14 vertèbres cervicales. Sa vision nocturne est 100 fois plus sensible que celle de l'humain. Son hululement puissant peut s'entendre jusqu'à 2 km. Il chasse des proies variées, du mulot au renardeau.",
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"title": "Le Paresseux",
|
||||
"content": "Le paresseux se déplace si lentement que des algues poussent sur sa fourrure, lui donnant un camouflage verdâtre. Il descend de son arbre une fois par semaine pour déféquer, moment où il est le plus vulnérable. Son métabolisme est le plus lent de tous les non-hibernants. Il peut tourner sa tête à 270 degrés pour surveiller les prédateurs. Les paresseux passent 90% de leur vie suspendus la tête en bas et peuvent même dormir ainsi.",
|
||||
},
|
||||
# Recettes de cuisine
|
||||
{
|
||||
"id": 41,
|
||||
"title": "Bœuf Bourguignon",
|
||||
"content": "Le bœuf bourguignon est un plat mijoté emblématique de la cuisine française. Coupez 1,5 kg de bœuf en cubes et faites-les revenir dans du beurre. Ajoutez 2 carottes, 2 oignons, 3 gousses d'ail et un bouquet garni. Versez 75 cl de vin rouge de Bourgogne et 25 cl de bouillon. Laissez mijoter 3 heures à feu doux. Ajoutez 200g de champignons et 150g de lardons dorés. Servez avec des pommes de terre vapeur ou des tagliatelles fraîches.",
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Ratatouille Provençale",
|
||||
"content": "Tranchez finement 2 aubergines, 2 courgettes, 2 poivrons, 4 tomates et 1 oignon. Dans une cocotte, faites revenir l'oignon et l'ail dans de l'huile d'olive. Ajoutez les légumes par couches en alternant. Assaisonnez avec du thym, du romarin, sel et poivre. Couvrez et laissez mijoter 40 minutes à feu doux. La ratatouille peut se déguster chaude ou froide, accompagnée de riz ou de pain grillé.",
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"title": "Coq au Vin",
|
||||
"content": "Découpez un coq ou un poulet fermier en morceaux. Faites mariner 12 heures dans 75 cl de vin rouge avec carottes, oignons et aromates. Égouttez et faites dorer les morceaux avec 100g de lardons. Flambez au cognac puis ajoutez la marinade filtrée. Laissez mijoter 1h30. Ajoutez des champignons et des petits oignons grelots. Liez la sauce avec du beurre manié. Servez avec des croûtons aillés.",
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"title": "Tarte Tatin",
|
||||
"content": "Dans un moule à tarte, faites fondre 100g de beurre avec 100g de sucre pour créer un caramel. Disposez 6 pommes Reinette coupées en quartiers en rosace serrée. Enfournez 30 minutes à 180°C. Recouvrez de pâte feuilletée et enfournez 25 minutes supplémentaires. Laissez tiédir 10 minutes avant de retourner sur un plat. Servez tiède avec de la crème fraîche ou une boule de glace vanille.",
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"title": "Bouillabaisse Marseillaise",
|
||||
"content": "Faites suer dans l'huile d'olive 2 oignons, 4 tomates, 4 gousses d'ail et du fenouil. Ajoutez safran, thym, laurier et zeste d'orange. Versez 2 litres de fumet de poisson et portez à ébullition. Ajoutez 2 kg de poissons variés (rascasse, grondin, rouget) coupés en morceaux. Cuisez 15 minutes à gros bouillons. Servez avec des croûtons, de la rouille et du gruyère râpé.",
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"title": "Quiche Lorraine",
|
||||
"content": "Garnissez un moule de pâte brisée. Faites revenir 200g de lardons fumés sans matière grasse. Battez 4 œufs avec 30 cl de crème fraîche, sel, poivre et noix de muscade. Disposez les lardons sur la pâte et versez l'appareil. Ajoutez éventuellement 100g de gruyère râpé. Enfournez 35 minutes à 180°C jusqu'à ce que la garniture soit dorée et gonflée. Servez tiède avec une salade verte.",
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"title": "Blanquette de Veau",
|
||||
"content": "Coupez 1,2 kg d'épaule de veau en cubes. Blanchissez-les 5 minutes à l'eau bouillante puis rincez. Remettez dans une cocotte avec carottes, oignons piqués de clous de girofle et bouquet garni. Couvrez d'eau et laissez mijoter 1h30. Préparez un roux avec beurre et farine, ajoutez le bouillon filtré. Liez avec 2 jaunes d'œufs et 20 cl de crème. Ajoutez champignons et oignons grelots. Servez avec du riz.",
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"title": "Crêpes Bretonnes",
|
||||
"content": "Mélangez 250g de farine, 4 œufs, 50 cl de lait et une pincée de sel. Ajoutez 50g de beurre fondu et laissez reposer 1 heure. Huilez légèrement une poêle chaude et versez une louche de pâte. Faites cuire 2 minutes de chaque côté jusqu'à ce que la crêpe soit dorée. Garnissez de sucre, confiture, chocolat ou jambon-fromage selon vos envies. Servez immédiatement.",
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"title": "Cassoulet Toulousain",
|
||||
"content": "Faites tremper 500g de haricots blancs 12 heures. Cuisez-les 1 heure avec carottes, oignons et bouquet garni. Faites revenir 4 cuisses de canard confites, 400g de saucisse de Toulouse et 200g de poitrine fumée. Mélangez haricots et viandes dans une cocotte avec la graisse de canard. Couvrez de chapelure et enfournez 1 heure à 160°C. Cassez la croûte plusieurs fois pendant la cuisson.",
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"title": "Soufflé au Fromage",
|
||||
"content": "Préparez une béchamel avec 40g de beurre, 40g de farine et 25 cl de lait. Hors du feu, incorporez 150g de gruyère râpé et 4 jaunes d'œufs. Montez 5 blancs en neige ferme avec une pincée de sel. Incorporez délicatement les blancs à la préparation. Versez dans des ramequins beurrés et farinés. Enfournez 20 minutes à 180°C sans ouvrir le four. Servez immédiatement.",
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"title": "Pot-au-feu",
|
||||
"content": "Dans une grande marmite, placez 1,5 kg de viande de bœuf (gîte, paleron, plat de côtes). Couvrez d'eau froide et portez à ébullition en écumant. Ajoutez 4 carottes, 4 poireaux, 2 navets, 2 oignons piqués de clous de girofle et un bouquet garni. Laissez mijoter 3 heures à feu doux. Servez le bouillon en entrée puis la viande et les légumes avec cornichons, moutarde et gros sel.",
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"title": "Mousse au Chocolat",
|
||||
"content": "Faites fondre 200g de chocolat noir au bain-marie. Séparez 6 œufs et incorporez les jaunes au chocolat fondu tiède. Montez les blancs en neige ferme avec une pincée de sel et 20g de sucre. Incorporez délicatement les blancs au chocolat en trois fois avec une spatule. Répartissez dans des verrines et réfrigérez 4 heures minimum. Décorez de copeaux de chocolat ou de chantilly.",
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"title": "Gratin Dauphinois",
|
||||
"content": "Épluchez et émincez finement 1,5 kg de pommes de terre. Frottez un plat à gratin avec une gousse d'ail. Disposez les pommes de terre en couches en assaisonnant de sel, poivre et noix de muscade. Mélangez 50 cl de crème avec 25 cl de lait et versez sur les pommes de terre. Ajoutez quelques noisettes de beurre. Enfournez 1h15 à 160°C jusqu'à obtenir une croûte dorée.",
|
||||
},
|
||||
{
|
||||
"id": 54,
|
||||
"title": "Salade de légumes",
|
||||
"content": "Disposez sur un plat 4 tomates en quartiers, 1 concombre émincé, 1 poivron coupé, 200g de haricots verts cuits, 4 œufs durs, 100g d'olives noires et 8 filets d'anchois. Préparez une vinaigrette avec huile d'olive, vinaigre de vin, moutarde, ail et basilic. Arrosez généreusement et servez frais. Certains ajoutent des artichauts ou des fèves.",
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"title": "Tarte au Citron Meringuée",
|
||||
"content": "Garnissez un moule de pâte sablée et faites-la cuire à blanc 15 minutes. Préparez une crème avec 4 jaunes d'œufs, 150g de sucre, le zeste et jus de 3 citrons. Cuisez à feu doux en remuant jusqu'à épaississement. Versez sur la pâte. Montez 4 blancs en neige avec 100g de sucre. Couvrez la tarte de meringue en formant des pointes. Enfournez 10 minutes à 150°C pour dorer.",
|
||||
},
|
||||
{
|
||||
"id": 56,
|
||||
"title": "Magret de Canard aux Figues",
|
||||
"content": "Quadrillez la peau de 2 magrets sans entamer la chair. Salez et poivrez. Posez-les côté peau dans une poêle froide. Faites cuire 7 minutes puis retournez pour 4 minutes (rosé). Réservez au chaud. Dans la graisse, faites revenir 8 figues coupées en deux avec 2 cuillères de miel et un trait de vinaigre balsamique. Tranchez les magrets et nappez de sauce aux figues.",
|
||||
},
|
||||
{
|
||||
"id": 57,
|
||||
"title": "Clafoutis aux Cerises",
|
||||
"content": "Disposez 500g de cerises lavées et équeutées (traditionnellement non dénoyautées) dans un plat beurré. Battez 4 œufs avec 100g de sucre jusqu'à ce que le mélange blanchisse. Ajoutez 100g de farine, 50 cl de lait et une pincée de sel. Versez l'appareil sur les cerises. Enfournez 45 minutes à 180°C. Saupoudrez de sucre glace et servez tiède ou froid.",
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"title": "Fondue Savoyarde",
|
||||
"content": "Frottez un caquelon avec une gousse d'ail coupée. Versez 15 cl de vin blanc sec et faites chauffer. Ajoutez 400g de comté, 400g de beaufort et 200g de reblochon râpés. Remuez en forme de 8 jusqu'à ce que le fromage fonde. Ajoutez 5 cl de kirsch et du poivre. Maintenez au chaud sur un réchaud. Trempez des cubes de pain rassis piqués sur des fourchettes.",
|
||||
},
|
||||
{
|
||||
"id": 59,
|
||||
"title": "Crème Brûlée",
|
||||
"content": "Faites chauffer 50 cl de crème avec une gousse de vanille fendue. Battez 6 jaunes d'œufs avec 100g de sucre. Versez la crème chaude en remuant puis filtrez. Répartissez dans des ramequins. Cuisez au bain-marie 40 minutes à 150°C. Réfrigérez 4 heures. Saupoudrez généreusement de sucre et caramélisez au chalumeau ou sous le gril. Laissez durcir 2 minutes avant de servir.",
|
||||
},
|
||||
{
|
||||
"id": 60,
|
||||
"title": "Tartare de Bœuf",
|
||||
"content": "Hachez finement 600g de filet de bœuf bien frais au couteau. Ajoutez 2 échalotes ciselées, 2 cuillères de câpres, 4 cornichons hachés, persil et ciboulette. Assaisonnez avec moutarde, sauce Worcestershire, Tabasco, sel et poivre. Incorporez 2 jaunes d'œufs et un filet d'huile d'olive. Formez des dômes et servez avec des frites maison et une salade verte. Se prépare au dernier moment.",
|
||||
},
|
||||
# Formes artistiques
|
||||
{
|
||||
"id": 61,
|
||||
"title": "La Musique",
|
||||
"content": "La musique est l'art d'organiser les sons dans le temps selon le rythme, la mélodie et l'harmonie. Présente dans toutes les cultures, elle accompagne les rites, les émotions et les récits humains depuis la préhistoire. Des modes antiques grecs au contrepoint baroque, du jazz à l'électro, elle évolue sans cesse. Les compositeurs explorent les timbres et les structures tandis que l’improvisation garde la spontanéité vivante. La musique relie mathématique, émotion et mouvement.",
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"title": "Le Cinéma",
|
||||
"content": "Le cinéma est l'art de raconter des histoires par le mouvement des images et le son. Né à la fin du XIXe siècle avec les frères Lumière, il a rapidement fusionné technique et poésie. Le montage, la lumière et la mise en scène en font un art total mêlant littérature, théâtre et musique. Du muet de Chaplin au cinéma numérique, chaque époque invente un nouveau langage visuel. Le cinéma explore la mémoire, les rêves et la condition humaine à travers l’écran.",
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"title": "La Danse",
|
||||
"content": "La danse est l’art du mouvement du corps dans l’espace et le temps, souvent accompagné de musique. Elle exprime des émotions, raconte des histoires ou célèbre des rites. Des danses tribales aux ballets classiques, des danses contemporaines au hip-hop, chaque culture invente ses gestes et son rythme. La chorégraphie unit discipline et liberté, le corps devenant un instrument expressif. La danse relie énergie, esthétique et communication non verbale.",
|
||||
},
|
||||
{
|
||||
"id": 64,
|
||||
"title": "La Peinture à l'Huile",
|
||||
"content": "La peinture à l'huile est une technique artistique utilisant des pigments mélangés à de l'huile siccative, généralement de lin. Inventée au XVe siècle et perfectionnée par les maîtres flamands comme Van Eyck, elle permet des glacis subtils et des dégradés lumineux. Le temps de séchage lent offre la possibilité de travailler les transitions et les détails. Les grands maîtres comme Rembrandt, Vermeer et plus tard les impressionnistes ont exploité ses possibilités. Cette technique reste aujourd'hui la plus prisée pour la peinture de chevalet.",
|
||||
},
|
||||
{
|
||||
"id": 65,
|
||||
"title": "La Sculpture sur Pierre",
|
||||
"content": "La sculpture sur pierre est l'un des arts les plus anciens de l'humanité, remontant à la préhistoire. Le sculpteur taille directement dans le marbre, le granit ou le calcaire avec des ciseaux et des masses. Michel-Ange considérait que la statue existait déjà dans le bloc, il suffisait de libérer la forme. Cette technique soustractive ne pardonne pas l'erreur. Les œuvres comme le David ou la Pietà démontrent la capacité de donner vie et émotion à la pierre froide.",
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"title": "La Calligraphie",
|
||||
"content": "La calligraphie est l'art de former les lettres avec beauté et harmonie. En Occident, les moines copistes médiévaux ont perfectionné l'onciale et la gothique. En Asie, la calligraphie chinoise et japonaise est considérée comme la forme d'art la plus pure, où chaque trait exprime l'énergie et l'esprit de l'artiste. L'outil traditionnel est le pinceau ou le calame. La maîtrise nécessite des années de pratique pour contrôler la pression, la vitesse et le rythme.",
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"title": "La Photographie",
|
||||
"content": "La photographie transforme la capture d'images en expression créative depuis le XIXe siècle. Des pionniers comme Ansel Adams et Henri Cartier-Bresson ont élevé le médium au rang d'art majeur. La composition, la lumière, le cadrage et le moment décisif sont essentiels. Le passage au numérique a ouvert de nouvelles possibilités de post-traitement. La photographie d'art explore tous les genres : portrait, paysage, abstrait, documentaire et conceptuel.",
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"title": "La Danse Contemporaine",
|
||||
"content": "La danse contemporaine émerge au XXe siècle comme rupture avec le ballet classique. Des chorégraphes comme Martha Graham, Merce Cunningham et Pina Bausch explorent de nouveaux langages corporels. Cette forme privilégie l'expression émotionnelle, la liberté de mouvement et l'improvisation. Le corps devient un outil de questionnement social et politique. Les spectacles intègrent souvent d'autres disciplines comme la vidéo, le théâtre et la musique expérimentale.",
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"title": "L'Origami",
|
||||
"content": "L'origami est l'art japonais du pliage de papier, transformant une feuille plane en sculpture tridimensionnelle sans couper ni coller. Pratiqué depuis le VIe siècle au Japon, il était d'abord réservé aux cérémonies religieuses. Les modèles traditionnels incluent la grue (symbole de paix), la grenouille et la fleur. L'origami moderne explore la complexité mathématique avec des créations hyperréalistes. Cette discipline développe patience, précision et compréhension spatiale.",
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"title": "La Mosaïque",
|
||||
"content": "La mosaïque assemble de petits fragments colorés (tesselles) de pierre, céramique ou verre pour créer des images et motifs. Les Romains et Byzantins ont porté cet art à son apogée avec les splendeurs de Ravenne et de Constantinople. Chaque tesselle est posée individuellement sur un support avec du mortier. Les jeux de lumière sur les tesselles de verre créent des effets lumineux uniques. Gaudi a réinventé la mosaïque moderne avec le trencadis au Parc Güell.",
|
||||
},
|
||||
{
|
||||
"id": 71,
|
||||
"title": "Le Théâtre",
|
||||
"content": "Le théâtre est à la fois un art de la représentation et un lieu de rencontre sociale. Né dans l'Antiquité grecque avec les tragédies d'Eschyle et Sophocle, il explore les grandes questions humaines. Le Moyen Âge voit l'essor des mystères religieux, tandis que la Renaissance célèbre Shakespeare et Molière. Le théâtre moderne expérimente avec le réalisme, l'absurde et le théâtre de l'opprimé. Il combine texte, jeu d'acteur, décor et lumière pour créer une expérience immersive.",
|
||||
},
|
||||
{
|
||||
"id": 72,
|
||||
"title": "Le Vitrail",
|
||||
"content": "Le vitrail assemble des morceaux de verre coloré maintenus par des baguettes de plomb pour créer des compositions lumineuses. Au Moyen Âge, les cathédrales gothiques comme Chartres transforment la lumière divine en récits bibliques. Les maîtres verriers maîtrisent la chimie des oxydes métalliques pour obtenir des couleurs intenses. Chaque pièce est taillée selon le carton préparatoire puis sertie. Le XXe siècle voit Chagall et Soulages réinventer cet art millénaire.",
|
||||
},
|
||||
{
|
||||
"id": 73,
|
||||
"title": "La Gravure",
|
||||
"content": "La gravure est une technique d'impression où l'artiste incise une matrice (bois, métal, pierre) pour créer des estampes multiples. La xylogravie (bois) est la plus ancienne, utilisée par Dürer et les estampes japonaises ukiyo-e. La taille-douce (métal) comprend l'eau-forte, l'aquatinte et le burin, prisées par Rembrandt et Goya. La lithographie, inventée en 1796, permet des nuances subtiles exploitées par Toulouse-Lautrec. Chaque tirage est numéroté et signé par l'artiste.",
|
||||
},
|
||||
# full text french analyzer articles
|
||||
{
|
||||
"id": 74,
|
||||
"title": "L'éléphant",
|
||||
"content": "L'éléphant s'est échappé",
|
||||
},
|
||||
{
|
||||
"id": 75,
|
||||
"title": "Foot",
|
||||
"content": "Le foot est un sport populaire",
|
||||
},
|
||||
{
|
||||
"id": 76,
|
||||
"title": "Il va courir",
|
||||
"content": "Il va courir",
|
||||
},
|
||||
]
|
||||
@@ -10,12 +10,9 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from opensearchpy.exceptions import NotFoundError
|
||||
|
||||
from core.models import get_opensearch_index_name
|
||||
from core.services.opensearch import (
|
||||
check_hybrid_search_enabled,
|
||||
embed_text,
|
||||
format_document,
|
||||
opensearch_client,
|
||||
)
|
||||
from core.services.indexing import chunk_document
|
||||
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
from core.utils import get_language_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,14 +66,27 @@ def reindex_with_embedding(index_name, batch_size=500, scroll="10m"):
|
||||
"query": {
|
||||
"bool": {
|
||||
"should": [
|
||||
{"bool": {"must_not": {"exists": {"field": "embedding"}}}},
|
||||
{
|
||||
"bool": {
|
||||
"must_not": {
|
||||
"term": {
|
||||
"embedding_model": settings.EMBEDDING_API_MODEL_NAME
|
||||
"must_not": [
|
||||
{
|
||||
"nested": {
|
||||
"path": "chunks",
|
||||
"query": {"match_all": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"bool": {
|
||||
"must_not": [
|
||||
{
|
||||
"term": {
|
||||
"embedding_model": settings.EMBEDDING_API_MODEL_NAME
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
],
|
||||
@@ -85,17 +95,17 @@ def reindex_with_embedding(index_name, batch_size=500, scroll="10m"):
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
nb_failed_embedding = 0
|
||||
nb_success_embedding = 0
|
||||
while len(page["hits"]["hits"]) > 0:
|
||||
actions = []
|
||||
for hit in page["hits"]["hits"]:
|
||||
source = hit["_source"]
|
||||
embedding = embed_text(
|
||||
format_document(source.get("title", ""), source.get("content", ""))
|
||||
chunks = chunk_document(
|
||||
get_language_value(source, "title"),
|
||||
get_language_value(source, "content"),
|
||||
)
|
||||
if embedding:
|
||||
if chunks:
|
||||
actions.append(
|
||||
{
|
||||
"update": {
|
||||
@@ -110,7 +120,7 @@ def reindex_with_embedding(index_name, batch_size=500, scroll="10m"):
|
||||
actions.append(
|
||||
{
|
||||
"doc": {
|
||||
"embedding": embedding,
|
||||
"chunks": chunks,
|
||||
"embedding_model": settings.EMBEDDING_API_MODEL_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""OpenSearch embedding utilities."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def embed_text(text):
|
||||
"""
|
||||
Get embedding vector for the given text from any OpenAI-compatible embedding API
|
||||
"""
|
||||
logger.info("embed: '%s'", text)
|
||||
|
||||
response = requests.post(
|
||||
settings.EMBEDDING_API_PATH,
|
||||
headers={"Authorization": f"Bearer {settings.EMBEDDING_API_KEY}>"},
|
||||
json={
|
||||
"input": text,
|
||||
"model": settings.EMBEDDING_API_MODEL_NAME,
|
||||
"dimensions": settings.EMBEDDING_DIMENSION,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=settings.EMBEDDING_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
logger.warning("embedding API request failed: %s", str(e))
|
||||
return None
|
||||
|
||||
try:
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.warning("unexpected embedding response format: %s", response.text)
|
||||
return None
|
||||
|
||||
return embedding
|
||||
@@ -0,0 +1,125 @@
|
||||
"""OpenSearch indexing utilities."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from opensearchpy.exceptions import NotFoundError
|
||||
from py3langid.langid import MODEL_FILE, LanguageIdentifier
|
||||
|
||||
from core.services.opensearch_configuration import (
|
||||
ANALYZERS,
|
||||
FILTERS,
|
||||
MAPPINGS,
|
||||
)
|
||||
|
||||
from .embedding import embed_text
|
||||
from .opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# see https://pypi.org/project/py3langid/
|
||||
LANGUAGE_IDENTIFIER = LanguageIdentifier.from_pickled_model(MODEL_FILE, norm_probs=True)
|
||||
LANGUAGE_IDENTIFIER.set_languages(["en", "fr", "de", "nl"])
|
||||
|
||||
TEXT_SPLITER = RecursiveCharacterTextSplitter(
|
||||
chunk_size=settings.CHUNK_SIZE,
|
||||
chunk_overlap=settings.CHUNK_OVERLAP,
|
||||
)
|
||||
|
||||
|
||||
def ensure_index_exists(index_name):
|
||||
"""Create index if it does not exist"""
|
||||
try:
|
||||
opensearch_client().indices.get(index=index_name)
|
||||
except NotFoundError:
|
||||
logger.info("Creating index: %s", index_name)
|
||||
opensearch_client().indices.create(
|
||||
index=index_name,
|
||||
body={
|
||||
"settings": {
|
||||
"index.knn": True,
|
||||
"analysis": {
|
||||
"analyzer": ANALYZERS,
|
||||
"filter": FILTERS,
|
||||
},
|
||||
},
|
||||
"mappings": MAPPINGS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def prepare_document_for_indexing(document):
|
||||
"""Prepare document for indexing using nested language structure and handle embedding"""
|
||||
language_code = detect_language_code(f"{document['title']} {document['content']}")
|
||||
return {
|
||||
"id": document["id"],
|
||||
f"title.{language_code}": document["title"],
|
||||
f"content.{language_code}": document["content"],
|
||||
"embedding_model": settings.EMBEDDING_API_MODEL_NAME
|
||||
if check_hybrid_search_enabled()
|
||||
else None,
|
||||
"chunks": chunk_document(
|
||||
document["title"],
|
||||
document["content"],
|
||||
)
|
||||
if check_hybrid_search_enabled()
|
||||
else None,
|
||||
"depth": document["depth"],
|
||||
"path": document["path"],
|
||||
"numchild": document["numchild"],
|
||||
"created_at": document["created_at"],
|
||||
"updated_at": document["updated_at"],
|
||||
"size": document["size"],
|
||||
"users": document["users"],
|
||||
"groups": document["groups"],
|
||||
"reach": document["reach"],
|
||||
"is_active": document["is_active"],
|
||||
}
|
||||
|
||||
|
||||
def chunk_document(title, content):
|
||||
"""
|
||||
Chunk a document into multiple pieces and embed them.
|
||||
"""
|
||||
chunks = []
|
||||
for idx, chunked_content in enumerate(TEXT_SPLITER.split_text(content)):
|
||||
embedding = embed_text(format_document(title, chunked_content))
|
||||
|
||||
if not embedding:
|
||||
logger.warning(
|
||||
"Failed to embed chunk %d of document '%s'. Document embedding is skipped",
|
||||
idx,
|
||||
title,
|
||||
)
|
||||
# if embedding fails for any chunk, we skip chunking the document
|
||||
return None
|
||||
|
||||
chunks.append(
|
||||
{
|
||||
"index": idx,
|
||||
"content": chunked_content,
|
||||
"embedding": embedding,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Document %s chunked into %d pieces", title, len(chunks))
|
||||
return chunks
|
||||
|
||||
|
||||
def format_document(title, content):
|
||||
"""Get the embedding input format for a document"""
|
||||
return f"<{title}>:<{content}>"
|
||||
|
||||
|
||||
def detect_language_code(text):
|
||||
"""Detect the language code of the document content."""
|
||||
|
||||
detected_code, confidence = LANGUAGE_IDENTIFIER.classify(text)
|
||||
|
||||
if confidence < settings.LANGUAGE_DETECTION_CONFIDENCE_THRESHOLD:
|
||||
return settings.UNDETERMINED_LANGUAGE_CODE
|
||||
|
||||
return detected_code
|
||||
@@ -1,23 +1,13 @@
|
||||
"""Opensearch related utils."""
|
||||
"""OpenSearch common utilities."""
|
||||
|
||||
import logging
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from opensearchpy import OpenSearch
|
||||
from opensearchpy.exceptions import NotFoundError
|
||||
from py3langid.langid import MODEL_FILE, LanguageIdentifier
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from core import enums
|
||||
from core.services.opensearch_configuration import (
|
||||
ANALYZERS,
|
||||
FILTERS,
|
||||
MAPPINGS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -28,9 +18,6 @@ REQUIRED_ENV_VARIABLES = [
|
||||
"OPENSEARCH_PASSWORD",
|
||||
"OPENSEARCH_USE_SSL",
|
||||
]
|
||||
# see https://pypi.org/project/py3langid/
|
||||
LANGUAGE_IDENTIFIER = LanguageIdentifier.from_pickled_model(MODEL_FILE, norm_probs=True)
|
||||
LANGUAGE_IDENTIFIER.set_languages(["en", "fr", "de", "nl"])
|
||||
|
||||
|
||||
@cache
|
||||
@@ -55,293 +42,6 @@ def opensearch_client():
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
q,
|
||||
nb_results,
|
||||
order_by,
|
||||
order_direction,
|
||||
search_indices,
|
||||
reach,
|
||||
visited,
|
||||
user_sub,
|
||||
groups,
|
||||
):
|
||||
"""Perform an OpenSearch search"""
|
||||
query = get_query(
|
||||
q=q,
|
||||
nb_results=nb_results,
|
||||
reach=reach,
|
||||
visited=visited,
|
||||
user_sub=user_sub,
|
||||
groups=groups,
|
||||
)
|
||||
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
|
||||
index=",".join(search_indices),
|
||||
body={
|
||||
"_source": enums.SOURCE_FIELDS, # limit the fields to return
|
||||
"script_fields": {
|
||||
"number_of_users": {"script": {"source": "doc['users'].size()"}},
|
||||
"number_of_groups": {"script": {"source": "doc['groups'].size()"}},
|
||||
},
|
||||
"sort": get_sort(
|
||||
query_keys=query.keys(),
|
||||
order_by=order_by,
|
||||
order_direction=order_direction,
|
||||
),
|
||||
"size": nb_results,
|
||||
# Compute query
|
||||
"query": query,
|
||||
},
|
||||
params=get_params(query_keys=query.keys()),
|
||||
# disable=unexpected-keyword-arg because
|
||||
# ignore_unavailable is not in the the method declaration
|
||||
ignore_unavailable=True,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def get_query( # noqa : PLR0913
|
||||
q, nb_results, reach, visited, user_sub, groups
|
||||
):
|
||||
"""Build OpenSearch query body based on parameters"""
|
||||
filter_ = get_filter(reach, visited, user_sub, groups)
|
||||
|
||||
if q == "*":
|
||||
logger.info("Performing match_all query")
|
||||
return {
|
||||
"bool": {
|
||||
"must": {"match_all": {}},
|
||||
"filter": {"bool": {"filter": filter_}},
|
||||
},
|
||||
}
|
||||
|
||||
hybrid_search_enabled = check_hybrid_search_enabled()
|
||||
if hybrid_search_enabled:
|
||||
embedding = embed_text(q)
|
||||
else:
|
||||
embedding = None
|
||||
|
||||
if not embedding:
|
||||
logger.info("Performing full-text search without embedding: %s", q)
|
||||
return {
|
||||
"bool": {
|
||||
"must": get_full_text_query(q),
|
||||
"filter": filter_,
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Performing hybrid search with embedding: %s", q)
|
||||
return {
|
||||
"hybrid": {
|
||||
"queries": [
|
||||
{
|
||||
"bool": {
|
||||
"must": get_full_text_query(q),
|
||||
"filter": filter_,
|
||||
}
|
||||
},
|
||||
{
|
||||
"bool": {
|
||||
"must": {
|
||||
"knn": {
|
||||
"embedding": {
|
||||
"vector": embedding,
|
||||
"k": nb_results,
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": filter_,
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_full_text_query(q):
|
||||
"""Build OpenSearch full-text query"""
|
||||
return {
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"multi_match": {
|
||||
"query": q,
|
||||
"fields": [
|
||||
"title.*.text^3",
|
||||
"content.*",
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"multi_match": {
|
||||
"query": q,
|
||||
"fields": [
|
||||
"title.*.text.trigrams^3",
|
||||
"content.*.trigrams",
|
||||
],
|
||||
"boost": settings.TRIGRAMS_BOOST,
|
||||
"minimum_should_match": settings.TRIGRAMS_MINIMUM_SHOULD_MATCH,
|
||||
}
|
||||
},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_filter(reach, visited, user_sub, groups):
|
||||
"""Build OpenSearch filter"""
|
||||
filters = [
|
||||
{"term": {"is_active": True}}, # filter out inactive documents
|
||||
# Access control filters
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
# Public or authenticated (not restricted)
|
||||
{
|
||||
"bool": {
|
||||
"must_not": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED},
|
||||
},
|
||||
"must": {
|
||||
"terms": {"_id": sorted(visited)},
|
||||
},
|
||||
}
|
||||
},
|
||||
# Restricted: either user or group must match
|
||||
{"term": {enums.USERS: user_sub}},
|
||||
{"terms": {enums.GROUPS: groups}},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
# Optional reach filter
|
||||
if reach is not None:
|
||||
filters.append({"term": {enums.REACH: reach}})
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def get_sort(query_keys, order_by, order_direction):
|
||||
"""Build OpenSearch sort clause"""
|
||||
# Add sorting logic based on relevance or specified field
|
||||
if "hybrid" in query_keys:
|
||||
# sorting by other field than "_score" is not supported in hybrid search
|
||||
# see: https://github.com/opensearch-project/neural-search/issues/866
|
||||
return {"_score": {"order": order_direction}}
|
||||
if order_by == enums.RELEVANCE:
|
||||
return {"_score": {"order": order_direction}}
|
||||
|
||||
return {order_by: {"order": order_direction}}
|
||||
|
||||
|
||||
def get_params(query_keys):
|
||||
"""Build OpenSearch search parameters"""
|
||||
if "hybrid" in query_keys:
|
||||
return {"search_pipeline": settings.HYBRID_SEARCH_PIPELINE_ID}
|
||||
return {}
|
||||
|
||||
|
||||
def format_document(title, content):
|
||||
"""Get the embedding input format for a document"""
|
||||
return f"<{title}>:<{content}>"
|
||||
|
||||
|
||||
def embed_text(text):
|
||||
"""
|
||||
Get embedding vector for the given text from any OpenAI-compatible embedding API
|
||||
"""
|
||||
logger.info("embed: '%s'", text)
|
||||
|
||||
response = requests.post(
|
||||
settings.EMBEDDING_API_PATH,
|
||||
headers={"Authorization": f"Bearer {settings.EMBEDDING_API_KEY}>"},
|
||||
json={
|
||||
"input": text,
|
||||
"model": settings.EMBEDDING_API_MODEL_NAME,
|
||||
"dimensions": settings.EMBEDDING_DIMENSION,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=settings.EMBEDDING_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
logger.warning("embedding API request failed: %s", str(e))
|
||||
return None
|
||||
|
||||
try:
|
||||
embedding = response.json()["data"][0]["embedding"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.warning("unexpected embedding response format: %s", response.text)
|
||||
return None
|
||||
|
||||
return embedding
|
||||
|
||||
|
||||
def prepare_document_for_indexing(document):
|
||||
"""Prepare document for indexing using nested language structure and handle embedding"""
|
||||
|
||||
language_code = detect_language_code(f"{document['title']} {document['content']}")
|
||||
return {
|
||||
"id": document["id"],
|
||||
f"title.{language_code}": document["title"],
|
||||
f"content.{language_code}": document["content"],
|
||||
"embedding": embed_text(format_document(document["title"], document["content"]))
|
||||
if check_hybrid_search_enabled()
|
||||
else None,
|
||||
"embedding_model": settings.EMBEDDING_API_MODEL_NAME
|
||||
if check_hybrid_search_enabled()
|
||||
else None,
|
||||
"depth": document["depth"],
|
||||
"path": document["path"],
|
||||
"numchild": document["numchild"],
|
||||
"created_at": document["created_at"],
|
||||
"updated_at": document["updated_at"],
|
||||
"size": document["size"],
|
||||
"users": document["users"],
|
||||
"groups": document["groups"],
|
||||
"reach": document["reach"],
|
||||
"is_active": document["is_active"],
|
||||
}
|
||||
|
||||
|
||||
def detect_language_code(text):
|
||||
"""Detect the language code of the document content."""
|
||||
|
||||
detected_code, confidence = LANGUAGE_IDENTIFIER.classify(text)
|
||||
|
||||
if confidence < settings.LANGUAGE_DETECTION_CONFIDENCE_THRESHOLD:
|
||||
return settings.UNDETERMINED_LANGUAGE_CODE
|
||||
|
||||
return detected_code
|
||||
|
||||
|
||||
def ensure_index_exists(index_name):
|
||||
"""Create index if it does not exist"""
|
||||
try:
|
||||
opensearch_client().indices.get(index=index_name)
|
||||
except NotFoundError:
|
||||
logger.info("Creating index: %s", index_name)
|
||||
opensearch_client().indices.create(
|
||||
index=index_name,
|
||||
body={
|
||||
"settings": {
|
||||
"index.knn": True,
|
||||
"analysis": {
|
||||
"analyzer": ANALYZERS,
|
||||
"filter": FILTERS,
|
||||
},
|
||||
},
|
||||
"mappings": MAPPINGS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def check_hybrid_search_enabled():
|
||||
"""Check that all required environment variables are set for hybrid search."""
|
||||
|
||||
@@ -269,16 +269,21 @@ MAPPINGS = {
|
||||
"groups": {"type": "keyword"},
|
||||
"reach": {"type": "keyword"},
|
||||
"is_active": {"type": "boolean"},
|
||||
"embedding": {
|
||||
# for simplicity, embedding is always present but is empty
|
||||
# when hybrid search is disabled
|
||||
"type": "knn_vector",
|
||||
"dimension": settings.EMBEDDING_DIMENSION,
|
||||
"method": {
|
||||
"engine": "lucene",
|
||||
"space_type": "l2",
|
||||
"name": "hnsw",
|
||||
"parameters": {},
|
||||
"chunks": {
|
||||
"type": "nested",
|
||||
"properties": {
|
||||
"index": {"type": "integer"},
|
||||
"content": {"type": "text"},
|
||||
"embedding": {
|
||||
"type": "knn_vector",
|
||||
"dimension": settings.EMBEDDING_DIMENSION,
|
||||
"method": {
|
||||
"engine": "lucene",
|
||||
"space_type": "l2",
|
||||
"name": "hnsw",
|
||||
"parameters": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"embedding_model": {"type": "keyword"},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""OpenSearch search utilities."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core import enums
|
||||
|
||||
from .embedding import embed_text
|
||||
from .opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
q,
|
||||
nb_results,
|
||||
order_by,
|
||||
order_direction,
|
||||
search_indices,
|
||||
reach,
|
||||
visited,
|
||||
user_sub,
|
||||
groups,
|
||||
):
|
||||
"""Perform an OpenSearch search"""
|
||||
query = get_query(
|
||||
q=q,
|
||||
nb_results=nb_results,
|
||||
reach=reach,
|
||||
visited=visited,
|
||||
user_sub=user_sub,
|
||||
groups=groups,
|
||||
)
|
||||
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
|
||||
index=",".join(search_indices),
|
||||
body={
|
||||
"_source": enums.SOURCE_FIELDS, # limit the fields to return
|
||||
"script_fields": {
|
||||
"number_of_users": {"script": {"source": "doc['users'].size()"}},
|
||||
"number_of_groups": {"script": {"source": "doc['groups'].size()"}},
|
||||
},
|
||||
"sort": get_sort(
|
||||
query_keys=query.keys(),
|
||||
order_by=order_by,
|
||||
order_direction=order_direction,
|
||||
),
|
||||
"size": nb_results,
|
||||
# Compute query
|
||||
"query": query,
|
||||
},
|
||||
params=get_params(query_keys=query.keys()),
|
||||
# disable=unexpected-keyword-arg because
|
||||
# ignore_unavailable is not in the the method declaration
|
||||
ignore_unavailable=True,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def get_query( # noqa : PLR0913
|
||||
q, nb_results, reach, visited, user_sub, groups
|
||||
):
|
||||
"""Build OpenSearch query body based on parameters"""
|
||||
filter_ = get_filter(reach, visited, user_sub, groups)
|
||||
|
||||
if q == "*":
|
||||
logger.info("Performing match_all query")
|
||||
return {
|
||||
"bool": {
|
||||
"must": {"match_all": {}},
|
||||
"filter": {"bool": {"filter": filter_}},
|
||||
},
|
||||
}
|
||||
|
||||
hybrid_search_enabled = check_hybrid_search_enabled()
|
||||
if hybrid_search_enabled:
|
||||
q_vector = embed_text(q)
|
||||
else:
|
||||
q_vector = None
|
||||
|
||||
if not q_vector:
|
||||
logger.info("Performing full-text search without embedding: %s", q)
|
||||
return get_full_text_query(q, filter_)
|
||||
|
||||
logger.info("Performing hybrid search with embedding: %s", q)
|
||||
return {
|
||||
"hybrid": {
|
||||
"queries": [
|
||||
get_full_text_query(q, filter_),
|
||||
get_semantic_search_query(q_vector, filter_, nb_results),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_semantic_search_query(q_vector, filter_, nb_results):
|
||||
"""Build OpenSearch semantic search query"""
|
||||
return {
|
||||
"bool": {
|
||||
"must": {
|
||||
"nested": {
|
||||
"path": "chunks",
|
||||
"score_mode": "max",
|
||||
"query": {
|
||||
"knn": {
|
||||
"chunks.embedding": {
|
||||
"vector": q_vector,
|
||||
"k": nb_results,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"filter": filter_,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_full_text_query(q, filter_):
|
||||
"""Build OpenSearch full-text query"""
|
||||
return {
|
||||
"bool": {
|
||||
"must": {
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"multi_match": {
|
||||
"query": q,
|
||||
"fields": [
|
||||
"title.*.text^3",
|
||||
"content.*",
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"multi_match": {
|
||||
"query": q,
|
||||
"fields": [
|
||||
"title.*.text.trigrams^3",
|
||||
"content.*.trigrams",
|
||||
],
|
||||
"boost": settings.TRIGRAMS_BOOST,
|
||||
"minimum_should_match": settings.TRIGRAMS_MINIMUM_SHOULD_MATCH,
|
||||
}
|
||||
},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
},
|
||||
},
|
||||
"filter": filter_,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_filter(reach, visited, user_sub, groups):
|
||||
"""Build OpenSearch filter"""
|
||||
filters = [
|
||||
{"term": {"is_active": True}}, # filter out inactive documents
|
||||
# Access control filters
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
# Public or authenticated (not restricted)
|
||||
{
|
||||
"bool": {
|
||||
"must_not": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED},
|
||||
},
|
||||
"must": {
|
||||
"terms": {"_id": sorted(visited)},
|
||||
},
|
||||
}
|
||||
},
|
||||
# Restricted: either user or group must match
|
||||
{"term": {enums.USERS: user_sub}},
|
||||
{"terms": {enums.GROUPS: groups}},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
# Optional reach filter
|
||||
if reach is not None:
|
||||
filters.append({"term": {enums.REACH: reach}})
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def get_sort(query_keys, order_by, order_direction):
|
||||
"""Build OpenSearch sort clause"""
|
||||
# Add sorting logic based on relevance or specified field
|
||||
if "hybrid" in query_keys:
|
||||
# sorting by other field than "_score" is not supported in hybrid search
|
||||
# see: https://github.com/opensearch-project/neural-search/issues/866
|
||||
return {"_score": {"order": order_direction}}
|
||||
if order_by == enums.RELEVANCE:
|
||||
return {"_score": {"order": order_direction}}
|
||||
|
||||
return {order_by: {"order": order_direction}}
|
||||
|
||||
|
||||
def get_params(query_keys):
|
||||
"""Build OpenSearch search parameters"""
|
||||
if "hybrid" in query_keys:
|
||||
return {"search_pipeline": settings.HYBRID_SEARCH_PIPELINE_ID}
|
||||
return {}
|
||||
@@ -21,7 +21,12 @@ from core.tests.mock import albert_embedding_response
|
||||
from core.tests.utils import (
|
||||
enable_hybrid_search,
|
||||
)
|
||||
from core.utils import bulk_create_documents, delete_search_pipeline, prepare_index
|
||||
from core.utils import (
|
||||
bulk_create_documents,
|
||||
delete_search_pipeline,
|
||||
get_language_value,
|
||||
prepare_index,
|
||||
)
|
||||
|
||||
SERVICE_NAME = "test-index"
|
||||
|
||||
@@ -64,7 +69,7 @@ def test_reindex_with_embedding_command(settings):
|
||||
)
|
||||
assert len(initial_index["hits"]["hits"]) == 3
|
||||
for embedded_hit in initial_index["hits"]["hits"]:
|
||||
assert embedded_hit["_source"]["embedding"] == None
|
||||
assert embedded_hit["_source"]["chunks"] == None
|
||||
assert embedded_hit["_source"]["embedding_model"] is None
|
||||
|
||||
# enable hybrid search
|
||||
@@ -89,8 +94,9 @@ def test_reindex_with_embedding_command(settings):
|
||||
for embedded_hit in embedded_index["hits"]["hits"]:
|
||||
embedded_source = embedded_hit["_source"]
|
||||
# the index contains a embedding and embedding_model
|
||||
assert len(embedded_source["chunks"]) == 1
|
||||
assert (
|
||||
embedded_source["embedding"]
|
||||
embedded_source["chunks"][0]["embedding"]
|
||||
== albert_embedding_response.response["data"][0]["embedding"]
|
||||
)
|
||||
assert embedded_source["embedding_model"] == settings.EMBEDDING_API_MODEL_NAME
|
||||
@@ -161,7 +167,7 @@ def test_reindex_can_fail_and_restart(settings):
|
||||
embedded_count = 0
|
||||
not_embedded_count = 0
|
||||
for hit in embedded_index["hits"]["hits"]:
|
||||
if hit["_source"].get("embedding"):
|
||||
if hit["_source"].get("chunks"):
|
||||
embedded_count += 1
|
||||
assert (
|
||||
hit["_source"]["embedding_model"] == settings.EMBEDDING_API_MODEL_NAME
|
||||
@@ -191,10 +197,11 @@ def test_reindex_can_fail_and_restart(settings):
|
||||
index=index_name, size=3, body={"query": {"match_all": {}}}
|
||||
)
|
||||
for hit in embedded_index["hits"]["hits"]:
|
||||
assert (
|
||||
hit["_source"]["embedding"]
|
||||
== albert_embedding_response.response["data"][0]["embedding"]
|
||||
)
|
||||
for chunk in hit["_source"]["chunks"]:
|
||||
assert (
|
||||
chunk["embedding"]
|
||||
== albert_embedding_response.response["data"][0]["embedding"]
|
||||
)
|
||||
assert hit["_source"]["embedding_model"] == settings.EMBEDDING_API_MODEL_NAME
|
||||
|
||||
|
||||
@@ -224,10 +231,6 @@ def test_reindex_preserves_concurrent_updates(settings):
|
||||
enable_hybrid_search(settings)
|
||||
|
||||
updated_title = "updated dog"
|
||||
updated_embedding = [
|
||||
1.0
|
||||
] * settings.EMBEDDING_DIMENSION # dummy embedding to simulate concurrent update
|
||||
# add a side_effect on the search to simulate a concurrent update
|
||||
patch(
|
||||
"core.services.opensearch.opensearch_client_.search",
|
||||
side_effect=opensearch_client_.update(
|
||||
@@ -256,14 +259,15 @@ def test_reindex_preserves_concurrent_updates(settings):
|
||||
index=index_name, size=2, body={"query": {"match_all": {}}}
|
||||
)
|
||||
# Check that the latest update is preserved
|
||||
dog_doc = [
|
||||
updated_document = [
|
||||
hit
|
||||
for hit in embedded_index["hits"]["hits"]
|
||||
if hit["_source"]["title.en"] == updated_title
|
||||
if get_language_value(hit["_source"], "title") == updated_title
|
||||
]
|
||||
assert len(dog_doc) == 1
|
||||
assert dog_doc[0]["_source"]["embedding"] is None
|
||||
assert dog_doc[0]["_source"]["embedding_model"] is None
|
||||
assert len(updated_document) == 1
|
||||
# Check it was not embedded
|
||||
assert updated_document[0]["_source"]["chunks"] is None
|
||||
assert updated_document[0]["_source"]["embedding_model"] is None
|
||||
|
||||
|
||||
def test_reindex_command_but_hybrid_search_is_disabled():
|
||||
@@ -275,7 +279,7 @@ def test_reindex_command_but_hybrid_search_is_disabled():
|
||||
|
||||
|
||||
def test_reindex_command_but_index_does_not_exist(settings):
|
||||
"""Test the `reindex_with_embedding` command fails when the idex does not exist."""
|
||||
"""Test the `reindex_with_embedding` command fails when the index does not exist."""
|
||||
wrong_index = "wrong-index-name"
|
||||
enable_hybrid_search(settings)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from faker import Faker
|
||||
from lasuite.oidc_resource_server.authentication import (
|
||||
get_resource_server_backend,
|
||||
)
|
||||
from opensearchpy.exceptions import NotFoundError
|
||||
|
||||
from core.services import opensearch
|
||||
|
||||
@@ -47,5 +48,5 @@ def cleanup_test_index(settings):
|
||||
|
||||
try:
|
||||
client.indices.delete(index=f"{prefix}-*")
|
||||
except opensearch.NotFoundError:
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest import mock
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from opensearchpy import NotFoundError
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
@@ -59,12 +60,12 @@ def test_api_documents_index_bulk_success():
|
||||
|
||||
|
||||
def test_api_documents_index_bulk_ensure_index():
|
||||
"""A registered service should be create the opensearch index if need."""
|
||||
"""A registered service should be created the opensearch index if needed."""
|
||||
opensearch_client_ = opensearch.opensearch_client()
|
||||
service = factories.ServiceFactory()
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
with pytest.raises(opensearch.NotFoundError):
|
||||
with pytest.raises(NotFoundError):
|
||||
opensearch_client_.indices.get(index=service.index_name)
|
||||
|
||||
response = APIClient().post(
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from opensearchpy import NotFoundError
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
@@ -53,8 +54,7 @@ def test_api_documents_index_single_invalid_token():
|
||||
def test_api_documents_index_single_hybrid_enabled_success(settings):
|
||||
"""
|
||||
A registered service should be able to index document with a valid token.
|
||||
If hybrid search is enabled, the indexing should have embedding of
|
||||
dimension settings.EMBEDDING_DIMENSION.
|
||||
If hybrid search is enabled, the documents are chunked and embedded.
|
||||
"""
|
||||
service = factories.ServiceFactory()
|
||||
enable_hybrid_search(settings)
|
||||
@@ -66,6 +66,9 @@ def test_api_documents_index_single_hybrid_enabled_success(settings):
|
||||
)
|
||||
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
document["content"] = (
|
||||
"a long text to embed." * 100
|
||||
) # Ensure content is long enough for chunking
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/index/",
|
||||
@@ -91,13 +94,28 @@ def test_api_documents_index_single_hybrid_enabled_success(settings):
|
||||
|
||||
# check embedding
|
||||
assert (
|
||||
new_indexed_document["_source"]["embedding"]
|
||||
new_indexed_document["_source"]["chunks"][0]["embedding"]
|
||||
== albert_embedding_response.response["data"][0]["embedding"]
|
||||
)
|
||||
assert (
|
||||
new_indexed_document["_source"]["embedding_model"]
|
||||
== settings.EMBEDDING_API_MODEL_NAME
|
||||
)
|
||||
# Check that the document has been chunked correctly
|
||||
assert (
|
||||
len(new_indexed_document["_source"]["chunks"])
|
||||
== int(
|
||||
len(document["content"]) / (settings.CHUNK_SIZE - settings.CHUNK_OVERLAP)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
for chunk in new_indexed_document["_source"]["chunks"]:
|
||||
assert (
|
||||
chunk["embedding"]
|
||||
== albert_embedding_response.response["data"][0]["embedding"]
|
||||
)
|
||||
assert chunk["content"] in document["content"]
|
||||
assert len(chunk["content"]) < len(document["content"])
|
||||
|
||||
|
||||
def test_api_documents_index_language_params():
|
||||
@@ -207,16 +225,16 @@ def test_api_documents_index_single_hybrid_disabled_success():
|
||||
new_indexed_document["_source"]["title.en"] == document["title"].strip().lower()
|
||||
)
|
||||
assert new_indexed_document["_source"]["content.en"] == document["content"]
|
||||
assert new_indexed_document["_source"]["embedding"] is None
|
||||
assert new_indexed_document["_source"]["chunks"] is None
|
||||
|
||||
|
||||
def test_api_documents_index_single_ensure_index():
|
||||
"""A registered service should be create the opensearch index if need."""
|
||||
def test_api_documents_index_single_ensure_index(settings):
|
||||
"""A registered service should be created the opensearch index if needed."""
|
||||
service = factories.ServiceFactory()
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
opensearch_client_ = opensearch.opensearch_client()
|
||||
|
||||
with pytest.raises(opensearch.NotFoundError):
|
||||
with pytest.raises(NotFoundError):
|
||||
opensearch_client_.indices.get(index=service.index_name)
|
||||
|
||||
response = APIClient().post(
|
||||
@@ -235,6 +253,23 @@ def test_api_documents_index_single_ensure_index():
|
||||
assert data[service.index_name]["mappings"] == {
|
||||
"dynamic": "strict",
|
||||
"properties": {
|
||||
"chunks": {
|
||||
"type": "nested",
|
||||
"properties": {
|
||||
"content": {"type": "text"},
|
||||
"embedding": {
|
||||
"type": "knn_vector",
|
||||
"dimension": settings.EMBEDDING_DIMENSION,
|
||||
"method": {
|
||||
"engine": "lucene",
|
||||
"space_type": "l2",
|
||||
"name": "hnsw",
|
||||
"parameters": {},
|
||||
},
|
||||
},
|
||||
"index": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"content": {
|
||||
"properties": {
|
||||
"de": {
|
||||
@@ -276,16 +311,6 @@ def test_api_documents_index_single_ensure_index():
|
||||
},
|
||||
"created_at": {"type": "date"},
|
||||
"depth": {"type": "integer"},
|
||||
"embedding": {
|
||||
"type": "knn_vector",
|
||||
"dimension": 1024,
|
||||
"method": {
|
||||
"engine": "lucene",
|
||||
"space_type": "l2",
|
||||
"name": "hnsw",
|
||||
"parameters": {},
|
||||
},
|
||||
},
|
||||
"embedding_model": {"type": "keyword"},
|
||||
"groups": {"type": "keyword"},
|
||||
"id": {"type": "keyword"},
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Test suite for opensearch embedding service
|
||||
"""
|
||||
|
||||
import logging
|
||||
from json import dumps as json_dumps
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core.services.embedding import embed_text
|
||||
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
|
||||
from .mock import albert_embedding_response
|
||||
from .utils import (
|
||||
check_hybrid_search_enabled as check_hybrid_search_enabled_utils,
|
||||
)
|
||||
from .utils import (
|
||||
enable_hybrid_search,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def before_each():
|
||||
"""Clear caches before each test"""
|
||||
clear_caches()
|
||||
yield
|
||||
clear_caches()
|
||||
|
||||
|
||||
def clear_caches():
|
||||
"""Clear caches used in opensearch service and factories"""
|
||||
check_hybrid_search_enabled.cache_clear()
|
||||
# the instance of check_hybrid_search_enabled used in utils.py
|
||||
# is different and must be cleared separately
|
||||
check_hybrid_search_enabled_utils.cache_clear()
|
||||
opensearch_client().indices.delete(index="*")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_text_success(settings):
|
||||
"""Test embed_text retrieval is successful"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert embedding == albert_embedding_response.response["data"][0]["embedding"]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_401_http_error(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None on 401 error"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"embedding API request failed: 401 Client Error: Unauthorized" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_500_http_error(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None on 500 error"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
status=500,
|
||||
body=json_dumps({"message": "Internal server error."}),
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"embedding API request failed: 500 Server Error: Internal Server Error"
|
||||
in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_wrong_format(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None if api returns a wrong format"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json={"wrong": "format"},
|
||||
status=200,
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"unexpected embedding response format" in message for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Test suite for opensearch indexing service
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core.services.indexing import detect_language_code, ensure_index_exists
|
||||
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
|
||||
from .mock import albert_embedding_response
|
||||
from .utils import (
|
||||
check_hybrid_search_enabled as check_hybrid_search_enabled_utils,
|
||||
)
|
||||
from .utils import (
|
||||
enable_hybrid_search,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
SERVICE_NAME = "test-service"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def before_each():
|
||||
"""Clear caches before each test"""
|
||||
clear_caches()
|
||||
yield
|
||||
clear_caches()
|
||||
|
||||
|
||||
def clear_caches():
|
||||
"""Clear caches used in opensearch service and factories"""
|
||||
check_hybrid_search_enabled.cache_clear()
|
||||
# the instance of check_hybrid_search_enabled used in utils.py
|
||||
# is different and must be cleared separately
|
||||
check_hybrid_search_enabled_utils.cache_clear()
|
||||
opensearch_client().indices.delete(index="*")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, analyzer_name, expected_language_analyzer_tokens, expected_trigram_analyzer_tokens",
|
||||
[
|
||||
(
|
||||
"l'éléphant a couru avec les Gens",
|
||||
"french_analyzer",
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("éléphant" -> "elephant")
|
||||
# stop words are removed ('avec', 'les')
|
||||
# elisions are removed ("l'")
|
||||
# stemming is applied ("gens" -> "gen")
|
||||
["elephant", "a", "couru", "gen"],
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("éléphant" -> "elephant")
|
||||
# words smaller than 3 characters are removed ("a")
|
||||
# trigrams are generated
|
||||
[
|
||||
"l'e",
|
||||
"'el",
|
||||
"ele",
|
||||
"lep",
|
||||
"eph",
|
||||
"pha",
|
||||
"han",
|
||||
"ant",
|
||||
"cou",
|
||||
"our",
|
||||
"uru",
|
||||
"ave",
|
||||
"vec",
|
||||
"les",
|
||||
"gen",
|
||||
"ens",
|
||||
],
|
||||
),
|
||||
(
|
||||
"The Elephant is running into a café",
|
||||
"english_analyzer",
|
||||
# lowercase is applied ("Elephant" -> "elephant")
|
||||
# asciifolding is applied ("café" -> "cafe")
|
||||
# stop words are removed ("The", "into", "a")
|
||||
# stemming is applied ("running" -> "run", "elephant" -> "eleph")
|
||||
["eleph", "run", "cafe"],
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("café" -> "cafe")
|
||||
# trigrams are generated
|
||||
# words smaller than 3 characters are removed ("a")
|
||||
[
|
||||
"the",
|
||||
"ele",
|
||||
"lep",
|
||||
"eph",
|
||||
"pha",
|
||||
"han",
|
||||
"ant",
|
||||
"run",
|
||||
"unn",
|
||||
"nni",
|
||||
"nin",
|
||||
"ing",
|
||||
"int",
|
||||
"nto",
|
||||
"caf",
|
||||
"afe",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Der Käfer läuft über die Straße",
|
||||
"german_analyzer",
|
||||
# lowercase is applied ("Der" -> "der", "Käfer" -> "käfer", "Straße" -> "straße")
|
||||
# asciifolding is applied ("käfer" -> "kafer", "straße" -> "strass")
|
||||
# stop words are removed ("Der", "die")
|
||||
# stemming is applied ("kafer" -> "kaf")
|
||||
["kaf", "lauft", "uber", "strass"],
|
||||
# lowercase is applied
|
||||
# asciifolding is applied ("käfer" -> "kafer", "straße" -> "strasse")
|
||||
# trigrams are generated
|
||||
[
|
||||
"der",
|
||||
"kaf",
|
||||
"afe",
|
||||
"fer",
|
||||
"lau",
|
||||
"auf",
|
||||
"uft",
|
||||
"ube",
|
||||
"ber",
|
||||
"die",
|
||||
"str",
|
||||
"tra",
|
||||
"ras",
|
||||
"ass",
|
||||
"sse",
|
||||
],
|
||||
),
|
||||
(
|
||||
"De Kinderen lopen naar de bakkerij",
|
||||
"dutch_analyzer",
|
||||
# lowercase is applied ("De" -> "de", "Kinderen" -> "kinderen")
|
||||
# stop words are removed ("De", "naar", "de")
|
||||
# stemming is applied ("kinderen" -> "kinder", "lopen" -> "lop")
|
||||
["kinder", "lop", "bakkerij"],
|
||||
# lowercase is applied
|
||||
# words smaller than 3 characters are removed ("de")
|
||||
# trigrams are generated
|
||||
[
|
||||
"kin",
|
||||
"ind",
|
||||
"nde",
|
||||
"der",
|
||||
"ere",
|
||||
"ren",
|
||||
"lop",
|
||||
"ope",
|
||||
"pen",
|
||||
"naa",
|
||||
"aar",
|
||||
"bak",
|
||||
"akk",
|
||||
"kke",
|
||||
"ker",
|
||||
"eri",
|
||||
"rij",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
@responses.activate
|
||||
def test_opensearch_analyzers(
|
||||
settings,
|
||||
text,
|
||||
analyzer_name,
|
||||
expected_language_analyzer_tokens,
|
||||
expected_trigram_analyzer_tokens,
|
||||
):
|
||||
"""Test the analyzers are correctly configured in OpenSearch"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
ensure_index_exists(SERVICE_NAME)
|
||||
|
||||
language_analyzer_response = opensearch_client().indices.analyze(
|
||||
index=SERVICE_NAME,
|
||||
body={
|
||||
"analyzer": analyzer_name,
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
language_analyzer_tokens = [
|
||||
token_info["token"] for token_info in language_analyzer_response["tokens"]
|
||||
]
|
||||
response_trigram_analyzer = opensearch_client().indices.analyze(
|
||||
index=SERVICE_NAME,
|
||||
body={
|
||||
"analyzer": "trigram_analyzer",
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
trigram_analyzer_tokens = [
|
||||
token_info["token"] for token_info in response_trigram_analyzer["tokens"]
|
||||
]
|
||||
|
||||
assert expected_language_analyzer_tokens == language_analyzer_tokens
|
||||
assert expected_trigram_analyzer_tokens == trigram_analyzer_tokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected_language_code",
|
||||
[
|
||||
("This is a test sentence.", "en"),
|
||||
("Ceci est une phrase de test.", "fr"),
|
||||
("Dies ist ein Testsatz.", "de"),
|
||||
("Dit is een testzin.", "nl"),
|
||||
("Esta es una oración de prueba.", "und"), # Spanish, unsupported
|
||||
("", "und"),
|
||||
("zefk,l", "und"),
|
||||
],
|
||||
)
|
||||
def test_detect_language_code(text, expected_language_code):
|
||||
"""Test detect_language_code function"""
|
||||
|
||||
assert detect_language_code(text) == expected_language_code
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test suite for opensearch service
|
||||
Test suite for opensearch search service
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -11,14 +11,8 @@ import responses
|
||||
|
||||
from core import factories
|
||||
from core.services import opensearch
|
||||
from core.services.opensearch import (
|
||||
check_hybrid_search_enabled,
|
||||
detect_language_code,
|
||||
embed_text,
|
||||
ensure_index_exists,
|
||||
opensearch_client,
|
||||
search,
|
||||
)
|
||||
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
|
||||
from core.services.search import search
|
||||
from core.utils import bulk_create_documents, delete_search_pipeline, prepare_index
|
||||
|
||||
from .mock import albert_embedding_response
|
||||
@@ -31,7 +25,6 @@ from .utils import (
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
SERVICE_NAME = "test-service"
|
||||
|
||||
|
||||
@@ -122,7 +115,7 @@ def test_hybrid_search_without_embedded_index(settings, caplog):
|
||||
indexed_documents = opensearch.opensearch_client().search(
|
||||
index=service.index_name, body={"query": {"match_all": {}}}
|
||||
)
|
||||
assert indexed_documents["hits"]["hits"][0]["_source"]["embedding"] is None
|
||||
assert indexed_documents["hits"]["hits"][0]["_source"]["chunks"] is None
|
||||
|
||||
# hybrid search is enabled before to do the first requests
|
||||
enable_hybrid_search(settings)
|
||||
@@ -208,6 +201,7 @@ def test_fall_back_on_full_text_search_if_hybrid_search_disabled(settings, caplo
|
||||
@responses.activate
|
||||
def test_fall_back_on_full_text_search_if_embedding_api_fails(settings, caplog):
|
||||
"""Test the full-text search is done when the embedding api fails"""
|
||||
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -366,270 +360,3 @@ def test_hybrid_search_number_of_matches(settings):
|
||||
for nb_results in [1, 2, 3]: # semantic should match k documents
|
||||
result = search(q=q, **{**search_params(service), "nb_results": nb_results})
|
||||
assert len(result["hits"]["hits"]) == nb_results
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_text_success(settings):
|
||||
"""Test embed_text retrieval is successful"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json=albert_embedding_response.response,
|
||||
status=200,
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert embedding == albert_embedding_response.response["data"][0]["embedding"]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_401_http_error(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None on 401 error"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"embedding API request failed: 401 Client Error: Unauthorized" in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_500_http_error(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None on 500 error"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
status=500,
|
||||
body=json_dumps({"message": "Internal server error."}),
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"embedding API request failed: 500 Server Error: Internal Server Error"
|
||||
in message
|
||||
for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_embed_wrong_format(settings, caplog):
|
||||
"""Test embed_text does not crash and returns None if api returns a wrong format"""
|
||||
enable_hybrid_search(settings)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.EMBEDDING_API_PATH,
|
||||
json={"wrong": "format"},
|
||||
status=200,
|
||||
)
|
||||
text = "canine pet"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
embedding = embed_text(text)
|
||||
|
||||
assert any(
|
||||
"unexpected embedding response format" in message for message in caplog.messages
|
||||
)
|
||||
|
||||
assert embedding is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, analyzer_name, expected_language_analyzer_tokens, expected_trigram_analyzer_tokens",
|
||||
[
|
||||
(
|
||||
"l'éléphant a couru avec les Gens",
|
||||
"french_analyzer",
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("éléphant" -> "elephant")
|
||||
# stop words are removed ('avec', 'les')
|
||||
# elisions are removed ("l'")
|
||||
# stemming is applied ("gens" -> "gen")
|
||||
["elephant", "a", "couru", "gen"],
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("éléphant" -> "elephant")
|
||||
# words smaller than 3 characters are removed ("a")
|
||||
# trigrams are generated
|
||||
[
|
||||
"l'e",
|
||||
"'el",
|
||||
"ele",
|
||||
"lep",
|
||||
"eph",
|
||||
"pha",
|
||||
"han",
|
||||
"ant",
|
||||
"cou",
|
||||
"our",
|
||||
"uru",
|
||||
"ave",
|
||||
"vec",
|
||||
"les",
|
||||
"gen",
|
||||
"ens",
|
||||
],
|
||||
),
|
||||
(
|
||||
"The Elephant is running into a café",
|
||||
"english_analyzer",
|
||||
# lowercase is applied ("Elephant" -> "elephant")
|
||||
# asciifolding is applied ("café" -> "cafe")
|
||||
# stop words are removed ("The", "into", "a")
|
||||
# stemming is applied ("running" -> "run", "elephant" -> "eleph")
|
||||
["eleph", "run", "cafe"],
|
||||
# lowercase is applied ("Gens" -> "gens")
|
||||
# asciifolding is applied ("café" -> "cafe")
|
||||
# trigrams are generated
|
||||
# words smaller than 3 characters are removed ("a")
|
||||
[
|
||||
"the",
|
||||
"ele",
|
||||
"lep",
|
||||
"eph",
|
||||
"pha",
|
||||
"han",
|
||||
"ant",
|
||||
"run",
|
||||
"unn",
|
||||
"nni",
|
||||
"nin",
|
||||
"ing",
|
||||
"int",
|
||||
"nto",
|
||||
"caf",
|
||||
"afe",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Der Käfer läuft über die Straße",
|
||||
"german_analyzer",
|
||||
# lowercase is applied ("Der" -> "der", "Käfer" -> "käfer", "Straße" -> "straße")
|
||||
# asciifolding is applied ("käfer" -> "kafer", "straße" -> "strass")
|
||||
# stop words are removed ("Der", "die")
|
||||
# stemming is applied ("kafer" -> "kaf")
|
||||
["kaf", "lauft", "uber", "strass"],
|
||||
# lowercase is applied
|
||||
# asciifolding is applied ("käfer" -> "kafer", "straße" -> "strasse")
|
||||
# trigrams are generated
|
||||
[
|
||||
"der",
|
||||
"kaf",
|
||||
"afe",
|
||||
"fer",
|
||||
"lau",
|
||||
"auf",
|
||||
"uft",
|
||||
"ube",
|
||||
"ber",
|
||||
"die",
|
||||
"str",
|
||||
"tra",
|
||||
"ras",
|
||||
"ass",
|
||||
"sse",
|
||||
],
|
||||
),
|
||||
(
|
||||
"De Kinderen lopen naar de bakkerij",
|
||||
"dutch_analyzer",
|
||||
# lowercase is applied ("De" -> "de", "Kinderen" -> "kinderen")
|
||||
# stop words are removed ("De", "naar", "de")
|
||||
# stemming is applied ("kinderen" -> "kinder", "lopen" -> "lop")
|
||||
["kinder", "lop", "bakkerij"],
|
||||
# lowercase is applied
|
||||
# words smaller than 3 characters are removed ("de")
|
||||
# trigrams are generated
|
||||
[
|
||||
"kin",
|
||||
"ind",
|
||||
"nde",
|
||||
"der",
|
||||
"ere",
|
||||
"ren",
|
||||
"lop",
|
||||
"ope",
|
||||
"pen",
|
||||
"naa",
|
||||
"aar",
|
||||
"bak",
|
||||
"akk",
|
||||
"kke",
|
||||
"ker",
|
||||
"eri",
|
||||
"rij",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_opensearch_analyzers(
|
||||
settings,
|
||||
text,
|
||||
analyzer_name,
|
||||
expected_language_analyzer_tokens,
|
||||
expected_trigram_analyzer_tokens,
|
||||
):
|
||||
"""Test the analyzers are correctly configured in OpenSearch"""
|
||||
enable_hybrid_search(settings)
|
||||
ensure_index_exists(SERVICE_NAME)
|
||||
|
||||
language_analyzer_response = opensearch_client().indices.analyze(
|
||||
index=SERVICE_NAME,
|
||||
body={
|
||||
"analyzer": analyzer_name,
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
language_analyzer_tokens = [
|
||||
token_info["token"] for token_info in language_analyzer_response["tokens"]
|
||||
]
|
||||
response_trigram_analyzer = opensearch_client().indices.analyze(
|
||||
index=SERVICE_NAME,
|
||||
body={
|
||||
"analyzer": "trigram_analyzer",
|
||||
"text": text,
|
||||
},
|
||||
)
|
||||
trigram_analyzer_tokens = [
|
||||
token_info["token"] for token_info in response_trigram_analyzer["tokens"]
|
||||
]
|
||||
|
||||
assert expected_language_analyzer_tokens == language_analyzer_tokens
|
||||
assert expected_trigram_analyzer_tokens == trigram_analyzer_tokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected_language_code",
|
||||
[
|
||||
("This is a test sentence.", "en"),
|
||||
("Ceci est une phrase de test.", "fr"),
|
||||
("Dies ist ein Testsatz.", "de"),
|
||||
("Dit is een testzin.", "nl"),
|
||||
("Esta es una oración de prueba.", "und"), # Spanish, unsupported
|
||||
("", "und"),
|
||||
("zefk,l", "und"),
|
||||
],
|
||||
)
|
||||
def test_detect_language_code(text, expected_language_code):
|
||||
"""Test detect_language_code function"""
|
||||
|
||||
assert detect_language_code(text) == expected_language_code
|
||||
@@ -1,17 +1,10 @@
|
||||
"""Utility functions for management commands."""
|
||||
"""Utility functions for Test."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.jwk import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
|
||||
from core.management.commands.create_search_pipeline import (
|
||||
ensure_search_pipeline_exists,
|
||||
)
|
||||
@@ -24,7 +17,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def enable_hybrid_search(settings):
|
||||
"""Enable hybrid search settings for tests."""
|
||||
logger.info("Enabling hybrid search for tests")
|
||||
settings.HYBRID_SEARCH_ENABLED = True
|
||||
settings.HYBRID_SEARCH_WEIGHTS = [0.3, 0.7]
|
||||
settings.EMBEDDING_API_KEY = "test-api-key"
|
||||
@@ -51,116 +43,6 @@ def build_authorization_bearer(token="some_token"):
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def setup_oicd_jwt_resource_server(
|
||||
responses,
|
||||
settings,
|
||||
sub="some_sub",
|
||||
audience="some_client_id",
|
||||
):
|
||||
"""
|
||||
Setup settings for a resource server with JWT backend.
|
||||
Simulate an encrypted token introspection.
|
||||
NOTE : Use it with @responses.activate or the fake introspection view will not work.
|
||||
"""
|
||||
token_data = {
|
||||
"sub": sub,
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": audience,
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "docs",
|
||||
"active": True,
|
||||
}
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
unencrypted_pem_private_key = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
pem_public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = audience
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "docs", "email"]
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
"""Encrypt the JWT token for the backend to decrypt."""
|
||||
token = jose_jwt.encode(
|
||||
{
|
||||
"kid": "1234567890",
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
},
|
||||
json_data,
|
||||
RSAKey.import_key(unencrypted_pem_private_key),
|
||||
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
|
||||
)
|
||||
|
||||
return jose_jwe.encrypt_compact(
|
||||
protected={
|
||||
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
},
|
||||
plaintext=token,
|
||||
public_key=RSAKey.import_key(pem_public_key),
|
||||
algorithms=[
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": audience, # settings.OIDC_RS_CLIENT_ID
|
||||
"token_introspection": token_data,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def setup_oicd_resource_server(
|
||||
responses,
|
||||
settings,
|
||||
|
||||
+13
-17
@@ -9,10 +9,8 @@ from opensearchpy.exceptions import NotFoundError
|
||||
from opensearchpy.helpers import bulk
|
||||
|
||||
from core import factories
|
||||
from core.services import opensearch
|
||||
from core.services.opensearch import (
|
||||
prepare_document_for_indexing,
|
||||
)
|
||||
from core.services.indexing import ensure_index_exists, prepare_document_for_indexing
|
||||
from core.services.opensearch import opensearch_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +30,7 @@ def delete_search_pipeline():
|
||||
)
|
||||
|
||||
try:
|
||||
opensearch.opensearch_client().transport.perform_request(
|
||||
opensearch_client().transport.perform_request(
|
||||
method="DELETE",
|
||||
url=f"/_search/pipeline/{django_settings.HYBRID_SEARCH_PIPELINE_ID}",
|
||||
)
|
||||
@@ -45,18 +43,16 @@ def delete_index(index_name):
|
||||
logger.info("Deleting Index %s", index_name)
|
||||
|
||||
try:
|
||||
opensearch.opensearch_client().indices.delete(index=index_name)
|
||||
opensearch_client().indices.delete(index=index_name)
|
||||
except NotFoundError:
|
||||
logger.info("Search pipeline %s not found, nothing to delete.", index_name)
|
||||
|
||||
|
||||
def prepare_index(index_name, documents: List):
|
||||
"""Prepare the search index before testing a query on it."""
|
||||
logger.info("prepare_index %s with %d documents", index_name, len(documents))
|
||||
opensearch_client_ = opensearch.opensearch_client()
|
||||
opensearch.ensure_index_exists(index_name)
|
||||
logger.info("Preparing index %s with %d documents", index_name, len(documents))
|
||||
|
||||
# Index new documents
|
||||
ensure_index_exists(index_name)
|
||||
actions = [
|
||||
{
|
||||
"_op_type": "index",
|
||||
@@ -66,16 +62,16 @@ def prepare_index(index_name, documents: List):
|
||||
}
|
||||
for document in documents
|
||||
]
|
||||
|
||||
if not actions:
|
||||
return
|
||||
|
||||
bulk(opensearch_client_, actions)
|
||||
opensearch_client_.indices.refresh(index=index_name)
|
||||
bulk(opensearch_client(), actions)
|
||||
opensearch_client().indices.refresh(index=index_name)
|
||||
|
||||
|
||||
def get_language_value(source, language_field):
|
||||
"""extract the value of the language field with the correct language_code extension"""
|
||||
"""
|
||||
extract the value of the language field with the correct language_code extension.
|
||||
"title" and "content" have extensions like "title.en" or "title.fr".
|
||||
get_language_value will return the value regardless of the extension.
|
||||
"""
|
||||
for language_code in django_settings.SUPPORTED_LANGUAGE_CODES:
|
||||
if f"{language_field}.{language_code}" in source:
|
||||
return source[f"{language_field}.{language_code}"]
|
||||
|
||||
+78
-49
@@ -14,12 +14,9 @@ from . import schemas
|
||||
from .authentication import ServiceTokenAuthentication
|
||||
from .models import Service, get_opensearch_index_name
|
||||
from .permissions import IsAuthAuthenticated
|
||||
from .services.opensearch import (
|
||||
ensure_index_exists,
|
||||
opensearch_client,
|
||||
prepare_document_for_indexing,
|
||||
search,
|
||||
)
|
||||
from .services.indexing import ensure_index_exists, prepare_document_for_indexing
|
||||
from .services.opensearch import opensearch_client
|
||||
from .services.search import search
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,7 +32,6 @@ class IndexDocumentView(views.APIView):
|
||||
authentication_classes = [ServiceTokenAuthentication]
|
||||
permission_classes = [IsAuthAuthenticated]
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
API view for indexing documents into OpenSearch index of the authenticated service.
|
||||
@@ -84,65 +80,97 @@ class IndexDocumentView(views.APIView):
|
||||
- Returns a list of results for all documents, with details of success and indexing
|
||||
errors.
|
||||
"""
|
||||
|
||||
index_name = request.auth.index_name
|
||||
opensearch_client_ = opensearch_client()
|
||||
|
||||
if isinstance(request.data, list):
|
||||
# Bulk indexing several documents
|
||||
results = []
|
||||
actions = []
|
||||
has_errors = False
|
||||
return self.bulk_index(request, index_name, opensearch_client_)
|
||||
|
||||
for i, document_data in enumerate(request.data):
|
||||
try:
|
||||
document = schemas.DocumentSchema(**document_data)
|
||||
except PydanticValidationError as excpt:
|
||||
errors = [
|
||||
{key: error[key] for key in ("msg", "type", "loc")}
|
||||
for error in excpt.errors()
|
||||
]
|
||||
results.append({"index": i, "status": "error", "errors": errors})
|
||||
has_errors = True
|
||||
else:
|
||||
document_dict = prepare_document_for_indexing(document.model_dump())
|
||||
_id = document_dict.pop("id")
|
||||
actions.append({"index": {"_id": _id}})
|
||||
actions.append(document_dict)
|
||||
results.append({"index": i, "_id": _id, "status": "valid"})
|
||||
return self.single_index(request, index_name, opensearch_client_)
|
||||
|
||||
if has_errors:
|
||||
return Response(results, status=status.HTTP_400_BAD_REQUEST)
|
||||
def single_index(self, request, index_name, opensearch_client_):
|
||||
"""
|
||||
Index a single document into OpenSearch.
|
||||
|
||||
# Build index if needed.
|
||||
ensure_index_exists(index_name)
|
||||
Args:
|
||||
request: The HTTP request containing document data.
|
||||
index_name: The name of the OpenSearch index.
|
||||
opensearch_client_: The OpenSearch client instance.
|
||||
|
||||
response = opensearch_client_.bulk(index=index_name, body=actions)
|
||||
for i, item in enumerate(response["items"]):
|
||||
if item["index"]["status"] != 201:
|
||||
results[i]["status"] = "error"
|
||||
results[i]["message"] = (
|
||||
item["index"].get("error", {}).get("reason", "Unknown error")
|
||||
)
|
||||
else:
|
||||
results[i]["status"] = "success"
|
||||
Returns:
|
||||
Response: HTTP response with status and document ID.
|
||||
- 201 Created: Returns the indexed document ID.
|
||||
- 400 Bad Request: Returns an error message if the document is invalid.
|
||||
"""
|
||||
|
||||
return Response(results, status=status.HTTP_201_CREATED)
|
||||
|
||||
# Indexing a single document
|
||||
document = schemas.DocumentSchema(**request.data)
|
||||
document_dict = prepare_document_for_indexing(document.model_dump())
|
||||
document_dict = prepare_document_for_indexing(
|
||||
schemas.DocumentSchema(**request.data).model_dump()
|
||||
)
|
||||
_id = document_dict.pop("id")
|
||||
|
||||
# Build index if needed.
|
||||
ensure_index_exists(index_name)
|
||||
|
||||
opensearch_client_.index(index=index_name, body=document_dict, id=_id)
|
||||
opensearch_client_.index(
|
||||
index=index_name,
|
||||
body=document_dict,
|
||||
id=_id,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"status": "created", "_id": _id}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def bulk_index(self, request, index_name, opensearch_client_):
|
||||
"""
|
||||
Index multiple documents into OpenSearch in bulk.
|
||||
|
||||
Args:
|
||||
request: The HTTP request containing a list of documents.
|
||||
index_name: The name of the OpenSearch index.
|
||||
opensearch_client_: The OpenSearch client instance.
|
||||
|
||||
Returns:
|
||||
Response: HTTP response with detailed status for each document.
|
||||
- 201 Created: Returns status for all documents.
|
||||
- 400 Bad Request: Returns errors if document validation fails.
|
||||
"""
|
||||
results = []
|
||||
actions = []
|
||||
has_errors = False
|
||||
|
||||
for i, document_data in enumerate(request.data):
|
||||
try:
|
||||
document = schemas.DocumentSchema(**document_data)
|
||||
except PydanticValidationError as excpt:
|
||||
errors = [
|
||||
{key: error[key] for key in ("msg", "type", "loc")}
|
||||
for error in excpt.errors()
|
||||
]
|
||||
results.append({"index": i, "status": "error", "errors": errors})
|
||||
has_errors = True
|
||||
else:
|
||||
document_dict = prepare_document_for_indexing(document.model_dump())
|
||||
_id = document_dict.pop("id")
|
||||
actions.append({"index": {"_id": _id}})
|
||||
actions.append(document_dict)
|
||||
results.append({"index": i, "_id": _id, "status": "valid"})
|
||||
|
||||
if has_errors:
|
||||
return Response(results, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ensure_index_exists(index_name)
|
||||
response = opensearch_client_.bulk(index=index_name, body=actions)
|
||||
for i, item in enumerate(response["items"]):
|
||||
if item["index"]["status"] != 201:
|
||||
results[i]["status"] = "error"
|
||||
results[i]["message"] = (
|
||||
item["index"].get("error", {}).get("reason", "Unknown error")
|
||||
)
|
||||
else:
|
||||
results[i]["status"] = "success"
|
||||
|
||||
return Response(results, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class SearchDocumentView(ResourceServerMixin, views.APIView):
|
||||
"""
|
||||
@@ -243,4 +271,5 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
|
||||
user_sub=user_sub,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
return Response(response["hits"]["hits"], status=status.HTTP_200_OK)
|
||||
|
||||
@@ -15,7 +15,8 @@ from faker import Faker
|
||||
from opensearchpy.helpers import bulk
|
||||
|
||||
from core import enums, factories
|
||||
from core.services.opensearch import ensure_index_exists, opensearch_client
|
||||
from core.services.indexing import ensure_index_exists
|
||||
from core.services.opensearch import opensearch_client
|
||||
|
||||
from demo import defaults
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ from core.management.commands.create_search_pipeline import (
|
||||
from core.services.opensearch import (
|
||||
check_hybrid_search_enabled,
|
||||
opensearch_client,
|
||||
)
|
||||
from core.services.search import (
|
||||
search,
|
||||
)
|
||||
from core.utils import (
|
||||
|
||||
@@ -128,11 +128,11 @@ def test_evaluate_search_engine_min_score_filter(settings, mock_embedding_api):
|
||||
"""Test that min_score filters out low-scoring results"""
|
||||
|
||||
out = io.StringIO()
|
||||
super_hight_score = 1000.0
|
||||
super_high_score = 1000.0
|
||||
call_command(
|
||||
"evaluate_search_engine",
|
||||
"v0",
|
||||
min_score=super_hight_score,
|
||||
min_score=super_high_score,
|
||||
stdout=out,
|
||||
)
|
||||
|
||||
|
||||
@@ -289,6 +289,13 @@ class Base(Configuration):
|
||||
HYBRID_SEARCH_WEIGHTS = values.ListValue(
|
||||
default=[0.3, 0.7], environ_name="HYBRID_SEARCH_WEIGHTS", environ_prefix=None
|
||||
)
|
||||
# Multi-embedding: chunk documents and embed each chunk separately
|
||||
CHUNK_SIZE = values.IntegerValue(
|
||||
default=512, environ_name="CHUNK_SIZE", environ_prefix=None
|
||||
)
|
||||
CHUNK_OVERLAP = values.IntegerValue(
|
||||
default=50, environ_name="CHUNK_OVERLAP", environ_prefix=None
|
||||
)
|
||||
EMBEDDING_API_PATH = values.Value(
|
||||
# embedding is the vector representation of a document used for semantic search
|
||||
default="None",
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies = [
|
||||
"factory_boy==3.3.1",
|
||||
"gunicorn==23.0.0",
|
||||
"py3langid==0.3.0",
|
||||
"langchain-text-splitters==1.0.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pydantic==2.10.5",
|
||||
@@ -45,7 +46,7 @@ dependencies = [
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk==2.32.0",
|
||||
"url-normalize==1.4.3",
|
||||
"opensearch-py==3.1.0",
|
||||
"opensearch-py==2.8.0",
|
||||
"whitenoise==6.8.2",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user