البحث عن النص الكامل للحوارات مع أباتشي لوسين: برنامج تعليمي
نشرت: 2022-03-11Apache Lucene هي مكتبة Java تستخدم للبحث عن النص الكامل للمستندات ، وهي في صميم خوادم البحث مثل Solr و Elasticsearch. يمكن أيضًا تضمينه في تطبيقات Java ، مثل تطبيقات Android أو خلفيات الويب.
على الرغم من أن خيارات تكوين Lucene واسعة النطاق ، إلا أنها مخصصة للاستخدام من قبل مطوري قواعد البيانات في مجموعة نصية عامة. إذا كانت مستنداتك تحتوي على بنية أو نوع محتوى معين ، فيمكنك الاستفادة من أي منهما لتحسين جودة البحث وإمكانية الاستعلام.
كمثال على هذا النوع من التخصيص ، في برنامج Lucene التعليمي هذا ، سنقوم بفهرسة مجموعة Project Gutenberg ، والتي تقدم آلاف الكتب الإلكترونية المجانية. نحن نعلم أن العديد من هذه الكتب عبارة عن روايات. لنفترض أننا مهتمون بشكل خاص بالحوار داخل هذه الروايات. لا يوفر Lucene أو Elasticsearch أو Solr أدوات غير تقليدية لتحديد المحتوى على أنه حوار. في الواقع ، سوف يتخلصون من علامات الترقيم في المراحل الأولى من تحليل النص ، وهو ما يتعارض مع القدرة على تحديد أجزاء النص التي تكون عبارة عن حوار. لذلك يجب أن يبدأ التخصيص لدينا في هذه المراحل المبكرة.
قطع من خط أنابيب تحليل أباتشي لوسين
يوفر تحليل Lucene JavaDoc نظرة عامة جيدة على جميع الأجزاء المتحركة في خط أنابيب تحليل النص.
على مستوى عالٍ ، يمكنك التفكير في خط أنابيب التحليل على أنه يستهلك تيارًا خامًا من الأحرف في البداية وينتج "مصطلحات" ، تقابل الكلمات تقريبًا ، في النهاية.
يمكن تصور خط أنابيب التحليل القياسي على النحو التالي:
سنرى كيفية تخصيص خط الأنابيب هذا للتعرف على مناطق النص التي تم تمييزها بعلامات اقتباس مزدوجة ، والتي سأسميها بالحوار ، ثم تصاعد التطابقات التي تحدث عند البحث في تلك المناطق.
قراءة الشخصيات
عند إضافة المستندات في البداية إلى الفهرس ، تتم قراءة الأحرف من Java InputStream ، وبالتالي يمكن أن تأتي من الملفات وقواعد البيانات ومكالمات خدمة الويب وما إلى ذلك. لإنشاء فهرس لـ Project Gutenberg ، نقوم بتنزيل الكتب الإلكترونية و قم بإنشاء تطبيق صغير لقراءة هذه الملفات وكتابتها في الفهرس. يعد إنشاء فهرس Lucene وقراءة الملفات من المسارات التي يتم التنقل فيها بشكل جيد ، لذلك لن نستكشفها كثيرًا. الكود الأساسي لإنتاج الفهرس هو:
IndexWriter writer = ...; BufferedReader reader = new BufferedReader(new InputStreamReader(... fileInputStream ...)); Document document = new Document(); document.add(new StringField("title", fileName, Store.YES)); document.add(new TextField("body", reader)); writer.addDocument(document);
يمكننا أن نرى أن كل كتاب إلكتروني سيتوافق مع Document
Lucene واحد ، لذا ، في وقت لاحق ، ستكون نتائج البحث لدينا قائمة بالكتب المطابقة. Store.YES
يشير إلى أننا نقوم بتخزين حقل العنوان ، وهو اسم الملف فقط. ومع ذلك ، لا نريد تخزين جسم الكتاب الإلكتروني ، حيث إنه ليس ضروريًا عند البحث ولن يؤدي إلا إلى إهدار مساحة القرص.
تبدأ القراءة الفعلية للدفق مع addDocument
. يسحب IndexWriter
الرموز المميزة من نهاية خط الأنابيب. InputStream
هذا السحب مرة أخرى عبر الأنبوب حتى تتم قراءة المرحلة الأولى ، الرمز المميز ، من Tokenizer
.
لاحظ أيضًا أننا لا نغلق الدفق ، حيث تتولى Lucene هذا الأمر نيابةً عنا.
ترميز الأحرف
يتخلص Lucene StandardTokenizer من علامات الترقيم ، ولذا سيبدأ التخصيص لدينا هنا ، حيث نحتاج إلى الاحتفاظ بعلامات الترقيم.
تدعوك وثائق StandardTokenizer
إلى نسخ كود المصدر وتكييفه وفقًا لاحتياجاتك ، ولكن هذا الحل سيكون معقدًا بشكل غير ضروري. بدلاً من ذلك ، سنقوم بتمديد CharTokenizer
، والذي يسمح لك بتحديد الأحرف "للقبول" ، حيث سيتم التعامل مع الأحرف غير "المقبولة" كمحددات بين الرموز المميزة والتخلص منها. نظرًا لأننا مهتمون بالكلمات والاقتباسات من حولها ، فإن الرمز المميز المخصص لدينا هو ببساطة:
public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }
بالنظر إلى تدفق المدخلات [He said, "Good day".]
، ستكون الرموز المميزة المنتجة [He]
، [said]
، ["Good]
، [day"]
لاحظ كيف تتخلل علامات الاقتباس داخل الرموز المميزة. من الممكن كتابة Tokenizer
الذي ينتج رموزًا منفصلة لكل عرض أسعار ، ولكن Tokenizer
يهتم أيضًا بتفاصيل معقدة وسهلة الفك مثل التخزين المؤقت والمسح الضوئي ، لذلك من الأفضل الحفاظ على Tokenizer
الخاص بك بسيطًا وتنظيف دفق رمزي على طول أكثر في خط الأنابيب.
تقسيم الرموز باستخدام المرشحات
بعد الرمز المميز تأتي سلسلة من كائنات TokenFilter
. لاحظ ، بالمناسبة ، أن هذا الفلتر تسمية خاطئة إلى حد ما ، حيث يمكن TokenFilter
إضافة الرموز المميزة أو إزالتها أو تعديلها.
تتوقع العديد من فئات التصفية التي يوفرها Lucene كلمات فردية ، لذلك لن يكون من المفيد أن تتدفق الرموز المميزة المختلطة للكلمة والاقتباس إليها. وبالتالي ، يجب أن يكون التخصيص التالي لبرنامج Lucene التعليمي هو إدخال مرشح يقوم بتنظيف ناتج QuotationTokenizer
.
سيشمل هذا التنظيف إنتاج رمز اقتباس بداية إضافي إذا ظهر الاقتباس في بداية الكلمة ، أو رمز اقتباس نهاية إذا ظهر الاقتباس في النهاية. سنضع جانبًا التعامل مع الكلمات المقتبسة المنفردة من أجل التبسيط.
يتضمن إنشاء فئة فرعية من TokenFilter
تنفيذ طريقة واحدة: incrementToken
. يجب أن تستدعي هذه الطريقة incrementToken
على المرشح السابق في الأنبوب ، ثم معالجة نتائج هذه المكالمة لأداء أي عمل يكون المرشح مسؤولاً عنه. تتوفر نتائج incrementToken
عبر كائنات Attribute
، والتي تصف الحالة الحالية لمعالجة الرمز المميز. بعد تنفيذنا لـ incrementToken
المرتجعات ، من المتوقع أنه تم التلاعب بالسمات لإعداد الرمز المميز للمرشح التالي (أو الفهرس إذا كنا في نهاية الأنبوب).
السمات التي نهتم بها في هذه المرحلة من خط الأنابيب هي:
CharTermAttribute
: يحتوي على مخزنchar[]
مؤقت يحتوي على أحرف الرمز المميز الحالي. سنحتاج إلى معالجة هذا لإزالة الاقتباس ، أو لإنتاج رمز اقتباس.TypeAttribute
: يحتوي على "نوع" الرمز المميز الحالي. نظرًا لأننا نضيف علامات اقتباس البداية والنهاية إلى تدفق الرمز المميز ، فسنقدم نوعين جديدين باستخدام عامل التصفية الخاص بنا.OffsetAttribute
: يمكن أن يقوم Lucene اختياريًا بتخزين المراجع إلى موقع المصطلحات في المستند الأصلي. تسمى هذه المراجع "تعويضات" ، وهي مجرد مؤشرات بداية ونهاية في تدفق الأحرف الأصلي. إذا قمنا بتغيير المخزن المؤقت فيCharTermAttribute
للإشارة إلى سلسلة فرعية فقط من الرمز المميز ، فيجب علينا ضبط هذه الإزاحات وفقًا لذلك.
قد تتساءل عن سبب تعقيد واجهة برمجة التطبيقات للتعامل مع تدفقات الرمز المميز ، وعلى وجه الخصوص ، لماذا لا يمكننا فعل شيء مثل String#split
على الرموز المميزة الواردة. هذا لأن Lucene مصمم للفهرسة عالية السرعة ومنخفضة النفقات ، حيث يمكن للمرشحات والفلاتر المدمجة أن تمضغ بسرعة غيغابايت من النص أثناء استخدام ميغا بايت فقط من الذاكرة. لتحقيق ذلك ، يتم إجراء عمليات تخصيص قليلة أو معدومة أثناء الترميز والتصفية ، وبالتالي فإن مثيلات Attribute
المذكورة أعلاه مخصصة للتخصيص مرة واحدة وإعادة استخدامها. إذا تمت كتابة الرموز المميزة والمرشحات الخاصة بك بهذه الطريقة ، وقمت بتقليل تخصيصاتها الخاصة ، يمكنك تخصيص Lucene دون المساومة على الأداء.
مع وضع كل ذلك في الاعتبار ، دعنا نرى كيفية تنفيذ مرشح يأخذ رمزًا مميزًا مثل ["Hello]
، وينتج الرمزين المميزين ، ["]
و [Hello]
:
public class QuotationTokenFilter extends TokenFilter { private static final char QUOTE = '"'; public static final String QUOTE_START_TYPE = "start_quote"; public static final String QUOTE_END_TYPE = "end_quote"; private final OffsetAttribute offsetAttr = addAttribute(OffsetAttribute.class); private final TypeAttribute typeAttr = addAttribute(TypeAttribute.class); private final CharTermAttribute termBufferAttr = addAttribute(CharTermAttribute.class);
نبدأ بالحصول على إشارات إلى بعض السمات التي رأيناها سابقًا. نلحق أسماء الحقول بـ "Attr" حتى يتضح لاحقًا عندما نشير إليها. من المحتمل أن بعض Tokenizer
لا توفر هذه السمات ، لذلك نستخدم addAttribute
للحصول على مراجعنا. addAttribute
مثيل سمة إذا كان مفقودًا ، أو احصل على مرجع مشترك لسمة هذا النوع. لاحظ أن Lucene لا يسمح بمثيلات متعددة من نفس نوع السمة مرة واحدة.
private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;
نظرًا لأن عامل التصفية الخاص بنا سيقدم رمزًا جديدًا لم يكن موجودًا في الدفق الأصلي ، فنحن بحاجة إلى مكان لحفظ حالة هذا الرمز المميز بين الاستدعاءات إلى incrementToken
. نظرًا لأننا نقسم رمزًا مميزًا موجودًا إلى قسمين ، يكفي معرفة الإزاحات ونوع الرمز المميز الجديد فقط. لدينا أيضًا علامة تخبرنا ما إذا كانت المكالمة التالية لـ incrementToken
ستصدر هذا الرمز المميز الإضافي. يوفر Lucene في الواقع زوجًا من الطرق ، captureState
و restoreState
، والتي ستقوم بذلك نيابةً عنك. لكن هذه الأساليب تتضمن تخصيص كائن State
، ويمكن أن تكون في الواقع أكثر تعقيدًا من مجرد إدارة تلك الحالة بنفسك ، لذلك سنتجنب استخدامها.
@Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }
كجزء من تجنبه الشديد للتخصيص ، يمكن لـ Lucene إعادة استخدام مثيلات المرشح. في هذه الحالة ، من المتوقع أن يؤدي استدعاء إعادة التعيين إلى reset
الفلتر إلى حالته الأولية. لذلك هنا ، نقوم ببساطة بإعادة تعيين حقول الرموز الإضافية الخاصة بنا.
@Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...
نحن الآن نصل إلى الأجزاء المثيرة للاهتمام. عندما يتم استدعاء تطبيق incrementToken
، لدينا فرصة لعدم استدعاء incrementToken
في المرحلة السابقة من خط الأنابيب. من خلال القيام بذلك ، نقدم رمزًا جديدًا بشكل فعال ، لأننا لا نسحب رمزًا مميزًا من الرمز Tokenizer
.
بدلاً من ذلك ، نقوم باستدعاء advanceToExtraToken
لإعداد السمات الخاصة برمزنا الإضافي ، وقم بتعيين emitExtraToken
إلى false لتجنب هذا الفرع في المكالمة التالية ، ثم العودة إلى true
، مما يشير إلى توفر رمز مميز آخر.
@Override public boolean incrementToken() throws IOException { ... (emit extra token) ... boolean hasNext = input.incrementToken(); if (hasNext) { char[] buffer = termBufferAttr.buffer(); if (termBuffer.length() > 1) { if (buffer[0] == QUOTE) { splitTermQuoteFirst(); } else if (buffer[termBuffer.length() - 1] == QUOTE) { splitTermWordFirst(); } } else if (termBuffer.length() == 1) { if (buffer[0] == QUOTE) { typeAttr.setType(QUOTE_END_TYPE); } } } return hasNext; }
ما تبقى من incrementToken
سيفعل واحدًا من ثلاثة أشياء مختلفة. تذكر أن termBufferAttr
يُستخدم لفحص محتويات الرمز الذي يأتي عبر الأنبوب:
إذا وصلنا إلى نهاية دفق الرمز المميز (على
hasNext
خاطئ) ، فقد انتهينا ونعود ببساطة.إذا كان لدينا رمز مميز لأكثر من حرف واحد ، وكان أحد هذه الأحرف عبارة عن اقتباس ، فإننا نقسم الرمز المميز.
إذا كان الرمز المميز عبارة عن اقتباس فردي ، فإننا نفترض أنه اقتباس نهائي. لفهم السبب ، لاحظ أن علامات الاقتباس في البداية تظهر دائمًا على يسار الكلمة (أي بدون علامات ترقيم وسيطة) ، بينما علامات الاقتباس النهائية يمكن أن تتبع علامات الترقيم (كما هو الحال في الجملة ،
[He told us to "go back the way we came."]
). في هذه الحالات ، سيكون الاقتباس الختامي بالفعل رمزًا منفصلاً ، ولذا نحتاج فقط إلى تعيين نوعه.
splitTermQuoteFirst
و splitTermWordFirst
بتعيين سمات لجعل الرمز المميز الحالي إما كلمة أو اقتباس ، وإعداد الحقول "الإضافية" للسماح باستهلاك النصف الآخر لاحقًا. الطريقتان متشابهتان ، لذلك سنلقي نظرة على splitTermQuoteFirst
فقط:
private void splitTermQuoteFirst() { int origStart = offsetAttr.startOffset(); int origEnd = offsetAttr.endOffset(); offsetAttr.setOffset(origStart, origStart + 1); typeAttr.setType(QUOTE_START_TYPE); termBufferAttr.setLength(1); prepareExtraTerm(origStart + 1, origEnd, TypeAttribute.DEFAULT_TYPE); }
نظرًا لأننا نريد تقسيم هذا الرمز المميز مع عرض الاقتباس في الدفق أولاً ، فإننا نقوم باقتطاع المخزن المؤقت عن طريق تعيين الطول على واحد (أي حرف واحد ؛ أي الاقتباس). نقوم بتعديل الإزاحة وفقًا لذلك (أي الإشارة إلى الاقتباس في المستند الأصلي) وكذلك تعيين النوع ليكون اقتباس بداية.
prepareExtraTerm
سيحدد الحقول extra*
emitExtraToken
على true. يتم استدعاؤها مع الإزاحة التي تشير إلى الرمز المميز "الإضافي" (أي الكلمة التي تلي الاقتباس).
كامل QuotationTokenFilter
متاح على GitHub.
جانباً ، بينما ينتج هذا المرشح رمزًا مميزًا إضافيًا واحدًا ، يمكن توسيع هذا النهج لتقديم عدد تعسفي من الرموز المميزة الإضافية. ما عليك سوى استبدال الحقول extra*
بمجموعة أو ، الأفضل من ذلك ، مصفوفة ذات طول ثابت إذا كان هناك حد لعدد الرموز المميزة الإضافية التي يمكن إنتاجها. راجع SynonymFilter
PendingInput
الداخلية الخاصة به للحصول على مثال على ذلك.

استهلاك رموز الاقتباس وتمييز الحوار
الآن وقد بذلنا كل هذا الجهد لإضافة تلك الاقتباسات إلى تدفق الرمز المميز ، يمكننا استخدامها لتحديد أقسام الحوار في النص.
نظرًا لأن هدفنا النهائي هو تعديل نتائج البحث بناءً على ما إذا كانت المصطلحات جزءًا من الحوار أم لا ، فنحن بحاجة إلى إرفاق البيانات الوصفية بهذه المصطلحات. يوفر Lucene PayloadAttribute
لهذا الغرض. الحمولات عبارة عن مصفوفات بايت يتم تخزينها جنبًا إلى جنب مع المصطلحات الموجودة في الفهرس ، ويمكن قراءتها لاحقًا أثناء البحث. هذا يعني أن علمنا سيشغل بايتًا كاملاً بشكل هدر ، لذلك يمكن تنفيذ الحمولات الإضافية كعلامات بت لتوفير مساحة.
يوجد أدناه عامل تصفية جديد ، DialoguePayloadTokenFilter
، والذي تمت إضافته إلى نهاية خط أنابيب التحليل. يتم إرفاق الحمولة التي تشير إلى ما إذا كان الرمز المميز جزءًا من الحوار أم لا.
public class DialoguePayloadTokenFilter extends TokenFilter { private final TypeAttribute typeAttr = getAttribute(TypeAttribute.class); private final PayloadAttribute payloadAttr = addAttribute(PayloadAttribute.class); private static final BytesRef PAYLOAD_DIALOGUE = new BytesRef(new byte[] { 1 }); private static final BytesRef PAYLOAD_NOT_DIALOGUE = new BytesRef(new byte[] { 0 }); private boolean withinDialogue; protected DialoguePayloadTokenFilter(TokenStream input) { super(input); } @Override public void reset() throws IOException { this.withinDialogue = false; super.reset(); } @Override public boolean incrementToken() throws IOException { boolean hasNext = input.incrementToken(); while(hasNext) { boolean isStartQuote = QuotationTokenFilter .QUOTE_START_TYPE.equals(typeAttr.type()); boolean isEndQuote = QuotationTokenFilter .QUOTE_END_TYPE.equals(typeAttr.type()); if (isStartQuote) { withinDialogue = true; hasNext = input.incrementToken(); } else if (isEndQuote) { withinDialogue = false; hasNext = input.incrementToken(); } else { break; } } if (hasNext) { payloadAttr.setPayload(withinDialogue ? PAYLOAD_DIALOGUE : PAYLOAD_NOT_DIALOGUE); } return hasNext; } }
نظرًا لأن هذا المرشح يحتاج فقط إلى الاحتفاظ بقطعة واحدة من الحالة ، فإنه withinDialogue
، فهو أبسط بكثير. يشير اقتباس البداية إلى أننا الآن داخل قسم من الحوار ، بينما يشير الاقتباس الختامي إلى أن قسم الحوار قد انتهى. في كلتا الحالتين ، يتم تجاهل رمز الاقتباس عن طريق إجراء استدعاء ثانٍ لـ incrementToken
، لذلك في الواقع ، لا تتدفق رموز اقتباس البدء أو علامات اقتباس النهاية أبدًا بعد هذه المرحلة في خط الأنابيب.
على سبيل المثال ، سيقوم DialoguePayloadTokenFilter
بتحويل تدفق الرمز المميز:
[the], [program], [printed], ["], [hello], [world], ["]`
في هذا الدفق الجديد:
[the][0], [program][0], [printed][0], [hello][1], [world][1]
ربط الرموز والفلاتر معًا
Analyzer
مسؤول عن تجميع خط أنابيب التحليل ، عادةً عن طريق الجمع بين Tokenizer
وسلسلة من TokenFilter
s. يمكن Analyzer
أيضًا تحديد كيفية إعادة استخدام خط الأنابيب هذا بين التحليلات. لا داعي للقلق بشأن ذلك لأن مكوناتنا لا تتطلب أي شيء باستثناء استدعاء reset()
بين الاستخدامات ، وهو ما سيفعله Lucene دائمًا. نحتاج فقط إلى القيام بالتجميع من خلال تنفيذ Analyzer#createComponents(String)
:
public class DialogueAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String fieldName) { QuotationTokenizer tokenizer = new QuotationTokenizer(); TokenFilter filter = new QuotationTokenFilter(tokenizer); filter = new LowerCaseFilter(filter); filter = new StopFilter(filter, StopAnalyzer.ENGLISH_STOP_WORDS_SET); filter = new DialoguePayloadTokenFilter(filter); return new TokenStreamComponents(tokenizer, filter); } }
كما رأينا سابقًا ، تحتوي المرشحات على مرجع يعود إلى المرحلة السابقة في خط الأنابيب ، وهكذا نقوم بإنشاء مثيل لها. نقوم أيضًا بإدخال بعض المرشحات من StandardAnalyzer
: LowerCaseFilter
و StopFilter
. يجب أن يأتي هذان العنصران بعد QuotationTokenFilter
لضمان فصل أي اقتباسات. يمكننا أن نكون أكثر مرونة في وضعنا لـ DialoguePayloadTokenFilter
، لأنه في أي مكان بعد QuotationTokenFilter
بالغرض. نضعها بعد StopFilter
لتجنب إضاعة الوقت في حقن حمولة الحوار في كلمات التوقف التي ستتم إزالتها في النهاية.
في ما يلي تصور لخط الأنابيب الجديد قيد التشغيل (باستثناء أجزاء خط الأنابيب القياسي التي أزلناها أو رأيناها بالفعل):
يمكن الآن استخدام DialogueAnalyzer
مثل أي Analyzer
أسهم آخر ، والآن يمكننا بناء الفهرس والانتقال إلى البحث.
البحث عن النص الكامل للحوار
إذا أردنا البحث فقط في الحوار ، كان بإمكاننا ببساطة تجاهل جميع الرموز المميزة خارج الاقتباس وكنا قد فعلنا ذلك. بدلاً من ذلك ، من خلال ترك جميع الرموز المميزة الأصلية كما هي ، منحنا أنفسنا المرونة لإجراء الاستعلامات التي تأخذ الحوار في الاعتبار ، أو التعامل مع الحوار مثل أي جزء آخر من النص.
تم توثيق أساسيات الاستعلام عن فهرس Lucene جيدًا. لأغراضنا ، يكفي أن نعرف أن الاستعلامات تتكون من كائنات Term
عالقة مع عوامل تشغيل مثل MUST
أو SHOULD
، جنبًا إلى جنب مع المستندات المطابقة بناءً على هذه الشروط. ثم يتم تسجيل مستندات المطابقة بناءً على كائن Similarity
قابل للتكوين ، ويمكن ترتيب هذه النتائج حسب الدرجة أو تصفيتها أو تحديدها. على سبيل المثال ، يسمح لنا Lucene بإجراء استعلام عن أهم عشرة مستندات يجب أن تحتوي على كلا المصطلحين [hello]
و [world]
.
يمكن إجراء تخصيص نتائج البحث بناءً على الحوار عن طريق تعديل درجة المستند بناءً على الحمولة. ستكون نقطة الامتداد الأولى لهذا في " Similarity
" ، وهو المسؤول عن وزن وتسجيل شروط المطابقة.
التشابه والتسجيل
ستستخدم الاستعلامات ، افتراضيًا ، DefaultSimilarity
الافتراضي ، الذي يقيِّم المصطلحات بناءً على معدل تكرارها في المستند. إنها نقطة تمديد جيدة لضبط الأوزان ، لذلك نقوم بتوسيعها أيضًا لتسجيل المستندات بناءً على الحمولة. يتم توفير الطريقة DefaultSimilarity#scorePayload
لهذا الغرض:
public final class DialogueAwareSimilarity extends DefaultSimilarity { @Override public float scorePayload(int doc, int start, int end, BytesRef payload) { if (payload.bytes[payload.offset] == 0) { return 0.0f; } return 1.0f; } }
DialogueAwareSimilarity
ببساطة بتسجيل الحمولات غير المحاورة على أنها صفر. نظرًا لأنه يمكن مطابقة كل Term
عدة مرات ، فمن المحتمل أن يكون له العديد من درجات الحمولة الصافية. تفسير هذه الدرجات حتى تنفيذ Query
.
انتبه جيدًا إلى BytesRef
الذي يحتوي على الحمولة: يجب أن نتحقق من البايت عند offset
، حيث لا يمكننا افتراض أن مصفوفة البايت هي نفس الحمولة التي قمنا بتخزينها سابقًا. عند قراءة الفهرس ، لن يضيع Lucene الذاكرة في تخصيص مصفوفة بايت منفصلة فقط من أجل الاستدعاء لتسجيل scorePayload
، لذلك نحصل على مرجع في مصفوفة بايت موجودة. عند الترميز مقابل Lucene API ، من المفيد أن تضع في اعتبارك أن الأداء هو الأولوية ، قبل راحة المطور بكثير.
الآن بعد أن أصبح لدينا تطبيق Similarity
الجديد الخاص بنا ، يجب تعيينه بعد ذلك على IndexSearcher
المستخدم لتنفيذ الاستعلامات:
IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());
الاستفسارات والشروط
الآن بعد أن IndexSearcher
بنا من تسجيل الحمولات ، يتعين علينا أيضًا إنشاء استعلام يدرك الحمولة. يمكن استخدام PayloadTermQuery
لمطابقة Term
واحد أثناء التحقق أيضًا من حمولات تلك التطابقات:
PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());
يطابق هذا الاستعلام المصطلح [hello]
داخل حقل النص (تذكر أن هذا هو المكان الذي نضع فيه محتويات المستند). يجب علينا أيضًا توفير وظيفة لحساب نتيجة الحمولة النهائية من جميع مباريات المصطلح ، لذلك نقوم بتوصيل AveragePayloadFunction
، والتي تُحسب متوسط جميع درجات الحمولة. على سبيل المثال ، إذا ظهر المصطلح [hello]
داخل الحوار مرتين وفي الحوار الخارجي مرة واحدة ، فستكون نتيجة الحمولة النهائية ²⁄₃. يتم ضرب نتيجة الحمولة النهائية هذه مع النتيجة التي يوفرها DefaultSimilarity
للمستند بأكمله.
نحن نستخدم المتوسط لأننا نرغب في تقليل التركيز على نتائج البحث حيث تظهر العديد من المصطلحات خارج الحوار ، ولإنتاج درجة صفر للمستندات بدون أي مصطلحات في الحوار على الإطلاق.
يمكننا أيضًا إنشاء العديد من كائنات PayloadTermQuery
باستخدام BooleanQuery
إذا أردنا البحث عن مصطلحات متعددة واردة في الحوار (لاحظ أن ترتيب المصطلحات غير ذي صلة في هذا الاستعلام ، على الرغم من أن أنواع الاستعلام الأخرى تدرك الموضع):
PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);
عند تنفيذ هذا الاستعلام ، يمكننا أن نرى كيف تعمل بنية الاستعلام وتنفيذ التشابه معًا:
تنفيذ الاستعلام والشرح
لتنفيذ الاستعلام ، نقوم بتسليمه إلى IndexSearcher
:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();
تُستخدم كائنات Collector
لإعداد مجموعة المستندات المطابقة.
يمكن تكوين المجمعات لتحقيق مجموعة من الفرز والتقييد والتصفية. للحصول على ، على سبيل المثال ، أفضل عشرة مستندات تسجيل تحتوي على مصطلح واحد على الأقل في الحوار ، نقوم بدمج TopScoreDocCollector
و PositiveScoresOnlyCollector
. يضمن أخذ الدرجات الإيجابية فقط تصفية النتائج الصفرية (أي تلك التي ليس لها شروط في الحوار).
لرؤية هذا الاستعلام قيد التنفيذ ، يمكننا تنفيذه ، ثم استخدام IndexSearcher#explain
لمعرفة كيفية تسجيل المستندات الفردية:
for (ScoreDoc result : topDocs.scoreDocs) { Document doc = searcher.doc(result.doc, Collections.singleton("title")); System.out.println("--- document " + doc.getField("title").stringValue() + " ---"); System.out.println(this.searcher.explain(query, result.doc)); }
هنا ، نكرر معرفات المستندات في TopDocs
التي حصلنا عليها من خلال البحث. نستخدم أيضًا IndexSearcher#doc
لاسترداد حقل العنوان للعرض. بالنسبة لاستعلامنا عن "hello"
، ينتج عن هذا:
--- Document whelv10.txt --- 0.072256625 = (MATCH) btq, product of: 0.072256625 = weight(body:hello in 7336) [DialogueAwareSimilarity], result of: 0.072256625 = fieldWeight in 7336, product of: 2.345208 = tf(freq=5.5), with freq of: 5.5 = phraseFreq=5.5 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.009765625 = fieldNorm(doc=7336) 1.0 = AveragePayloadFunction.docScore() --- Document daved10.txt --- 0.061311778 = (MATCH) btq, product of: 0.061311778 = weight(body:hello in 6873) [DialogueAwareSimilarity], result of: 0.061311778 = fieldWeight in 6873, product of: 3.3166249 = tf(freq=11.0), with freq of: 11.0 = phraseFreq=11.0 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.005859375 = fieldNorm(doc=6873) 1.0 = AveragePayloadFunction.docScore() ...
على الرغم من أن الناتج مليء بالمصطلحات ، يمكننا أن نرى كيف تم استخدام تطبيق Similarity
المخصص في تسجيل النقاط ، وكيف أنتجت MaxPayloadFunction
مضاعفًا قدره 1.0
لهذه التطابقات. هذا يعني أن الحمولة تم تحميلها وتسجيلها ، وأن جميع مطابقات "Hello"
حدثت في حوار ، وبالتالي فإن هذه النتائج موجودة في الجزء العلوي حيث نتوقعها.
وتجدر الإشارة أيضًا إلى أن فهرس مشروع جوتنبرج ، مع الحمولات ، يصل حجمه إلى ما يقرب من أربعة غيغابايت ، ومع ذلك ، تظهر الاستفسارات على جهاز التطوير المتواضع الخاص بي على الفور. لم نضحي بأي سرعة لتحقيق أهداف البحث لدينا.
تغليف
Lucene هي مكتبة بحث عن نص كامل قوية ومُصممة لغرض تأخذ دفقًا خامًا من الأحرف ، وتجمعها في رموز ، وتستمر في استخدامها كمصطلحات في فهرس. يمكنه الاستعلام بسرعة عن هذا الفهرس وتقديم نتائج مرتبة ، ويوفر فرصة كبيرة للتوسع مع الحفاظ على الكفاءة.
باستخدام Lucene مباشرة في تطبيقاتنا ، أو كجزء من خادم ، يمكننا إجراء عمليات بحث عن النص الكامل في الوقت الفعلي عبر غيغابايت من المحتوى. علاوة على ذلك ، عن طريق التحليل والتسجيل المخصصين ، يمكننا الاستفادة من الميزات الخاصة بالمجال في مستنداتنا لتحسين ملاءمة النتائج أو الاستعلامات المخصصة.
تتوفر قوائم التعليمات البرمجية الكاملة لبرنامج Lucene التعليمي هذا على GitHub. يحتوي الريبو على تطبيقين: LuceneIndexerApp
لبناء الفهرس ، و LuceneQueryApp
لتنفيذ الاستعلامات.
تحتوي مجموعة ملفات Project Gutenberg ، والتي يمكن الحصول عليها كصورة قرص عبر BitTorrent ، على الكثير من الكتب التي تستحق القراءة (إما مع Lucene ، أو بالطريقة القديمة فقط).
فهرسة سعيدة!