ค้นหาข้อความทั้งหมดของบทสนทนาด้วย Apache Lucene: บทช่วยสอน

เผยแพร่แล้ว: 2022-03-11

Apache Lucene เป็นไลบรารี Java ที่ใช้สำหรับการค้นหาข้อความแบบเต็มของเอกสาร และเป็นแกนหลักของเซิร์ฟเวอร์การค้นหา เช่น Solr และ Elasticsearch นอกจากนี้ยังสามารถฝังลงในแอปพลิเคชัน Java เช่นแอป Android หรือเว็บแบ็กเอนด์

แม้ว่าตัวเลือกการกำหนดค่าของ Lucene นั้นมีมากมาย แต่ก็มีจุดประสงค์เพื่อใช้โดยผู้พัฒนาฐานข้อมูลในคลังข้อความทั่วไป ถ้าเอกสารของคุณมีโครงสร้างหรือประเภทของเนื้อหาเฉพาะ คุณสามารถใช้ประโยชน์จากสิ่งใดสิ่งหนึ่งเพื่อปรับปรุงคุณภาพการค้นหาและความสามารถในการสืบค้น

ค้นหาข้อความเต็มด้วย apache lucene

ตัวอย่างของการปรับแต่งประเภทนี้ ในบทช่วยสอนของ Lucene เราจะจัดทำดัชนีคลังข้อมูลของ Project Gutenberg ซึ่งมี e-book ฟรีนับพันเล่ม เรารู้ว่าหนังสือเหล่านี้เป็นนวนิยายหลายเล่ม สมมติว่าเราสนใจ บทสนทนา ในนวนิยายเหล่านี้เป็นพิเศษ ทั้ง Lucene, Elasticsearch และ Solr ไม่มีเครื่องมือสำเร็จรูปในการระบุเนื้อหาเป็นบทสนทนา ในความเป็นจริง พวกเขาจะละเว้นเครื่องหมายวรรคตอนในช่วงแรกของการวิเคราะห์ข้อความ ซึ่งขัดต่อความสามารถในการระบุส่วนของข้อความที่เป็นบทสนทนา ดังนั้นมันจึงเป็นช่วงเริ่มต้นที่การปรับแต่งของเราจะต้องเริ่มต้นขึ้น

ชิ้นส่วนของท่อวิเคราะห์ Apache Lucene

JavaDoc การวิเคราะห์ Lucene ให้ภาพรวมที่ดีของชิ้นส่วนที่เคลื่อนไหวทั้งหมดในไปป์ไลน์การวิเคราะห์ข้อความ

ในระดับสูง คุณสามารถนึกถึงไปป์ไลน์การวิเคราะห์ว่าใช้สตรีมอักขระแบบดิบๆ ในตอนเริ่มต้น และสร้าง "เงื่อนไข" ที่สัมพันธ์กับคำคร่าวๆ ในตอนท้าย

ไปป์ไลน์การวิเคราะห์มาตรฐานสามารถมองเห็นได้ดังนี้:

ท่อวิเคราะห์ Lucene

เราจะดูวิธีปรับแต่งไปป์ไลน์นี้เพื่อจดจำพื้นที่ของข้อความที่มีเครื่องหมายอัญประกาศคู่ ซึ่งฉันจะเรียกบทสนทนา จากนั้นจึงเพิ่มการจับคู่ที่เกิดขึ้นเมื่อค้นหาในภูมิภาคเหล่านั้น

การอ่านตัวอักษร

เมื่อเริ่มเพิ่มเอกสารลงในดัชนี อักขระจะถูกอ่านจาก Java InputStream และสามารถมาจากไฟล์ ฐานข้อมูล การเรียกใช้บริการเว็บ ฯลฯ ในการสร้างดัชนีสำหรับ Project Gutenberg เราดาวน์โหลด e-book และ สร้างแอปพลิเคชันขนาดเล็กเพื่ออ่านไฟล์เหล่านี้และเขียนลงในดัชนี การสร้างดัชนี 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);

เราจะเห็นได้ว่า e-book แต่ละเล่มจะสอดคล้องกับ Document Lucene ฉบับเดียว ดังนั้นในภายหลัง ผลการค้นหาของเราจะเป็นรายการหนังสือที่ตรงกัน Store.YES ระบุว่าเราเก็บฟิลด์ ชื่อ ซึ่งเป็นเพียงชื่อไฟล์ อย่างไรก็ตาม เราไม่ต้องการเก็บ เนื้อความ ของ ebook เนื่องจากไม่จำเป็นในการค้นหาและจะเปลืองเนื้อที่ดิสก์เท่านั้น

การอ่านสตรีมจริงเริ่มต้นด้วย addDocument IndexWriter ดึงโทเค็นจากส่วนท้ายของไปป์ไลน์ การดึงนี้ดำเนินการย้อนกลับผ่านไพพ์จนกระทั่งสเตจแรก Tokenizer อ่านจาก InputStream

โปรดทราบว่าเราไม่ปิดสตรีม เนื่องจาก Lucene จัดการเรื่องนี้ให้เรา

ตัวละคร Tokenizing

Lucene StandardTokenizer ทิ้งเครื่องหมายวรรคตอน ดังนั้นการปรับแต่งของเราจึงเริ่มต้นที่นี่ เนื่องจากเราจำเป็นต้องรักษาคำพูด

เอกสารประกอบสำหรับ StandardTokenizer ขอเชิญคุณคัดลอกซอร์สโค้ดและปรับแต่งให้เข้ากับความต้องการของคุณ แต่โซลูชันนี้จะซับซ้อนโดยไม่จำเป็น แต่เราจะขยาย CharTokenizer ซึ่งอนุญาตให้คุณระบุอักขระเพื่อ "ยอมรับ" โดยที่อักขระที่ไม่ "ยอมรับ" จะถือว่าเป็นตัวคั่นระหว่างโทเค็นและโยนทิ้งไป เนื่องจากเราสนใจคำและใบเสนอราคารอบๆ ตัว Tokenizer แบบกำหนดเองของเราจึงเป็นเพียง:

 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 เรียบง่ายและทำความสะอาด โทเค็นสตรีมต่อไปในไปป์ไลน์

การแยกโทเค็นโดยใช้ตัวกรอง

หลังจาก tokenizer จะมีชุดของออบเจ็กต์ TokenFilter โปรดทราบว่า ตัวกรอง นั้นมีการเรียกชื่อผิดเล็กน้อย เนื่องจาก TokenFilter สามารถเพิ่ม ลบ หรือแก้ไขโทเค็นได้

คลาสตัวกรองจำนวนมากที่ Lucene จัดเตรียมไว้ให้คาดหวังคำเดียว ดังนั้นจึงไม่เกิดโทเค่นคำและอัญประกาศผสมกันไหลเข้ามา ดังนั้น การปรับแต่งต่อไปของบทช่วยสอน Lucene จะต้องเป็นการแนะนำตัวกรองที่จะล้างผลลัพธ์ของ QuotationTokenizer

การล้างข้อมูลนี้จะเกี่ยวข้องกับการผลิตโทเค็น ใบเสนอราคาเริ่มต้น เพิ่มเติม หากใบเสนอราคาปรากฏขึ้นที่จุดเริ่มต้นของคำ หรือโทเค็นการ เสนอราคาสิ้นสุด หากใบเสนอราคาปรากฏขึ้นที่ส่วนท้าย เราจะละเว้นการจัดการคำที่ยกมาเพียงคำเดียวเพื่อความเรียบง่าย

การสร้างคลาสย่อย TokenFilter เกี่ยวข้องกับการใช้เมธอดเดียว: incrementToken เมธอดนี้ต้องเรียก incrementToken บนตัวกรองก่อนหน้าในไพพ์ จากนั้นจัดการผลลัพธ์ของการเรียกนั้นเพื่อทำงานใดๆ ก็ตามที่ตัวกรองรับผิดชอบ ผลลัพธ์ของ incrementToken มีให้ผ่านทางอ็อบเจ็กต์ Attribute ซึ่งอธิบายสถานะปัจจุบันของการประมวลผลโทเค็น หลังจากที่เราใช้ incrementToken ส่งคืน คาดว่าแอตทริบิวต์จะได้รับการจัดการเพื่อตั้งค่าโทเค็นสำหรับตัวกรองถัดไป (หรือดัชนีหากเราอยู่ที่ส่วนท้ายของไพพ์)

คุณลักษณะที่เราสนใจ ณ จุดนี้ในไปป์ไลน์คือ:

  • CharTermAttribute : ประกอบด้วยบัฟเฟอร์ char[] ที่เก็บอักขระของโทเค็นปัจจุบัน เราจะต้องจัดการสิ่งนี้เพื่อลบใบเสนอราคา หรือเพื่อสร้างโทเค็นของใบเสนอราคา

  • TypeAttribute : ประกอบด้วย "ประเภท" ของโทเค็นปัจจุบัน เนื่องจากเรากำลังเพิ่มราคาเริ่มต้นและสิ้นสุดให้กับสตรีมโทเค็น เราจะแนะนำประเภทใหม่สองประเภทโดยใช้ตัวกรองของเรา

  • OffsetAttribute : Lucene สามารถเลือกจัดเก็บข้อมูลอ้างอิงไปยังตำแหน่งของเงื่อนไขในเอกสารต้นฉบับได้ การอ้างอิงเหล่านี้เรียกว่า "ออฟเซ็ต" ซึ่งเป็นเพียงดัชนีเริ่มต้นและสิ้นสุดในสตรีมอักขระดั้งเดิม หากเราเปลี่ยนบัฟเฟอร์ใน CharTermAttribute ให้ชี้ไปที่สตริงย่อยของโทเค็น เราต้องปรับการชดเชยเหล่านี้ให้สอดคล้องกัน

คุณอาจสงสัยว่าเหตุใด API สำหรับจัดการสตรีมโทเค็นจึงซับซ้อน และโดยเฉพาะอย่างยิ่ง เหตุใดเราจึงไม่สามารถทำบางอย่างเช่น 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 ใช้เพื่อตรวจสอบเนื้อหาของโทเค็นที่มาจากไพพ์:

  1. หากเราถึงจุดสิ้นสุดของสตรีมโทเค็น (เช่น hasNext เป็นเท็จ) แสดงว่าเสร็จแล้วและเพียงแค่ส่งคืน

  2. หากเรามีโทเค็นมากกว่าหนึ่งอักขระ และหนึ่งในอักขระเหล่านั้นเป็นเครื่องหมายคำพูด เราจะแยกโทเค็นนั้นออก

  3. หากโทเค็นเป็นใบเสนอราคาเดี่ยว เราจะถือว่าโทเค็นนั้นเป็นใบเสนอราคาสิ้นสุด เพื่อให้เข้าใจว่าทำไม ให้สังเกตว่าเครื่องหมายคำพูดเริ่มต้นมักจะปรากฏอยู่ทางด้านซ้ายของคำเสมอ (เช่น ไม่มีเครื่องหมายวรรคตอนกลาง) ในขณะที่เครื่องหมายคำพูดลงท้ายสามารถตามด้วยเครื่องหมายวรรคตอน (เช่น ในประโยค [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 ให้เป็นจริง มันถูกเรียกด้วยการออฟเซ็ตที่ชี้ไปที่โทเค็น "พิเศษ" (เช่น คำที่ตามหลังเครื่องหมายคำพูด)

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]

ผูก Tokenizers และตัวกรองเข้าด้วยกัน

ตัว Analyzer มีหน้าที่ในการประกอบไปป์ไลน์การวิเคราะห์ โดยทั่วไปจะรวม Tokenizer เข้ากับชุดของ TokenFilter ตัว 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 เพื่อหลีกเลี่ยงไม่ให้เสียเวลาใส่ข้อมูลการสนทนาลงในคำหยุดที่จะถูกลบออกในที่สุด

นี่คือภาพการทำงานของไปป์ไลน์ใหม่ของเรา (ลบด้วยส่วนต่างๆ ของไปป์ไลน์มาตรฐานที่เราได้ลบออกหรือเห็นแล้ว):

การสร้างภาพไปป์ไลน์ใหม่ใน apache lucene

ตอนนี้สามารถใช้ 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 เดียวในขณะที่ตรวจสอบ payloads ของการจับคู่เหล่านั้น:

 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)); }

ที่นี่ เราวนซ้ำ ID เอกสารใน 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" เกิดขึ้นในกล่องโต้ตอบ ดังนั้นผลลัพธ์เหล่านี้จึงอยู่ที่ด้านบนสุดที่เราคาดหวัง

นอกจากนี้ยังควรค่าแก่การชี้ให้เห็นว่าดัชนีสำหรับ Project Gutenberg ที่มีเพย์โหลดมีขนาดเกือบสี่กิกะไบต์ แต่ในเครื่องพัฒนาเจียมเนื้อเจียมตัวของฉัน การสืบค้นจะเกิดขึ้นทันที เราไม่ได้เสียสละความเร็วใดๆ เพื่อให้บรรลุเป้าหมายการค้นหาของเรา

ห่อ

Lucene เป็นไลบรารีค้นหาข้อความแบบเต็มที่มีประสิทธิภาพและสร้างขึ้นเพื่อวัตถุประสงค์ที่นำกระแสอักขระดิบมารวมกลุ่มเป็นโทเค็น และคงไว้เป็นเงื่อนไขในดัชนี สามารถสืบค้นดัชนีนั้นได้อย่างรวดเร็วและให้ผลลัพธ์ที่ได้รับการจัดอันดับ และให้โอกาสเพียงพอสำหรับการขยายในขณะที่ยังคงประสิทธิภาพไว้

ด้วยการใช้ Lucene โดยตรงในแอปพลิเคชันของเรา หรือเป็นส่วนหนึ่งของเซิร์ฟเวอร์ เราสามารถทำการค้นหาข้อความแบบเต็มในแบบเรียลไทม์ผ่านเนื้อหาหลายกิกะไบต์ นอกจากนี้ ด้วยการวิเคราะห์และการให้คะแนนแบบกำหนดเอง เราสามารถใช้ประโยชน์จากคุณลักษณะเฉพาะโดเมนในเอกสารของเรา เพื่อปรับปรุงความเกี่ยวข้องของผลลัพธ์หรือการสืบค้นข้อมูลที่กำหนดเอง

รายการรหัสแบบเต็มสำหรับบทช่วยสอน Lucene นี้มีอยู่ใน GitHub repo มีสองแอปพลิเคชัน: LuceneIndexerApp สำหรับสร้างดัชนี และ LuceneQueryApp สำหรับดำเนินการค้นหา

คลังข้อมูลของ Project Gutenberg ซึ่งสามารถรับเป็นภาพดิสก์ผ่าน BitTorrent มีหนังสือน่าอ่านมากมาย (ไม่ว่าจะกับ Lucene หรือแบบเก่า)

มีความสุขในการจัดทำดัชนี!